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:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user