Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/archon/guides/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
56 changes: 56 additions & 0 deletions packages/adapters/src/chat/slack/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -38,6 +39,9 @@ const mockApp = {
conversations: {
replies: mockReplies,
},
reactions: {
add: mockReactionsAdd,
},
},
event: mockEvent,
start: mockStart,
Expand Down Expand Up @@ -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<typeof mockReactionsAdd>).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);
});
});
});
32 changes: 32 additions & 0 deletions packages/adapters/src/chat/slack/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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)
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1030,7 +1030,7 @@ async function collectSlackConfig(): Promise<SlackConfig> {
' - 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' +
Expand Down
1 change: 1 addition & 0 deletions packages/docs-web/src/content/docs/adapters/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,10 @@ export async function startServer(opts: ServerOptions = {}): Promise<void> {
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;
Expand Down