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.
This commit is contained in:
William Valentin
2026-02-06 16:51:52 -08:00
parent 20930a4816
commit 647d7779c7
6 changed files with 510 additions and 16 deletions
+183
View File
@@ -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');
});
});
+31 -1
View File
@@ -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';
},
});