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..466396d 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,29 @@ export class SlackAdapter { console.log(`[slack] Watching channels: ${[...this.channelIds].join(', ')}`); } + private async resolveUserIds(): Promise { + const names = new Set(this.users!); + this.allowedUserIds = new Set(); + + 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(', ')}`); + } + private async poll(): Promise { try { const result = await this.slackApi.searchMessages(`<@${this.botUserId}>`); @@ -71,6 +100,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..177caa9 100644 --- a/src/slack-api.ts +++ b/src/slack-api.ts @@ -101,4 +101,13 @@ export class SlackApi { if (cursor) params.cursor = cursor; return this.call('conversations.list', params); } + + async usersList(cursor?: string): Promise<{ + members: Array<{ id: string; name: string; profile: { real_name?: string; display_name?: string } }>; + response_metadata?: { next_cursor?: string }; + }> { + const params: Record = { limit: '200' }; + if (cursor) params.cursor = cursor; + return this.call('users.list', params); + } } 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();