Skip to content

Commit c38ef09

Browse files
committed
feat(slack): umbrella Slack UX upgrade — buttons, status, reactions, slash commands
Single Slack adapter PR pulling together the in-thread interactivity primitives the team will need on a shared instance: - Interactive Block Kit Approve/Reject buttons on approval gates - Cancel button on a per-run status message edited in place as DAG nodes progress - Lifecycle reactions on the triggering message (🔄 → ✅ / ❌) - Native `/archon` and `/archon-workflow` slash commands (Socket Mode, no URL needed) - `_part i/n_` annotations on long replies split across multiple messages - Italic cost/token footer after direct-chat replies and on terminal workflow status Approve/Reject/Cancel buttons call existing platform-agnostic operations (approveWorkflow / rejectWorkflow / abandonWorkflow); no schema or workflow engine changes. Authorization re-uses the existing SLACK_ALLOWED_USER_IDS whitelist for button clicks and slash commands. Per-user attribution in thread context is intentionally deferred to a separate PR — it needs a user_id column on conversations/messages/workflow_runs and orchestrator plumbing.
1 parent 6ccfb4b commit c38ef09

11 files changed

Lines changed: 1888 additions & 14 deletions

File tree

packages/adapters/src/chat/slack/adapter.ts

Lines changed: 213 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
* Slack platform adapter using @slack/bolt with Socket Mode
33
* Handles message sending with markdown block formatting for AI responses
44
*/
5-
import { App, LogLevel } from '@slack/bolt';
5+
import { App, LogLevel, type SlashCommand } from '@slack/bolt';
66
import type { IPlatformAdapter, MessageMetadata } from '@archon/core';
7+
import type { TokenUsage } from '@archon/providers/types';
78
import { createLogger } from '@archon/paths';
89
import { isSlackUserAuthorized } from './auth';
910
import { parseAllowedUserIds } from './auth';
1011
import { splitIntoParagraphChunks } from '../../utils/message-splitting';
12+
import { formatCostFooter } from './blocks';
1113
import type { SlackMessageEvent } from './types';
1214

1315
/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
@@ -19,11 +21,22 @@ function getLog(): ReturnType<typeof createLogger> {
1921

2022
const MAX_MARKDOWN_BLOCK_LENGTH = 12000; // Slack markdown block limit
2123

24+
/** Slack channel + message ts pair used for reactions and edits. */
25+
export interface SlackMessageRef {
26+
channel: string;
27+
ts: string;
28+
}
29+
30+
/** Cap on the in-memory triggering-message map to prevent unbounded growth. */
31+
const MAX_TRACKED_TRIGGERS = 1000;
32+
2233
export class SlackAdapter implements IPlatformAdapter {
2334
private app: App;
2435
private streamingMode: 'stream' | 'batch';
2536
private messageHandler: ((event: SlackMessageEvent) => Promise<void>) | null = null;
2637
private allowedUserIds: string[];
38+
/** Maps conversation ID → triggering Slack message so the bridge can react / edit. */
39+
private triggeringMessages = new Map<string, SlackMessageRef>();
2740

2841
constructor(botToken: string, appToken: string, mode: 'stream' | 'batch' = 'batch') {
2942
this.app = new App({
@@ -48,7 +61,8 @@ export class SlackAdapter implements IPlatformAdapter {
4861
/**
4962
* Send a message to a Slack channel/thread
5063
* Uses markdown block for proper formatting of AI responses
51-
* Automatically splits messages longer than 12000 characters
64+
* Automatically splits messages longer than 12000 characters and footers each
65+
* chunk with `_part i/n_` so users know the output was wrapped.
5266
*/
5367
async sendMessage(
5468
channelId: string,
@@ -63,16 +77,20 @@ export class SlackAdapter implements IPlatformAdapter {
6377
: [channelId, undefined];
6478

6579
if (message.length <= MAX_MARKDOWN_BLOCK_LENGTH) {
66-
// Use markdown block for proper formatting
6780
await this.sendWithMarkdownBlock(channel, message, threadTs);
68-
} else {
69-
// Long message: split by paragraphs
70-
getLog().debug({ messageLength: message.length }, 'slack.message_splitting');
71-
const chunks = splitIntoParagraphChunks(message, MAX_MARKDOWN_BLOCK_LENGTH - 500);
81+
return;
82+
}
7283

73-
for (const chunk of chunks) {
74-
await this.sendWithMarkdownBlock(channel, chunk, threadTs);
75-
}
84+
getLog().debug({ messageLength: message.length }, 'slack.message_splitting');
85+
// Reserve headroom for the trailing "_part i/n_" footer. The longest footer
86+
// appears on the largest split (e.g. 7 chunks → "_part 7/7_") and is well
87+
// under 32 chars, so a 64-char reserve is comfortable.
88+
const chunks = splitIntoParagraphChunks(message, MAX_MARKDOWN_BLOCK_LENGTH - 500 - 64);
89+
const total = chunks.length;
90+
for (let i = 0; i < total; i++) {
91+
const body = chunks[i] ?? '';
92+
const annotated = total > 1 ? `${body}\n\n_part ${i + 1}/${total}_` : body;
93+
await this.sendWithMarkdownBlock(channel, annotated, threadTs);
7694
}
7795
}
7896

@@ -111,6 +129,40 @@ export class SlackAdapter implements IPlatformAdapter {
111129
}
112130
}
113131

132+
/**
133+
* Append a small italic cost / token footer after a direct-chat assistant
134+
* turn. Posted as a context block so it visually de-emphasises vs the
135+
* assistant reply. No-op when there's nothing meaningful to surface.
136+
*/
137+
async sendResultFooter(
138+
conversationId: string,
139+
info: { cost?: number; tokens?: TokenUsage; stopReason?: string }
140+
): Promise<void> {
141+
const text = formatCostFooter(info);
142+
if (!text) return;
143+
144+
const [channel, threadTs] = conversationId.includes(':')
145+
? conversationId.split(':')
146+
: [conversationId, undefined];
147+
148+
try {
149+
await this.app.client.chat.postMessage({
150+
channel,
151+
thread_ts: threadTs,
152+
text,
153+
blocks: [
154+
{
155+
type: 'context',
156+
elements: [{ type: 'mrkdwn', text }],
157+
},
158+
],
159+
});
160+
} catch (error) {
161+
// Cost footer is informational only — never let it fail the conversation.
162+
getLog().warn({ err: error as Error, channel }, 'slack.result_footer_failed');
163+
}
164+
}
165+
114166
/**
115167
* Get the Bolt App instance
116168
*/
@@ -132,6 +184,37 @@ export class SlackAdapter implements IPlatformAdapter {
132184
return 'slack';
133185
}
134186

187+
/**
188+
* Returns the channel/ts of the inbound user message that triggered the
189+
* given conversation, if we have it. Workflow bridge uses this to add
190+
* lifecycle reactions to the user's mention/DM.
191+
*/
192+
getTriggeringMessage(conversationId: string): SlackMessageRef | undefined {
193+
return this.triggeringMessages.get(conversationId);
194+
}
195+
196+
/** Drop the cached triggering message for a conversation (e.g. on workflow terminal). */
197+
clearTriggeringMessage(conversationId: string): void {
198+
this.triggeringMessages.delete(conversationId);
199+
}
200+
201+
/** Test seam: expose the configured whitelist to the workflow bridge. */
202+
getAllowedUserIds(): string[] {
203+
return this.allowedUserIds;
204+
}
205+
206+
private trackTrigger(conversationId: string, ref: SlackMessageRef): void {
207+
// Defensive cap so chat-only conversations that never run a workflow
208+
// can't grow the map without bound.
209+
if (this.triggeringMessages.size >= MAX_TRACKED_TRIGGERS) {
210+
const oldest = this.triggeringMessages.keys().next().value;
211+
if (oldest !== undefined) {
212+
this.triggeringMessages.delete(oldest);
213+
}
214+
}
215+
this.triggeringMessages.set(conversationId, ref);
216+
}
217+
135218
/**
136219
* Check if a message is in a thread
137220
*/
@@ -260,6 +343,10 @@ export class SlackAdapter implements IPlatformAdapter {
260343
ts: event.ts,
261344
thread_ts: event.thread_ts,
262345
};
346+
this.trackTrigger(this.getConversationId(messageEvent), {
347+
channel: event.channel,
348+
ts: event.ts,
349+
});
263350
// Fire-and-forget - errors handled by caller
264351
void this.messageHandler(messageEvent);
265352
}
@@ -296,14 +383,130 @@ export class SlackAdapter implements IPlatformAdapter {
296383
ts: event.ts,
297384
thread_ts: 'thread_ts' in event ? event.thread_ts : undefined,
298385
};
386+
this.trackTrigger(this.getConversationId(messageEvent), {
387+
channel: event.channel,
388+
ts: event.ts,
389+
});
299390
void this.messageHandler(messageEvent);
300391
}
301392
});
302393

394+
// Slash commands: /archon (general) and /archon-workflow (workflow control)
395+
this.app.command('/archon', async ({ command, ack, respond, client }) => {
396+
await ack();
397+
await this.handleSlashCommand(command, respond, client, 'archon');
398+
});
399+
this.app.command('/archon-workflow', async ({ command, ack, respond, client }) => {
400+
await ack();
401+
await this.handleSlashCommand(command, respond, client, 'archon-workflow');
402+
});
403+
303404
await this.app.start();
304405
getLog().info('slack.bot_started');
305406
}
306407

408+
/**
409+
* Forward a slash command into the same message-handling flow used by
410+
* @mention. Slash commands carry no message ts of their own, so we first
411+
* post a visible "seed" message in the channel — its ts becomes the thread
412+
* root for everything that follows, giving slash-driven runs the same
413+
* threading model as @mention runs.
414+
*/
415+
private async handleSlashCommand(
416+
command: SlashCommand,
417+
respond: (msg: { response_type: 'ephemeral' | 'in_channel'; text: string }) => Promise<unknown>,
418+
client: App['client'],
419+
kind: 'archon' | 'archon-workflow'
420+
): Promise<void> {
421+
const actorId = command.user_id;
422+
if (!isSlackUserAuthorized(actorId, this.allowedUserIds)) {
423+
getLog().info(
424+
{ maskedUserId: `${actorId.slice(0, 4)}***`, kind },
425+
'slack.slash_unauthorized'
426+
);
427+
await respond({
428+
response_type: 'ephemeral',
429+
text: 'Sorry — you are not on the allowed user list for this Archon instance.',
430+
});
431+
return;
432+
}
433+
434+
if (!this.messageHandler) {
435+
await respond({
436+
response_type: 'ephemeral',
437+
text: 'Archon is starting up — try again in a moment.',
438+
});
439+
return;
440+
}
441+
442+
const raw = (command.text ?? '').trim();
443+
if (!raw) {
444+
const help =
445+
kind === 'archon-workflow'
446+
? 'Usage: `/archon-workflow <subcommand>` — e.g. `list`, `status`, `run <name> <args>`, `approve <id>`, `reject <id> <reason>`, `abandon <id>`.'
447+
: 'Usage: `/archon <message>` — talk to Archon in this channel.';
448+
await respond({ response_type: 'ephemeral', text: help });
449+
return;
450+
}
451+
452+
const messageText = kind === 'archon-workflow' ? `/workflow ${raw}` : raw;
453+
454+
// Post a visible seed message so the bot's responses thread cleanly under
455+
// a parent. The seed quotes the invoking user and the command they ran,
456+
// mirroring how @mention surfaces the original message in the thread.
457+
let seedTs: string | undefined;
458+
try {
459+
const seedText =
460+
kind === 'archon-workflow'
461+
? `<@${actorId}> ran \`/archon-workflow ${raw}\``
462+
: `<@${actorId}> via /archon: ${raw}`;
463+
const posted = await client.chat.postMessage({
464+
channel: command.channel_id,
465+
text: seedText,
466+
});
467+
seedTs = posted.ts ?? undefined;
468+
} catch (error) {
469+
getLog().warn(
470+
{ err: error as Error, channel: command.channel_id, kind },
471+
'slack.slash_seed_post_failed'
472+
);
473+
await respond({
474+
response_type: 'ephemeral',
475+
text: 'Could not post in this channel — is the bot invited here?',
476+
});
477+
return;
478+
}
479+
480+
if (!seedTs) {
481+
await respond({
482+
response_type: 'ephemeral',
483+
text: 'Could not start the conversation (Slack returned no message id).',
484+
});
485+
return;
486+
}
487+
488+
const messageEvent: SlackMessageEvent = {
489+
text: messageText,
490+
user: actorId,
491+
channel: command.channel_id,
492+
ts: seedTs,
493+
};
494+
this.trackTrigger(this.getConversationId(messageEvent), {
495+
channel: command.channel_id,
496+
ts: seedTs,
497+
});
498+
499+
await respond({
500+
response_type: 'ephemeral',
501+
text:
502+
kind === 'archon-workflow'
503+
? `Running \`/workflow ${raw}\` — see thread for output.`
504+
: `Running \`${raw}\` — see thread for output.`,
505+
});
506+
507+
void this.messageHandler(messageEvent);
508+
}
509+
307510
/**
308511
* Stop the bot gracefully
309512
*/

0 commit comments

Comments
 (0)