diff --git a/src/channels/slack/adapter.test.ts b/src/channels/slack/adapter.test.ts new file mode 100644 index 0000000..c1834a1 --- /dev/null +++ b/src/channels/slack/adapter.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mock @slack/bolt before importing adapter ────────────────────── + +let capturedMessageHandler: ((args: Record) => Promise) | null = null; +const mockPostMessage = vi.fn(); +const mockStart = vi.fn(); +const mockStop = vi.fn(); + +/** Create a fresh mock Bolt App instance. */ +function createMockApp() { + capturedMessageHandler = null; + return { + message: vi.fn((handler: (args: Record) => Promise) => { + capturedMessageHandler = handler; + }), + start: mockStart, + stop: mockStop, + client: { + chat: { + postMessage: mockPostMessage, + }, + }, + }; +} + +let mockApp = createMockApp(); + +vi.mock('@slack/bolt', () => ({ + App: vi.fn().mockImplementation(() => mockApp), +})); + +import { SlackAdapter, type SlackAdapterConfig } from './adapter.js'; +import type { InboundMessage } from '../types.js'; + +const baseConfig: SlackAdapterConfig = { + botToken: 'xoxb-test-token', + appToken: 'xapp-test-token', + signingSecret: 'test-signing-secret', +}; + +/** Helper: simulate a Slack message event through the captured handler. */ +async function simulateMessage(message: Record) { + if (!capturedMessageHandler) throw new Error('No message handler captured — call connect() first'); + await capturedMessageHandler({ message }); +} + +describe('SlackAdapter', () => { + let adapter: SlackAdapter; + + beforeEach(async () => { + vi.clearAllMocks(); + mockApp = createMockApp(); + // Re-wire the App mock to return the fresh mockApp + const { App } = vi.mocked(await import('@slack/bolt')); + (App as unknown as ReturnType).mockImplementation(() => mockApp); + adapter = new SlackAdapter(baseConfig); + }); + + // ── Basic properties ────────────────────────────────────────── + + it('has name "slack"', () => { + expect(adapter.name).toBe('slack'); + }); + + it('starts as disconnected', () => { + expect(adapter.status).toBe('disconnected'); + }); + + // ── connect / disconnect ────────────────────────────────────── + + it('connect creates Bolt App with Socket Mode and sets connected status', async () => { + await adapter.connect(); + + expect(adapter.status).toBe('connected'); + const { App } = await import('@slack/bolt'); + expect(App).toHaveBeenCalledWith({ + token: 'xoxb-test-token', + appToken: 'xapp-test-token', + signingSecret: 'test-signing-secret', + socketMode: true, + }); + expect(mockStart).toHaveBeenCalledTimes(1); + }); + + it('connect registers message handler via app.message()', async () => { + await adapter.connect(); + + expect(mockApp.message).toHaveBeenCalledTimes(1); + expect(capturedMessageHandler).toBeTypeOf('function'); + }); + + it('disconnect stops app and sets disconnected', async () => { + await adapter.connect(); + expect(adapter.status).toBe('connected'); + + await adapter.disconnect(); + expect(mockStop).toHaveBeenCalledTimes(1); + expect(adapter.status).toBe('disconnected'); + }); + + it('disconnect is safe when not connected', async () => { + await adapter.disconnect(); + expect(adapter.status).toBe('disconnected'); + // No app to stop — should not throw + }); + + // ── send ────────────────────────────────────────────────────── + + it('send throws when not connected', async () => { + await expect(adapter.send('C123:1234.5678', { text: 'hello' })).rejects.toThrow( + 'Slack adapter not connected', + ); + }); + + it('send delivers a short message with correct channel and thread_ts', async () => { + await adapter.connect(); + + await adapter.send('C123:1234.5678', { text: 'Hello there' }); + + expect(mockPostMessage).toHaveBeenCalledTimes(1); + expect(mockPostMessage).toHaveBeenCalledWith({ + channel: 'C123', + text: 'Hello there', + thread_ts: '1234.5678', + }); + }); + + it('send chunks long messages (>4000 chars)', async () => { + await adapter.connect(); + + // Create a message longer than 4000 chars — two halves joined by a newline + const half = 'A'.repeat(3000); + const longMessage = `${half}\n${'B'.repeat(3000)}`; + + await adapter.send('C123:1234.5678', { text: longMessage }); + + // Should have been split into at least 2 chunks + expect(mockPostMessage.mock.calls.length).toBeGreaterThanOrEqual(2); + // All calls should target the same channel and thread + for (const call of mockPostMessage.mock.calls) { + expect(call[0].channel).toBe('C123'); + expect(call[0].thread_ts).toBe('1234.5678'); + } + }); + + it('send throws on invalid peer ID format (no colon)', async () => { + await adapter.connect(); + + await expect(adapter.send('invalid-no-colon', { text: 'hello' })).rejects.toThrow( + 'Invalid peer ID format: invalid-no-colon', + ); + }); + + // ── onMessage / inbound handling ────────────────────────────── + + it('inbound message triggers handler with correct peerId (channelId:threadTs)', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + await simulateMessage({ + ts: '1234.5678', + thread_ts: '1111.0000', + channel: 'C123', + user: 'U456', + text: 'Hello Flynn', + }); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.channel).toBe('slack'); + expect(msg.senderId).toBe('C123:1111.0000'); + expect(msg.senderName).toBe('U456'); + expect(msg.text).toBe('Hello Flynn'); + expect(msg.id).toBe('1234.5678'); + }); + + it('inbound message uses thread_ts for peer ID when in thread', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + await simulateMessage({ + ts: '2222.3333', + thread_ts: '1111.0000', + channel: 'C123', + user: 'U456', + text: 'Threaded reply', + }); + + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.senderId).toBe('C123:1111.0000'); + }); + + it('inbound message falls back to ts when no thread_ts', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + await simulateMessage({ + ts: '2222.3333', + channel: 'C123', + user: 'U456', + text: 'Top-level message', + }); + + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.senderId).toBe('C123:2222.3333'); + }); + + it('ignores bot messages (bot_id present)', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + await simulateMessage({ + ts: '1234.5678', + channel: 'C123', + user: 'U456', + text: 'Bot message', + bot_id: 'B789', + }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('ignores bot messages (subtype === "bot_message")', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + await simulateMessage({ + ts: '1234.5678', + channel: 'C123', + user: 'U456', + text: 'Bot message', + subtype: 'bot_message', + }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('ignores messages in disallowed channels', async () => { + const restrictedAdapter = new SlackAdapter({ + ...baseConfig, + allowedChannelIds: ['C-allowed'], + }); + const handler = vi.fn(); + restrictedAdapter.onMessage(handler); + + await restrictedAdapter.connect(); + + await simulateMessage({ + ts: '1234.5678', + channel: 'C-not-allowed', + user: 'U456', + text: 'Hello', + }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('strips bot mentions (<@U\\w+> pattern)', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + await simulateMessage({ + ts: '1234.5678', + channel: 'C123', + user: 'U456', + text: '<@UBOT123> What is the weather?', + }); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('What is the weather?'); + }); + + it('!reset command delivers reset metadata', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + await simulateMessage({ + ts: '1234.5678', + channel: 'C123', + user: 'U456', + text: '!reset', + }); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('!reset'); + expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' }); + }); + + it('"reset" text (without !) delivers reset metadata', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + await simulateMessage({ + ts: '1234.5678', + channel: 'C123', + user: 'U456', + text: 'reset', + }); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('!reset'); + expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' }); + }); + + it('does nothing when no message handler is registered', async () => { + // Don't call onMessage — no handler registered + await adapter.connect(); + + // Should not throw + await simulateMessage({ + ts: '1234.5678', + channel: 'C123', + user: 'U456', + text: 'Hello', + }); + }); + + it('messages with no text property are handled (empty string)', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + await simulateMessage({ + ts: '1234.5678', + channel: 'C123', + user: 'U456', + // no text property + }); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe(''); + }); + + it('allowed channel messages pass through when allowedChannelIds configured', async () => { + const restrictedAdapter = new SlackAdapter({ + ...baseConfig, + allowedChannelIds: ['C-allowed'], + }); + const handler = vi.fn(); + restrictedAdapter.onMessage(handler); + + await restrictedAdapter.connect(); + + await simulateMessage({ + ts: '1234.5678', + channel: 'C-allowed', + user: 'U456', + text: 'Hello', + }); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('Hello'); + }); +}); diff --git a/src/channels/slack/adapter.ts b/src/channels/slack/adapter.ts new file mode 100644 index 0000000..b4a71f3 --- /dev/null +++ b/src/channels/slack/adapter.ts @@ -0,0 +1,218 @@ +/** + * 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 { + InboundMessage, + OutboundMessage, + ChannelAdapter, + ChannelStatus, +} from '../types.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[]; +} + +/** 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; +} + +/** + * Split a long message into chunks that respect Slack's readability limit. + * Prefers splitting at newlines, then spaces, then hard-cuts. + */ +function splitMessage(text: string, maxLength: number): string[] { + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > 0) { + if (remaining.length <= maxLength) { + chunks.push(remaining); + break; + } + + // Try to split at a newline within the allowed window + let splitIndex = remaining.lastIndexOf('\n', maxLength); + if (splitIndex === -1 || splitIndex < maxLength / 2) { + splitIndex = remaining.lastIndexOf(' ', maxLength); + } + if (splitIndex === -1 || splitIndex < maxLength / 2) { + splitIndex = maxLength; + } + + chunks.push(remaining.slice(0, splitIndex)); + remaining = remaining.slice(splitIndex).trimStart(); + } + + return chunks; +} + +/** + * 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; + + 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 }) => { + this.handleMessage(message as unknown as SlackMessageEvent); + }); + + await this.app.start(); + 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, + }); + } + } + } + + /** Internal: process an inbound Slack message event. */ + private handleMessage(message: SlackMessageEvent): void { + 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 ( + this.config.allowedChannelIds && + this.config.allowedChannelIds.length > 0 && + !this.config.allowedChannelIds.includes(channelId) + ) { + return; + } + + // Build peer ID: channelId:threadTs (thread-aware) + const threadTs = message.thread_ts ?? message.ts ?? ''; + const peerId = `${channelId}:${threadTs}`; + + // Strip bot mentions: <@U\w+> pattern + let text = (message.text ?? '').replace(/<@U\w+>/g, '').trim(); + + // TODO: message.user is a Slack user ID (e.g. U0123ABC), not a display name. + // To resolve display names, use this.app.client.users.info() with caching. + const senderName = message.user; + + // Detect reset command + if (text === '!reset' || text === 'reset') { + this.messageHandler({ + id: message.ts ?? '', + channel: 'slack', + senderId: peerId, + senderName, + text: '!reset', + timestamp: Date.now(), + metadata: { isCommand: true, command: 'reset' }, + }); + return; + } + + // Regular message + this.messageHandler({ + id: message.ts ?? '', + channel: 'slack', + senderId: peerId, + senderName, + text, + timestamp: Date.now(), + }); + } +} diff --git a/src/channels/slack/index.ts b/src/channels/slack/index.ts new file mode 100644 index 0000000..9dfa787 --- /dev/null +++ b/src/channels/slack/index.ts @@ -0,0 +1 @@ +export { SlackAdapter, type SlackAdapterConfig } from './adapter.js';