diff --git a/src/channels/slack/adapter.test.ts b/src/channels/slack/adapter.test.ts index c1834a1..7424e1f 100644 --- a/src/channels/slack/adapter.test.ts +++ b/src/channels/slack/adapter.test.ts @@ -7,6 +7,9 @@ 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; @@ -20,6 +23,12 @@ function createMockApp() { chat: { postMessage: mockPostMessage, }, + auth: { + test: mockAuthTest, + }, + users: { + info: mockUsersInfo, + }, }, }; } @@ -172,7 +181,7 @@ describe('SlackAdapter', () => { 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.senderName).toBe('Test User'); expect(msg.text).toBe('Hello Flynn'); expect(msg.id).toBe('1234.5678'); }); @@ -374,4 +383,66 @@ describe('SlackAdapter', () => { 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'); + }); }); diff --git a/src/channels/slack/adapter.ts b/src/channels/slack/adapter.ts index ffcad58..203860a 100644 --- a/src/channels/slack/adapter.ts +++ b/src/channels/slack/adapter.ts @@ -22,6 +22,8 @@ export interface SlackAdapterConfig { signingSecret: string; /** Channel IDs to respond in. Empty = all channels. */ allowedChannelIds?: string[]; + /** Require bot mention to respond (default: false). */ + requireMention?: boolean; } /** Minimal shape of a Slack message event from Bolt. */ @@ -49,6 +51,7 @@ export class SlackAdapter implements ChannelAdapter { private messageHandler?: (msg: InboundMessage) => void; private config: SlackAdapterConfig; private userNameCache: Map = new Map(); + private botUserId?: string; get status(): ChannelStatus { return this._status; @@ -81,6 +84,15 @@ export class SlackAdapter implements ChannelAdapter { }); 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) { @@ -167,6 +179,15 @@ export class SlackAdapter implements ChannelAdapter { return; } + // Mention requirement + const requireMention = this.config.requireMention ?? false; + if (requireMention && this.botUserId) { + const mentionPattern = `<@${this.botUserId}>`; + if (!(message.text ?? '').includes(mentionPattern)) { + return; + } + } + // Build peer ID: channelId:threadTs (thread-aware) const threadTs = message.thread_ts ?? message.ts ?? ''; const peerId = `${channelId}:${threadTs}`; diff --git a/src/channels/telegram/adapter.test.ts b/src/channels/telegram/adapter.test.ts index 0722697..dc0a4ae 100644 --- a/src/channels/telegram/adapter.test.ts +++ b/src/channels/telegram/adapter.test.ts @@ -253,4 +253,187 @@ describe('TelegramAdapter', () => { expect(next).toHaveBeenCalledTimes(1); }); + + // ── Group chat mention gating ────────────────────────────────── + + it('ignores group messages without mention when requireMention is true', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + // Simulate onStart callback to set botInfo + const startCall = mockStart.mock.calls[0][0]; + startCall.onStart({ id: 12345, username: 'flynn_bot' }); + + const textHandlerCall = mockOn.mock.calls.find( + (call) => call[0] === 'message:text', + ); + const textHandler = textHandlerCall![1]; + + const ctx = { + message: { message_id: 42, text: 'Hello everyone', reply_to_message: undefined }, + chat: { id: 100, type: 'group' }, + from: { first_name: 'Will' }, + replyWithChatAction: vi.fn(), + }; + + await textHandler(ctx); + + expect(handler).not.toHaveBeenCalled(); + expect(ctx.replyWithChatAction).not.toHaveBeenCalled(); + }); + + it('processes group messages with bot mention when requireMention is true', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + // Simulate onStart callback to set botInfo + const startCall = mockStart.mock.calls[0][0]; + startCall.onStart({ id: 12345, username: 'flynn_bot' }); + + const textHandlerCall = mockOn.mock.calls.find( + (call) => call[0] === 'message:text', + ); + const textHandler = textHandlerCall![1]; + + const ctx = { + message: { message_id: 42, text: '@flynn_bot What is the weather?', reply_to_message: undefined }, + chat: { id: 100, type: 'group' }, + from: { first_name: 'Will' }, + replyWithChatAction: vi.fn(), + }; + + await textHandler(ctx); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('What is the weather?'); + }); + + it('processes group messages as reply to bot when requireMention is true', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + // Simulate onStart callback to set botInfo + const startCall = mockStart.mock.calls[0][0]; + startCall.onStart({ id: 12345, username: 'flynn_bot' }); + + const textHandlerCall = mockOn.mock.calls.find( + (call) => call[0] === 'message:text', + ); + const textHandler = textHandlerCall![1]; + + const ctx = { + message: { + message_id: 42, + text: 'Follow up question', + reply_to_message: { from: { id: 12345 } }, + }, + chat: { id: 100, type: 'supergroup' }, + from: { first_name: 'Will' }, + replyWithChatAction: vi.fn(), + }; + + await textHandler(ctx); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('Follow up question'); + }); + + it('processes group messages without mention when requireMention is false', async () => { + const noMentionAdapter = new TelegramAdapter({ + ...baseConfig, + requireMention: false, + }); + const handler = vi.fn(); + noMentionAdapter.onMessage(handler); + + await noMentionAdapter.connect(); + + // Simulate onStart callback + const startCall = mockStart.mock.calls[0][0]; + startCall.onStart({ id: 12345, username: 'flynn_bot' }); + + const textHandlerCall = mockOn.mock.calls.find( + (call) => call[0] === 'message:text', + ); + const textHandler = textHandlerCall![1]; + + const ctx = { + message: { message_id: 42, text: 'Hello everyone', reply_to_message: undefined }, + chat: { id: 100, type: 'group' }, + from: { first_name: 'Will' }, + replyWithChatAction: vi.fn(), + }; + + await textHandler(ctx); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('Hello everyone'); + }); + + it('DMs are always processed regardless of requireMention setting', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + // Simulate onStart callback to set botInfo + const startCall = mockStart.mock.calls[0][0]; + startCall.onStart({ id: 12345, username: 'flynn_bot' }); + + const textHandlerCall = mockOn.mock.calls.find( + (call) => call[0] === 'message:text', + ); + const textHandler = textHandlerCall![1]; + + const ctx = { + message: { message_id: 42, text: 'Hello Flynn', reply_to_message: undefined }, + chat: { id: 100, type: 'private' }, + from: { first_name: 'Will' }, + replyWithChatAction: vi.fn(), + }; + + await textHandler(ctx); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('Hello Flynn'); + }); + + it('strips bot mention from group message text', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.connect(); + + // Simulate onStart callback to set botInfo + const startCall = mockStart.mock.calls[0][0]; + startCall.onStart({ id: 12345, username: 'flynn_bot' }); + + const textHandlerCall = mockOn.mock.calls.find( + (call) => call[0] === 'message:text', + ); + const textHandler = textHandlerCall![1]; + + const ctx = { + message: { message_id: 42, text: '@flynn_bot tell me a joke', reply_to_message: undefined }, + chat: { id: 100, type: 'group' }, + from: { first_name: 'Will' }, + replyWithChatAction: vi.fn(), + }; + + await textHandler(ctx); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('tell me a joke'); + }); }); diff --git a/src/channels/telegram/adapter.ts b/src/channels/telegram/adapter.ts index 46716ec..f46d30c 100644 --- a/src/channels/telegram/adapter.ts +++ b/src/channels/telegram/adapter.ts @@ -15,6 +15,8 @@ import { splitMessage } from '../utils.js'; export interface TelegramAdapterConfig { botToken: string; allowedChatIds: number[]; + /** Require bot mention or reply-to-bot to respond in group chats (default: true). */ + requireMention?: boolean; hookEngine?: HookEngine; } @@ -32,6 +34,7 @@ export class TelegramAdapter implements ChannelAdapter { private bot: Bot | null = null; private messageHandler?: (msg: InboundMessage) => void; private config: TelegramAdapterConfig; + private botInfo?: { id: number; username?: string }; get status(): ChannelStatus { return this._status; @@ -120,7 +123,33 @@ export class TelegramAdapter implements ChannelAdapter { this.bot.on('message:text', async (ctx) => { if (!this.messageHandler) return; - const text = ctx.message.text; + // Group chat mention gating + const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; + const requireMention = this.config.requireMention ?? true; + + if (isGroup && requireMention && this.botInfo) { + const rawText = ctx.message.text; + const username = this.botInfo.username; + + // Check for @bot_username mention + const isMentioned = username + ? rawText.includes(`@${username}`) + : false; + + // Also allow replies to bot messages + const isReplyToBot = ctx.message.reply_to_message?.from?.id === this.botInfo.id; + + if (!isMentioned && !isReplyToBot) { + return; + } + } + + let text = ctx.message.text; + + // Strip bot mention from text + if (isGroup && this.botInfo?.username) { + text = text.replace(new RegExp(`@${this.botInfo.username}\\b`, 'g'), '').trim(); + } // Show typing indicator while processing await ctx.replyWithChatAction('typing'); @@ -140,6 +169,7 @@ export class TelegramAdapter implements ChannelAdapter { this.bot.start({ onStart: (botInfo) => { console.log(`Telegram bot started: @${botInfo.username}`); + this.botInfo = { id: botInfo.id, username: botInfo.username }; this._status = 'connected'; }, }); diff --git a/src/channels/whatsapp/adapter.test.ts b/src/channels/whatsapp/adapter.test.ts index 12d883b..a9efa0f 100644 --- a/src/channels/whatsapp/adapter.test.ts +++ b/src/channels/whatsapp/adapter.test.ts @@ -10,6 +10,7 @@ const mockDestroy = vi.fn(); interface MockClientInfo { pushname?: string; + wid?: { _serialized: string }; } /** Create a fresh mock Client instance. */ @@ -25,7 +26,7 @@ function createMockClient() { initialize: mockInitialize, destroy: mockDestroy, sendMessage: mockSendMessage, - info: { pushname: 'Flynn' } as MockClientInfo, + info: { pushname: 'Flynn', wid: { _serialized: '5511000000000@c.us' } } as MockClientInfo, }; } @@ -375,7 +376,7 @@ describe('WhatsAppAdapter', () => { expect(handler).toHaveBeenCalledTimes(1); }); - it('ignores group messages (from addresses ending in @g.us)', async () => { + it('ignores group messages when no allowedGroupIds configured', async () => { const handler = vi.fn(); adapter.onMessage(handler); @@ -388,10 +389,160 @@ describe('WhatsAppAdapter', () => { body: 'Group message', })); - // WhatsApp adapter should only handle direct messages for now + // WhatsApp adapter should ignore group messages when allowedGroupIds is empty expect(handler).not.toHaveBeenCalled(); }); + // ── Group chat support ──────────────────────────────────────── + + it('processes group messages from allowed group IDs with mention', async () => { + const groupAdapter = new WhatsAppAdapter({ + allowedGroupIds: ['120363025555555555'], + requireMention: true, + }); + const handler = vi.fn(); + groupAdapter.onMessage(handler); + + const connectPromise = groupAdapter.connect(); + simulateEvent('ready'); + await connectPromise; + + simulateEvent('message', createMockMessage({ + from: '120363025555555555@g.us', + body: '@5511000000000 Hello bot', + })); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('Hello bot'); + expect(msg.senderId).toBe('120363025555555555@g.us'); + }); + + it('ignores group messages from disallowed group IDs', async () => { + const groupAdapter = new WhatsAppAdapter({ + allowedGroupIds: ['120363025555555555'], + }); + const handler = vi.fn(); + groupAdapter.onMessage(handler); + + const connectPromise = groupAdapter.connect(); + simulateEvent('ready'); + await connectPromise; + + simulateEvent('message', createMockMessage({ + from: '999999999999999999@g.us', + body: '@5511000000000 Hello bot', + })); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('ignores group messages without mention when requireMention is true', async () => { + const groupAdapter = new WhatsAppAdapter({ + allowedGroupIds: ['120363025555555555'], + requireMention: true, + }); + const handler = vi.fn(); + groupAdapter.onMessage(handler); + + const connectPromise = groupAdapter.connect(); + simulateEvent('ready'); + await connectPromise; + + simulateEvent('message', createMockMessage({ + from: '120363025555555555@g.us', + body: 'Hello everyone', + })); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('processes group messages without mention when requireMention is false', async () => { + const groupAdapter = new WhatsAppAdapter({ + allowedGroupIds: ['120363025555555555'], + requireMention: false, + }); + const handler = vi.fn(); + groupAdapter.onMessage(handler); + + const connectPromise = groupAdapter.connect(); + simulateEvent('ready'); + await connectPromise; + + simulateEvent('message', createMockMessage({ + from: '120363025555555555@g.us', + body: 'Hello everyone', + })); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('Hello everyone'); + }); + + it('strips bot mention from group message text', async () => { + const groupAdapter = new WhatsAppAdapter({ + allowedGroupIds: ['120363025555555555'], + requireMention: true, + }); + const handler = vi.fn(); + groupAdapter.onMessage(handler); + + const connectPromise = groupAdapter.connect(); + simulateEvent('ready'); + await connectPromise; + + simulateEvent('message', createMockMessage({ + from: '120363025555555555@g.us', + body: '@5511000000000 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('DM messages are always processed regardless of requireMention', async () => { + const groupAdapter = new WhatsAppAdapter({ + requireMention: true, + }); + const handler = vi.fn(); + groupAdapter.onMessage(handler); + + const connectPromise = groupAdapter.connect(); + simulateEvent('ready'); + await connectPromise; + + simulateEvent('message', createMockMessage({ + from: '5511999999999@c.us', + body: 'Hello Flynn', + })); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('Hello Flynn'); + }); + + it('group messages detected via mentionedIds array', async () => { + const groupAdapter = new WhatsAppAdapter({ + allowedGroupIds: ['120363025555555555'], + requireMention: true, + }); + const handler = vi.fn(); + groupAdapter.onMessage(handler); + + const connectPromise = groupAdapter.connect(); + simulateEvent('ready'); + await connectPromise; + + simulateEvent('message', createMockMessage({ + from: '120363025555555555@g.us', + body: 'Hello bot', + mentionedIds: ['5511000000000@c.us'], + })); + + expect(handler).toHaveBeenCalledTimes(1); + }); + it('passes data_dir to LocalAuth strategy', async () => { const adapterWithDir = new WhatsAppAdapter({ dataDir: '/tmp/whatsapp-session', diff --git a/src/channels/whatsapp/adapter.ts b/src/channels/whatsapp/adapter.ts index e2d9068..62c05a7 100644 --- a/src/channels/whatsapp/adapter.ts +++ b/src/channels/whatsapp/adapter.ts @@ -20,6 +20,10 @@ import { splitMessage } from '../utils.js'; export interface WhatsAppAdapterConfig { /** Phone numbers allowed to interact. Empty = all numbers. */ allowedNumbers?: string[]; + /** Group IDs (without @g.us suffix) allowed to interact. Empty = no groups. */ + allowedGroupIds?: string[]; + /** Require bot mention to respond in group chats (default: true). DMs always respond. */ + requireMention?: boolean; /** Directory for session persistence (LocalAuth data path). */ dataDir?: string; } @@ -48,6 +52,7 @@ export class WhatsAppAdapter implements ChannelAdapter { private client: Client | null = null; private messageHandler?: (msg: InboundMessage) => void; private config: WhatsAppAdapterConfig; + private botId?: string; get status(): ChannelStatus { return this._status; @@ -84,6 +89,8 @@ export class WhatsAppAdapter implements ChannelAdapter { this.client!.on('ready', () => { console.log('WhatsApp bot connected'); this._status = 'connected'; + // Capture bot's own JID for mention detection + this.botId = (this.client as any)?.info?.wid?._serialized; resolve(); }); @@ -150,20 +157,51 @@ export class WhatsAppAdapter implements ChannelAdapter { const from = message.from; - // Ignore group messages (only handle direct messages) - if (from.endsWith('@g.us')) return; + // Group message handling + const isGroup = from.endsWith('@g.us'); + if (isGroup) { + // Check allowed group IDs + const groupId = from.replace(/@g\.us$/, ''); + if ( + !this.config.allowedGroupIds || + this.config.allowedGroupIds.length === 0 || + !this.config.allowedGroupIds.includes(groupId) + ) { + return; // Group not allowed (empty list = no groups) + } - // Check allowed numbers (strip @c.us suffix for comparison) - const phoneNumber = from.replace(/@c\.us$/, ''); - if ( - this.config.allowedNumbers && - this.config.allowedNumbers.length > 0 && - !this.config.allowedNumbers.includes(phoneNumber) - ) { - return; + // Mention requirement in group chats + const requireMention = this.config.requireMention ?? true; + if (requireMention) { + // WhatsApp mentions use @phone_number format in body + // Also check for mentions in the message mentionedIds + const mentionsBot = this.botId + ? message.body?.includes(`@${this.botId.replace(/@c\.us$/, '')}`) || + (message as any).mentionedIds?.some((id: string) => id === this.botId) + : false; + if (!mentionsBot) return; + } + } + + // Check allowed numbers for DMs (strip @c.us suffix for comparison) + if (!isGroup) { + const phoneNumber = from.replace(/@c\.us$/, ''); + if ( + this.config.allowedNumbers && + this.config.allowedNumbers.length > 0 && + !this.config.allowedNumbers.includes(phoneNumber) + ) { + return; + } + } + + // Strip bot mention from message body for group messages + let text = message.body ?? ''; + if (isGroup && this.botId) { + const botNumber = this.botId.replace(/@c\.us$/, ''); + text = text.replace(new RegExp(`@${botNumber}\\b`, 'g'), '').trim(); } - const text = message.body ?? ''; const senderName = message._data?.notifyName; // Detect reset command