/** * Slack channel adapter. * * Implements the ChannelAdapter interface using @slack/bolt with Socket Mode. * Thread-aware: each channel+thread gets its own session via channelId:threadTs peer IDs. * Messages are chunked at 4000 chars for readability. */ import { App } from '@slack/bolt'; import type { Attachment, InboundMessage, OutboundMessage, OutboundAttachment, ChannelAdapter, ChannelStatus, } from '../types.js'; import { allowTrustedOrPairedSender, buildResetInboundMessage, isAllowedByAllowlist, normalizeResetCommandText, shouldIgnoreForMissingMention, splitMessage, } from '../utils.js'; import type { PairingManager } from '../pairing.js'; /** Configuration for the Slack channel adapter. */ export interface SlackAdapterConfig { botToken: string; appToken: string; signingSecret: string; /** Channel IDs to respond in. Empty = all channels. */ allowedChannelIds?: string[]; /** Require bot mention to respond (default: false). */ requireMention?: boolean; /** Optional pairing manager for DM pairing codes. */ pairingManager?: PairingManager; } interface CachedUserName { name: string; expiresAt: number; } /** Minimal shape of a Slack message event from Bolt. */ interface SlackMessageEvent { ts?: string; thread_ts?: string; channel?: string; user?: string; text?: string; bot_id?: string; subtype?: string; files?: Array<{ id?: string; mimetype?: string; name?: string; size?: number; url_private?: string; url_private_download?: string; }>; } /** * Slack channel adapter backed by @slack/bolt. * * Handles channel filtering, thread-aware peer IDs, * and message chunking for readability (4000 chars). */ export class SlackAdapter implements ChannelAdapter { readonly name = 'slack'; private _status: ChannelStatus = 'disconnected'; private app: App | null = null; private messageHandler?: (msg: InboundMessage) => void; private config: SlackAdapterConfig; private userNameCache: Map = new Map(); private botUserId?: string; private readonly userNameCacheTtlMs = 60 * 60 * 1_000; private readonly userNameCacheMaxEntries = 1_000; get status(): ChannelStatus { return this._status; } constructor(config: SlackAdapterConfig) { this.config = config; } /** Register the inbound message handler. Called by the registry before connect(). */ onMessage(handler: (msg: InboundMessage) => void): void { this.messageHandler = handler; } /** Create the Bolt app with Socket Mode, wire up event handlers, and start. */ async connect(): Promise { this._status = 'connecting'; try { this.app = new App({ token: this.config.botToken, appToken: this.config.appToken, signingSecret: this.config.signingSecret, socketMode: true, }); // Register message event handler this.app.message(async ({ message }) => { await this.handleMessage(message as unknown as SlackMessageEvent); }); await this.app.start(); // Resolve bot user ID for mention detection try { const authResult = await this.app.client.auth.test(); this.botUserId = authResult.user_id as string | undefined; } catch { console.warn('Slack: could not resolve bot user ID for mention detection'); } this._status = 'connected'; console.log('Slack bot connected via Socket Mode'); } catch (error) { this._status = 'error'; throw error; } } /** Stop the Bolt app and clean up. */ async disconnect(): Promise { if (this.app) { try { await this.app.stop(); } finally { this.app = null; } } this._status = 'disconnected'; } /** Send an outbound message, automatically chunking if it exceeds 4000 chars. */ async send(peerId: string, message: OutboundMessage): Promise { if (!this.app) {throw new Error('Slack adapter not connected');} // Parse peerId: "channelId:threadTs" const colonIndex = peerId.indexOf(':'); if (colonIndex === -1) {throw new Error(`Invalid peer ID format: ${peerId}`);} const channel = peerId.slice(0, colonIndex); const threadTs = peerId.slice(colonIndex + 1); if (!channel || !threadTs) {throw new Error(`Invalid peer ID format: ${peerId}`);} const text = message.text; if (text.length <= 4000) { await this.app.client.chat.postMessage({ channel, text, thread_ts: threadTs, }); } else { const chunks = splitMessage(text, 4000); for (const chunk of chunks) { await this.app.client.chat.postMessage({ channel, text: chunk, thread_ts: threadTs, }); } } // Send outbound attachments after text if (message.attachments && message.attachments.length > 0) { for (const attachment of message.attachments) { await this.sendAttachment(channel, threadTs, attachment); } } } /** Upload and send a single outbound attachment via Slack's files.uploadV2 API. */ private async sendAttachment( channel: string, threadTs: string, attachment: OutboundAttachment, ): Promise { if (!this.app) {return;} try { if (attachment.data) { await this.app.client.files.uploadV2({ channel_id: channel, thread_ts: threadTs, file: Buffer.from(attachment.data, 'base64'), filename: attachment.filename ?? 'attachment', }); } else if (attachment.url) { // For URL-based attachments, share as a text message with the URL await this.app.client.chat.postMessage({ channel, text: attachment.url, thread_ts: threadTs, }); } } catch (error) { console.error( `Slack: failed to send ${attachment.mimeType} attachment:`, error instanceof Error ? error.message : 'Unknown error', ); } } /** Resolve a Slack user ID to a display name, with caching. */ private async resolveUserName(userId: string): Promise { const now = Date.now(); const cached = this.userNameCache.get(userId); if (cached && cached.expiresAt > now) { // Refresh LRU order on cache hit. this.userNameCache.delete(userId); this.userNameCache.set(userId, cached); return cached.name; } if (cached) { this.userNameCache.delete(userId); } try { const app = this.app; if (!app) { return userId; } const result = await app.client.users.info({ user: userId }); const name = result.user?.real_name || result.user?.name || userId; this.userNameCache.set(userId, { name, expiresAt: now + this.userNameCacheTtlMs }); if (this.userNameCache.size > this.userNameCacheMaxEntries) { const oldestKey = this.userNameCache.keys().next().value; if (typeof oldestKey === 'string') { this.userNameCache.delete(oldestKey); } } return name; } catch { return userId; } } /** * Download media files from a Slack message and convert to base64 Attachments. * Non-media files are skipped. Download errors are logged but don't crash the handler. */ private async extractMediaAttachments( files?: SlackMessageEvent['files'], ): Promise { if (!files || files.length === 0) {return [];} const attachments: Attachment[] = []; for (const file of files) { // Only process image and audio files if (!file.mimetype?.startsWith('image/') && !file.mimetype?.startsWith('audio/')) {continue;} const downloadUrl = file.url_private_download || file.url_private; if (!downloadUrl) {continue;} try { const response = await fetch(downloadUrl, { headers: { Authorization: `Bearer ${this.config.botToken}` }, }); if (!response.ok) { console.warn( `Slack: failed to download file ${file.name ?? file.id ?? 'unknown'}: HTTP ${response.status}`, ); continue; } const arrayBuffer = await response.arrayBuffer(); const base64 = Buffer.from(arrayBuffer).toString('base64'); attachments.push({ mimeType: file.mimetype, data: base64, filename: file.name, size: file.size, }); } catch (error) { console.warn( `Slack: error downloading file ${file.name ?? file.id ?? 'unknown'}:`, error instanceof Error ? error.message : 'Unknown error', ); } } return attachments; } /** Internal: process an inbound Slack message event. */ private async handleMessage(message: SlackMessageEvent): Promise { if (!this.messageHandler) {return;} // Ignore bot messages if (message.bot_id || message.subtype === 'bot_message') {return;} const channelId = message.channel; if (!channelId) {return;} // Check allowed channel IDs if (!isAllowedByAllowlist(channelId, this.config.allowedChannelIds)) { const senderId = message.user ?? ''; const allowed = await allowTrustedOrPairedSender({ pairingManager: this.config.pairingManager, channel: 'slack', senderId, text: message.text ?? '', isTrusted: false, onPaired: async () => { if (!this.app) {return;} const threadTs = message.thread_ts ?? message.ts ?? ''; await this.app.client.chat.postMessage({ channel: channelId, text: 'Pairing successful! You can now chat with Flynn.', thread_ts: threadTs || undefined, }); }, }); if (!allowed) { return; } } // Mention requirement const mentionPattern = this.botUserId ? `<@${this.botUserId}>` : undefined; if (shouldIgnoreForMissingMention({ requireMention: mentionPattern ? this.config.requireMention : false, defaultRequireMention: false, mentionsBot: mentionPattern ? (message.text ?? '').includes(mentionPattern) : false, })) { return; } // Note: Slack doesn't expose a typing indicator API for bots // Build peer ID: channelId:threadTs (thread-aware) const threadTs = message.thread_ts ?? message.ts ?? ''; const peerId = `${channelId}:${threadTs}`; // Strip bot mentions: <@U\w+> pattern const rawText = (message.text ?? '').replace(/<@U\w+>/g, '').trim(); const text = normalizeResetCommandText(rawText); // Resolve display name from Slack user ID const senderName = message.user ? await this.resolveUserName(message.user) : undefined; // Extract media attachments from Slack file uploads const attachments = await this.extractMediaAttachments(message.files); // Detect reset command if (text === '!reset') { this.messageHandler(buildResetInboundMessage({ id: message.ts ?? '', channel: 'slack', senderId: peerId, senderName, attachments, })); return; } // Regular message this.messageHandler({ id: message.ts ?? '', channel: 'slack', senderId: peerId, senderName, text, timestamp: Date.now(), ...(attachments.length > 0 && { attachments }), }); } }