diff --git a/.claude/skills/archon/guides/slack.md b/.claude/skills/archon/guides/slack.md index 42011ee980..14a3293434 100644 --- a/.claude/skills/archon/guides/slack.md +++ b/.claude/skills/archon/guides/slack.md @@ -10,7 +10,7 @@ Follow the step-by-step instructions in **[docs/slack-setup.md](../../../../../d 1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps) (from scratch) 2. Enable **Socket Mode** — generates an App-Level Token (`xapp-...`) for `SLACK_APP_TOKEN` -3. Add **Bot Token Scopes**: `app_mentions:read`, `chat:write`, `channels:history`, `channels:join`, `im:history`, `im:write`, `im:read` +3. Add **Bot Token Scopes**: `app_mentions:read`, `chat:write`, `reactions:write`, `channels:history`, `channels:join`, `im:history`, `im:write`, `im:read` 4. Subscribe to **Bot Events**: `app_mention`, `message.im` 5. **Install to Workspace** — generates a Bot User OAuth Token (`xoxb-...`) for `SLACK_BOT_TOKEN` 6. Invite the bot to your channel: `/invite @YourBotName` diff --git a/packages/adapters/src/chat/slack/adapter.test.ts b/packages/adapters/src/chat/slack/adapter.test.ts index 980d327018..a1845cd1db 100644 --- a/packages/adapters/src/chat/slack/adapter.test.ts +++ b/packages/adapters/src/chat/slack/adapter.test.ts @@ -26,6 +26,7 @@ mock.module('@archon/paths', () => ({ // Create mock functions const mockPostMessage = mock(() => Promise.resolve(undefined)); const mockReplies = mock(() => Promise.resolve({ messages: [] })); +const mockReactionsAdd = mock(() => Promise.resolve({ ok: true })); const mockEvent = mock(() => {}); const mockStart = mock(() => Promise.resolve(undefined)); const mockStop = mock(() => Promise.resolve(undefined)); @@ -38,6 +39,9 @@ const mockApp = { conversations: { replies: mockReplies, }, + reactions: { + add: mockReactionsAdd, + }, }, event: mockEvent, start: mockStart, @@ -432,4 +436,56 @@ describe('SlackAdapter', () => { expect((second.blocks[1] as { type: string }).type).toBe('actions'); }); }); + + describe('acknowledgeReceipt', () => { + const event: SlackMessageEvent = { + text: 'hello', + user: 'U123', + channel: 'C456', + ts: '1234567890.000001', + }; + + beforeEach(() => { + mockReactionsAdd.mockClear(); + }); + + test('posts :eyes: reaction on the incoming message', async () => { + const adapter = new SlackAdapter('xoxb-fake', 'xapp-fake'); + await adapter.acknowledgeReceipt(event); + + expect(mockReactionsAdd).toHaveBeenCalledTimes(1); + const args = (mockReactionsAdd as Mock).mock.calls[0][0] as { + channel: string; + timestamp: string; + name: string; + }; + expect(args.channel).toBe('C456'); + expect(args.timestamp).toBe('1234567890.000001'); + expect(args.name).toBe('eyes'); + }); + + test('does not throw when reactions:write scope is missing', async () => { + // Simulate Slack's `missing_scope` error shape. + const scopeError = Object.assign(new Error('missing_scope'), { + data: { error: 'missing_scope', needed: 'reactions:write' }, + }); + mockReactionsAdd.mockImplementationOnce(() => Promise.reject(scopeError)); + + const adapter = new SlackAdapter('xoxb-fake', 'xapp-fake'); + // If this rejected, the test runner would surface it — proving graceful handling. + await adapter.acknowledgeReceipt(event); + expect(mockReactionsAdd).toHaveBeenCalledTimes(1); + }); + + test('silently skips when message already has the reaction', async () => { + const alreadyReacted = Object.assign(new Error('already_reacted'), { + data: { error: 'already_reacted' }, + }); + mockReactionsAdd.mockImplementationOnce(() => Promise.reject(alreadyReacted)); + + const adapter = new SlackAdapter('xoxb-fake', 'xapp-fake'); + await adapter.acknowledgeReceipt(event); + expect(mockReactionsAdd).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/adapters/src/chat/slack/adapter.ts b/packages/adapters/src/chat/slack/adapter.ts index 0cd137e630..4f697a615e 100644 --- a/packages/adapters/src/chat/slack/adapter.ts +++ b/packages/adapters/src/chat/slack/adapter.ts @@ -316,6 +316,38 @@ export class SlackAdapter implements IPlatformAdapter { this.messageHandler = handler; } + /** + * Post an :eyes: reaction to the incoming message so the user knows the + * bot received the request immediately — before thread-history fetch, + * orchestration, lock acquisition, or first LLM token. + * + * Intentionally silent on failure: + * - `reactions:write` scope is optional; missing-scope workspaces still + * get a working bot, just without the visual receipt. + * - We never want a reaction error to block message processing. + */ + async acknowledgeReceipt(event: SlackMessageEvent): Promise { + try { + await this.app.client.reactions.add({ + channel: event.channel, + timestamp: event.ts, + name: 'eyes', + }); + getLog().debug({ channel: event.channel, ts: event.ts }, 'slack.receipt_ack_sent'); + } catch (error) { + const err = error as Error & { data?: { error?: string } }; + // `already_reacted` just means we're re-processing; not worth a warn. + if (err.data?.error === 'already_reacted') { + getLog().debug({ channel: event.channel }, 'slack.receipt_ack_already_reacted'); + return; + } + getLog().warn( + { err, slackError: err.data?.error, channel: event.channel }, + 'slack.receipt_ack_failed' + ); + } + } + /** * Start the bot (connects via Socket Mode) */ diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 0235ceec3e..6bb7d74797 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -1030,7 +1030,7 @@ async function collectSlackConfig(): Promise { ' - Settings -> Socket Mode -> Enable\n' + ' - Generate an App-Level Token (xapp-...)\n' + '3. Add Bot Token Scopes (OAuth & Permissions):\n' + - ' - app_mentions:read, chat:write, channels:history\n' + + ' - app_mentions:read, chat:write, reactions:write, channels:history\n' + ' - channels:join, im:history, im:write, im:read\n' + '4. Subscribe to Bot Events (Event Subscriptions):\n' + ' - app_mention, message.im\n' + diff --git a/packages/docs-web/src/content/docs/adapters/slack.md b/packages/docs-web/src/content/docs/adapters/slack.md index ce53956793..e414e9aec5 100644 --- a/packages/docs-web/src/content/docs/adapters/slack.md +++ b/packages/docs-web/src/content/docs/adapters/slack.md @@ -55,6 +55,7 @@ Archon uses **Socket Mode** for Slack integration, which means: 3. Add these scopes to bot token scopes: - `app_mentions:read` -- Receive @mention events - `chat:write` -- Send messages + - `reactions:write` -- Post :eyes: receipt reaction on incoming messages (optional; bot works without it) - `channels:history` -- Read messages in public channels (for thread context) - `channels:join` -- Allow bot to join public channels - `groups:history` -- Read messages in private channels (optional) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3d0d1bdcf5..82ed2ed3bd 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -404,6 +404,10 @@ export async function startServer(opts: ServerOptions = {}): Promise { const content = slackAdapter.stripBotMention(event.text); if (!content) return; // Message was only a mention with no content + // Immediate receipt ack (:eyes:). Fire-and-forget — we don't want to + // delay thread-history fetch or orchestration on a reaction round-trip. + void slackAdapter.acknowledgeReceipt(event); + // Check for thread context let threadContext: string | undefined; let parentConversationId: string | undefined;