From a1baddf71867de4ed6855649b901adb93da291fc Mon Sep 17 00:00:00 2001 From: Jacek Tomaszewski Date: Tue, 17 Mar 2026 20:52:13 +0100 Subject: [PATCH 1/3] feat(slack): add slackUsers config option for user filtering Add optional slackUsers config to filter which users the bot responds to. User display names are resolved to IDs on startup via users.search API. Messages from users not in the allowed list are skipped. Co-Authored-By: Claude Opus 4.5 --- README.md | 1 + SPEC.md | 3 +++ src/config.ts | 1 + src/index.ts | 2 +- src/platforms/slack.ts | 27 +++++++++++++++++++++++++++ src/slack-api.ts | 6 ++++++ 6 files changed, 39 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fb46969..fc208d0 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ npm run dev "claude": { "model": "claude-sonnet-4-20250514" }, "platforms": ["slack"], "slackChannels": ["#agent-playground"], // optional — omit to respond in all channels + "slackUsers": ["Jacek Tomaszewski"], // optional — omit to respond to all users "mcpServers": { "slack": { "command": "npx", diff --git a/SPEC.md b/SPEC.md index ed57f60..19c3349 100644 --- a/SPEC.md +++ b/SPEC.md @@ -44,6 +44,7 @@ The ingestion mechanism is an implementation detail of each platform adapter. Th "claude": { "model": "claude-sonnet-4-20250514" }, "platforms": ["slack"], "slackChannels": ["#channel"], // optional — if omitted, responds in all channels + "slackUsers": ["Jacek Tomaszewski"], // optional — if omitted, responds to all users "mcpServers": { "name": { "command": "npx", @@ -63,6 +64,8 @@ The ingestion mechanism is an implementation detail of each platform adapter. Th ### Platform: Slack (`src/platforms/slack.ts`) - Receives messages via best available transport (WebSocket > webhook > polling) - Detects bot mentions (resolves bot user ID on startup via `users_search`) +- Resolves allowed user display names to IDs on startup (if `slackUsers` configured) +- Filters messages to only respond to allowed users (if configured) - Manages reactions (👀 typing, ✅ done) as persistent state - Sends thread replies via MCP `conversations_add_message` diff --git a/src/config.ts b/src/config.ts index 5b28024..e7465f0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,7 @@ export interface AppConfig { claude: { model: string }; platforms: string[]; slackChannels?: string[]; + slackUsers?: string[]; mcpServers: Record; } diff --git a/src/index.ts b/src/index.ts index 5ecc6cf..b2e9754 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,7 +27,7 @@ async function main(): Promise { if (!xoxc || !xoxd) throw new Error('SLACK_XOXC and SLACK_XOXD required'); const slackApi = new SlackApi(xoxc, xoxd); - const adapter = new SlackAdapter(agent, slackApi, config.slackChannels); + const adapter = new SlackAdapter(agent, slackApi, config.slackChannels, config.slackUsers); await adapter.start(); adapters.push(adapter); } diff --git a/src/platforms/slack.ts b/src/platforms/slack.ts index 777fee2..273f6f6 100644 --- a/src/platforms/slack.ts +++ b/src/platforms/slack.ts @@ -6,11 +6,13 @@ export class SlackAdapter { private processing = new Set(); private timer: ReturnType | null = null; private channelIds: Set | null = null; + private allowedUserIds: Set | null = null; constructor( private agent: Agent, private slackApi: SlackApi, private channels?: string[], + private users?: string[], private pollIntervalMs = 10_000, ) {} @@ -23,6 +25,10 @@ export class SlackAdapter { await this.resolveChannelIds(); } + if (this.users?.length) { + await this.resolveUserIds(); + } + await this.poll(); // initial poll this.timer = setInterval(() => this.poll(), this.pollIntervalMs); console.log(`[slack] Polling every ${this.pollIntervalMs / 1000}s`); @@ -53,6 +59,24 @@ export class SlackAdapter { console.log(`[slack] Watching channels: ${[...this.channelIds].join(', ')}`); } + private async resolveUserIds(): Promise { + this.allowedUserIds = new Set(); + + for (const name of this.users!) { + const res = await this.slackApi.usersSearch(name); + const user = res.results.find( + u => u.profile.real_name === name || u.profile.display_name === name + ); + if (user) { + this.allowedUserIds.add(user.id); + } else { + console.warn(`[slack] User not found: ${name}`); + } + } + + console.log(`[slack] Allowed users: ${[...this.allowedUserIds].join(', ')}`); + } + private async poll(): Promise { try { const result = await this.slackApi.searchMessages(`<@${this.botUserId}>`); @@ -71,6 +95,9 @@ export class SlackAdapter { // Channel filter if (this.channelIds && !this.channelIds.has(channelId)) continue; + // User filter + if (this.allowedUserIds && !this.allowedUserIds.has(msg.user)) continue; + // Currently processing const key = `${channelId}:${msg.ts}`; if (this.processing.has(key)) continue; diff --git a/src/slack-api.ts b/src/slack-api.ts index 9352203..09b733a 100644 --- a/src/slack-api.ts +++ b/src/slack-api.ts @@ -101,4 +101,10 @@ export class SlackApi { if (cursor) params.cursor = cursor; return this.call('conversations.list', params); } + + async usersSearch(query: string): Promise<{ + results: Array<{ id: string; name: string; profile: { real_name?: string; display_name?: string } }>; + }> { + return this.call('users.search', { query, count: '20' }); + } } From 5e03dfdd14e7829a872f9850d4b6781c732ddd2b Mon Sep 17 00:00:00 2001 From: Jacek Tomaszewski Date: Tue, 17 Mar 2026 20:54:49 +0100 Subject: [PATCH 2/3] test(e2e): add user filter test for slackUsers config Tests that mentions from users not in the allowed list are ignored. Skipped if SLACK_TEST_BLOCKED_* env vars are not set. Co-Authored-By: Claude Opus 4.5 --- tests/e2e.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/e2e.ts b/tests/e2e.ts index 47980be..68cedb2 100644 --- a/tests/e2e.ts +++ b/tests/e2e.ts @@ -114,6 +114,27 @@ async function main(): Promise { if (reacted) throw new Error('Agent reacted to non-mention message'); }); + // --- Test 2b: User filter - non-allowed user ignored (if slackUsers configured) --- + // This test only runs if SLACK_TEST_BLOCKED_* env vars are set (a third user not in slackUsers) + if (process.env.SLACK_TEST_BLOCKED_XOXC && process.env.SLACK_TEST_BLOCKED_XOXD) { + await test('mention from non-allowed user is ignored', async () => { + const blockedApi = new SlackApi( + process.env.SLACK_TEST_BLOCKED_XOXC!, + process.env.SLACK_TEST_BLOCKED_XOXD!, + ); + + const text = `<@${agentUserId}> blocked user test ${Date.now()}`; + const { ts } = await blockedApi.chatPostMessage(channelId, text); + console.log(` Posted mention from blocked user ts=${ts}`); + + await new Promise(r => setTimeout(r, 25_000)); + const reacted = await hasReaction(channelId, ts, 'eyes') || await hasReaction(channelId, ts, 'white_check_mark'); + if (reacted) throw new Error('Agent reacted to mention from non-allowed user'); + }); + } else { + console.log('⏭️ Skipping user filter test (SLACK_TEST_BLOCKED_* not set)'); + } + // --- Test 3: Multiple mentions in a thread each get separate replies --- await test('multiple mentions in thread get separate replies', async () => { const nonce = Date.now(); From aaa9639e126062c24dc85e8457ea6dce99b88809 Mon Sep 17 00:00:00 2001 From: Jacek Tomaszewski Date: Tue, 17 Mar 2026 21:14:38 +0100 Subject: [PATCH 3/3] fix(slack): use users.list instead of users.search for user resolution users.search requires specific Slack plan/scopes. Use users.list with pagination to find configured users by real_name or display_name. Co-Authored-By: Claude Opus 4.5 --- src/platforms/slack.ts | 23 ++++++++++++++--------- src/slack-api.ts | 9 ++++++--- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/platforms/slack.ts b/src/platforms/slack.ts index 273f6f6..466396d 100644 --- a/src/platforms/slack.ts +++ b/src/platforms/slack.ts @@ -60,18 +60,23 @@ export class SlackAdapter { } private async resolveUserIds(): Promise { + const names = new Set(this.users!); this.allowedUserIds = new Set(); - for (const name of this.users!) { - const res = await this.slackApi.usersSearch(name); - const user = res.results.find( - u => u.profile.real_name === name || u.profile.display_name === name - ); - if (user) { - this.allowedUserIds.add(user.id); - } else { - console.warn(`[slack] User not found: ${name}`); + let cursor: string | undefined; + do { + const res = await this.slackApi.usersList(cursor); + for (const user of res.members) { + if (names.has(user.profile.real_name || '') || names.has(user.profile.display_name || '')) { + this.allowedUserIds.add(user.id); + } } + cursor = res.response_metadata?.next_cursor || undefined; + } while (cursor); + + if (this.allowedUserIds.size < names.size) { + const found = this.allowedUserIds.size; + console.warn(`[slack] Only found ${found}/${names.size} configured users`); } console.log(`[slack] Allowed users: ${[...this.allowedUserIds].join(', ')}`); diff --git a/src/slack-api.ts b/src/slack-api.ts index 09b733a..177caa9 100644 --- a/src/slack-api.ts +++ b/src/slack-api.ts @@ -102,9 +102,12 @@ export class SlackApi { return this.call('conversations.list', params); } - async usersSearch(query: string): Promise<{ - results: Array<{ id: string; name: string; profile: { real_name?: string; display_name?: string } }>; + async usersList(cursor?: string): Promise<{ + members: Array<{ id: string; name: string; profile: { real_name?: string; display_name?: string } }>; + response_metadata?: { next_cursor?: string }; }> { - return this.call('users.search', { query, count: '20' }); + const params: Record = { limit: '200' }; + if (cursor) params.cursor = cursor; + return this.call('users.list', params); } }