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' ;
66import type { IPlatformAdapter , MessageMetadata } from '@archon/core' ;
7+ import type { TokenUsage } from '@archon/providers/types' ;
78import { createLogger } from '@archon/paths' ;
89import { isSlackUserAuthorized } from './auth' ;
910import { parseAllowedUserIds } from './auth' ;
1011import { splitIntoParagraphChunks } from '../../utils/message-splitting' ;
12+ import { formatCostFooter } from './blocks' ;
1113import 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
2022const 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+
2233export 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