Files
flynn/src/channels/slack/adapter.test.ts
T
William Valentin 647d7779c7 feat: add group chat and mention-gating to channel adapters
Slack: add requireMention option, resolve bot user ID on connect.
Telegram: add group chat mention/reply-to-bot detection, strip @mention
from message text, default requireMention=true for groups.
WhatsApp: add allowedGroupIds for group chat support, mention detection
via mentionedIds and body text, strip bot mention from messages.
2026-02-06 16:51:52 -08:00

449 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Mock @slack/bolt before importing adapter ──────────────────────
let capturedMessageHandler: ((args: Record<string, unknown>) => Promise<void>) | null = null;
const mockPostMessage = vi.fn();
const mockStart = vi.fn();
const mockStop = vi.fn();
const mockAuthTest = vi.fn().mockResolvedValue({ user_id: 'UBOT123' });
const mockUsersInfo = vi.fn().mockResolvedValue({ user: { real_name: 'Test User', name: 'testuser' } });
/** Create a fresh mock Bolt App instance. */
function createMockApp() {
capturedMessageHandler = null;
return {
message: vi.fn((handler: (args: Record<string, unknown>) => Promise<void>) => {
capturedMessageHandler = handler;
}),
start: mockStart,
stop: mockStop,
client: {
chat: {
postMessage: mockPostMessage,
},
auth: {
test: mockAuthTest,
},
users: {
info: mockUsersInfo,
},
},
};
}
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<string, unknown>) {
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<typeof vi.fn>).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('Test User');
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');
});
// ── Mention gating ─────────────────────────────────────────────
it('ignores messages without mention when requireMention is true', async () => {
const mentionAdapter = new SlackAdapter({
...baseConfig,
requireMention: true,
});
const handler = vi.fn();
mentionAdapter.onMessage(handler);
await mentionAdapter.connect();
await simulateMessage({
ts: '1234.5678',
channel: 'C123',
user: 'U456',
text: 'Hello everyone',
});
expect(handler).not.toHaveBeenCalled();
});
it('processes messages with bot mention when requireMention is true', async () => {
const mentionAdapter = new SlackAdapter({
...baseConfig,
requireMention: true,
});
const handler = vi.fn();
mentionAdapter.onMessage(handler);
await mentionAdapter.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('processes all messages when requireMention is false (default)', async () => {
const handler = vi.fn();
adapter.onMessage(handler);
await adapter.connect();
await simulateMessage({
ts: '1234.5678',
channel: 'C123',
user: 'U456',
text: 'Hello without mention',
});
expect(handler).toHaveBeenCalledTimes(1);
const msg: InboundMessage = handler.mock.calls[0][0];
expect(msg.text).toBe('Hello without mention');
});
});