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:
@@ -7,6 +7,9 @@ const mockPostMessage = vi.fn();
|
|||||||
const mockStart = vi.fn();
|
const mockStart = vi.fn();
|
||||||
const mockStop = 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. */
|
/** Create a fresh mock Bolt App instance. */
|
||||||
function createMockApp() {
|
function createMockApp() {
|
||||||
capturedMessageHandler = null;
|
capturedMessageHandler = null;
|
||||||
@@ -20,6 +23,12 @@ function createMockApp() {
|
|||||||
chat: {
|
chat: {
|
||||||
postMessage: mockPostMessage,
|
postMessage: mockPostMessage,
|
||||||
},
|
},
|
||||||
|
auth: {
|
||||||
|
test: mockAuthTest,
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
info: mockUsersInfo,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -172,7 +181,7 @@ describe('SlackAdapter', () => {
|
|||||||
const msg: InboundMessage = handler.mock.calls[0][0];
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
||||||
expect(msg.channel).toBe('slack');
|
expect(msg.channel).toBe('slack');
|
||||||
expect(msg.senderId).toBe('C123:1111.0000');
|
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.text).toBe('Hello Flynn');
|
||||||
expect(msg.id).toBe('1234.5678');
|
expect(msg.id).toBe('1234.5678');
|
||||||
});
|
});
|
||||||
@@ -374,4 +383,66 @@ describe('SlackAdapter', () => {
|
|||||||
const msg: InboundMessage = handler.mock.calls[0][0];
|
const msg: InboundMessage = handler.mock.calls[0][0];
|
||||||
expect(msg.text).toBe('Hello');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface SlackAdapterConfig {
|
|||||||
signingSecret: string;
|
signingSecret: string;
|
||||||
/** Channel IDs to respond in. Empty = all channels. */
|
/** Channel IDs to respond in. Empty = all channels. */
|
||||||
allowedChannelIds?: string[];
|
allowedChannelIds?: string[];
|
||||||
|
/** Require bot mention to respond (default: false). */
|
||||||
|
requireMention?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal shape of a Slack message event from Bolt. */
|
/** Minimal shape of a Slack message event from Bolt. */
|
||||||
@@ -49,6 +51,7 @@ export class SlackAdapter implements ChannelAdapter {
|
|||||||
private messageHandler?: (msg: InboundMessage) => void;
|
private messageHandler?: (msg: InboundMessage) => void;
|
||||||
private config: SlackAdapterConfig;
|
private config: SlackAdapterConfig;
|
||||||
private userNameCache: Map<string, string> = new Map();
|
private userNameCache: Map<string, string> = new Map();
|
||||||
|
private botUserId?: string;
|
||||||
|
|
||||||
get status(): ChannelStatus {
|
get status(): ChannelStatus {
|
||||||
return this._status;
|
return this._status;
|
||||||
@@ -81,6 +84,15 @@ export class SlackAdapter implements ChannelAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.app.start();
|
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';
|
this._status = 'connected';
|
||||||
console.log('Slack bot connected via Socket Mode');
|
console.log('Slack bot connected via Socket Mode');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -167,6 +179,15 @@ export class SlackAdapter implements ChannelAdapter {
|
|||||||
return;
|
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)
|
// Build peer ID: channelId:threadTs (thread-aware)
|
||||||
const threadTs = message.thread_ts ?? message.ts ?? '';
|
const threadTs = message.thread_ts ?? message.ts ?? '';
|
||||||
const peerId = `${channelId}:${threadTs}`;
|
const peerId = `${channelId}:${threadTs}`;
|
||||||
|
|||||||
@@ -253,4 +253,187 @@ describe('TelegramAdapter', () => {
|
|||||||
|
|
||||||
expect(next).toHaveBeenCalledTimes(1);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { splitMessage } from '../utils.js';
|
|||||||
export interface TelegramAdapterConfig {
|
export interface TelegramAdapterConfig {
|
||||||
botToken: string;
|
botToken: string;
|
||||||
allowedChatIds: number[];
|
allowedChatIds: number[];
|
||||||
|
/** Require bot mention or reply-to-bot to respond in group chats (default: true). */
|
||||||
|
requireMention?: boolean;
|
||||||
hookEngine?: HookEngine;
|
hookEngine?: HookEngine;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ export class TelegramAdapter implements ChannelAdapter {
|
|||||||
private bot: Bot | null = null;
|
private bot: Bot | null = null;
|
||||||
private messageHandler?: (msg: InboundMessage) => void;
|
private messageHandler?: (msg: InboundMessage) => void;
|
||||||
private config: TelegramAdapterConfig;
|
private config: TelegramAdapterConfig;
|
||||||
|
private botInfo?: { id: number; username?: string };
|
||||||
|
|
||||||
get status(): ChannelStatus {
|
get status(): ChannelStatus {
|
||||||
return this._status;
|
return this._status;
|
||||||
@@ -120,7 +123,33 @@ export class TelegramAdapter implements ChannelAdapter {
|
|||||||
this.bot.on('message:text', async (ctx) => {
|
this.bot.on('message:text', async (ctx) => {
|
||||||
if (!this.messageHandler) return;
|
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
|
// Show typing indicator while processing
|
||||||
await ctx.replyWithChatAction('typing');
|
await ctx.replyWithChatAction('typing');
|
||||||
@@ -140,6 +169,7 @@ export class TelegramAdapter implements ChannelAdapter {
|
|||||||
this.bot.start({
|
this.bot.start({
|
||||||
onStart: (botInfo) => {
|
onStart: (botInfo) => {
|
||||||
console.log(`Telegram bot started: @${botInfo.username}`);
|
console.log(`Telegram bot started: @${botInfo.username}`);
|
||||||
|
this.botInfo = { id: botInfo.id, username: botInfo.username };
|
||||||
this._status = 'connected';
|
this._status = 'connected';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const mockDestroy = vi.fn();
|
|||||||
|
|
||||||
interface MockClientInfo {
|
interface MockClientInfo {
|
||||||
pushname?: string;
|
pushname?: string;
|
||||||
|
wid?: { _serialized: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a fresh mock Client instance. */
|
/** Create a fresh mock Client instance. */
|
||||||
@@ -25,7 +26,7 @@ function createMockClient() {
|
|||||||
initialize: mockInitialize,
|
initialize: mockInitialize,
|
||||||
destroy: mockDestroy,
|
destroy: mockDestroy,
|
||||||
sendMessage: mockSendMessage,
|
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);
|
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();
|
const handler = vi.fn();
|
||||||
adapter.onMessage(handler);
|
adapter.onMessage(handler);
|
||||||
|
|
||||||
@@ -388,10 +389,160 @@ describe('WhatsAppAdapter', () => {
|
|||||||
body: 'Group message',
|
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();
|
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 () => {
|
it('passes data_dir to LocalAuth strategy', async () => {
|
||||||
const adapterWithDir = new WhatsAppAdapter({
|
const adapterWithDir = new WhatsAppAdapter({
|
||||||
dataDir: '/tmp/whatsapp-session',
|
dataDir: '/tmp/whatsapp-session',
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import { splitMessage } from '../utils.js';
|
|||||||
export interface WhatsAppAdapterConfig {
|
export interface WhatsAppAdapterConfig {
|
||||||
/** Phone numbers allowed to interact. Empty = all numbers. */
|
/** Phone numbers allowed to interact. Empty = all numbers. */
|
||||||
allowedNumbers?: string[];
|
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). */
|
/** Directory for session persistence (LocalAuth data path). */
|
||||||
dataDir?: string;
|
dataDir?: string;
|
||||||
}
|
}
|
||||||
@@ -48,6 +52,7 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
|||||||
private client: Client | null = null;
|
private client: Client | null = null;
|
||||||
private messageHandler?: (msg: InboundMessage) => void;
|
private messageHandler?: (msg: InboundMessage) => void;
|
||||||
private config: WhatsAppAdapterConfig;
|
private config: WhatsAppAdapterConfig;
|
||||||
|
private botId?: string;
|
||||||
|
|
||||||
get status(): ChannelStatus {
|
get status(): ChannelStatus {
|
||||||
return this._status;
|
return this._status;
|
||||||
@@ -84,6 +89,8 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
|||||||
this.client!.on('ready', () => {
|
this.client!.on('ready', () => {
|
||||||
console.log('WhatsApp bot connected');
|
console.log('WhatsApp bot connected');
|
||||||
this._status = 'connected';
|
this._status = 'connected';
|
||||||
|
// Capture bot's own JID for mention detection
|
||||||
|
this.botId = (this.client as any)?.info?.wid?._serialized;
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,10 +157,34 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
|||||||
|
|
||||||
const from = message.from;
|
const from = message.from;
|
||||||
|
|
||||||
// Ignore group messages (only handle direct messages)
|
// Group message handling
|
||||||
if (from.endsWith('@g.us')) return;
|
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)
|
// 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$/, '');
|
const phoneNumber = from.replace(/@c\.us$/, '');
|
||||||
if (
|
if (
|
||||||
this.config.allowedNumbers &&
|
this.config.allowedNumbers &&
|
||||||
@@ -162,8 +193,15 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
|||||||
) {
|
) {
|
||||||
return;
|
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;
|
const senderName = message._data?.notifyName;
|
||||||
|
|
||||||
// Detect reset command
|
// Detect reset command
|
||||||
|
|||||||
Reference in New Issue
Block a user