From c52591f68f5da809a6477eaf072ac0d9471c8f2a Mon Sep 17 00:00:00 2001 From: nanoclaw-coder Date: Wed, 20 May 2026 03:06:05 +0200 Subject: [PATCH] fix(whatsapp): detect group @-mentions via contextInfo.mentionedJid Before this change, the inbound construction site hard-coded `isMention: !isGroup ? true : undefined`, which meant group messages that explicitly @-mentioned the bot never set the field. The router then never woke the agent on a mention-only trigger. Detection lives in a new pure helper `isBotMentionedInGroup` which scans `contextInfo.mentionedJid` across the four message types that can host mentions (extendedTextMessage + image/video/document captions), matching against both the bot's phone JID and LID since modern WhatsApp clients increasingly emit the LID for phone-number mentions. A second helper `computeIsMention` wraps the DM/group ternary so both branches of the fix are unit-testable. Tests in src/channels/whatsapp.test.ts cover phone-JID detection, LID-only detection, image-caption mentions, the negative cases, and the call-site isMention semantics for DMs vs groups vs no-mention. Fixes #2560 --- src/channels/whatsapp.test.ts | 101 ++++++++++++++++++++++++++++++++++ src/channels/whatsapp.ts | 69 ++++++++++++++++++++++- 2 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 src/channels/whatsapp.test.ts diff --git a/src/channels/whatsapp.test.ts b/src/channels/whatsapp.test.ts new file mode 100644 index 000000000..bef838dfc --- /dev/null +++ b/src/channels/whatsapp.test.ts @@ -0,0 +1,101 @@ +/** + * Regression coverage for #2560 — group @-mentions of the bot must set + * `InboundMessage.isMention`. Before the fix, the inbound construction + * site hard-coded `isMention: !isGroup ? true : undefined`, which dropped + * every group mention on the floor and prevented the router from waking + * the agent on a mention-only trigger. + * + * The detection logic lives in the exported pure helper `isBotMentionedInGroup`; + * the inbound site calls it with `normalized`, `botPhoneJid`, `botLidUser`. + * `isMention` is then computed as: + * + * isMention: !isGroup ? true : botMentionedInGroup ? true : undefined + * + * Both the helper and the call-site ternary are covered below so a future + * refactor that breaks either part fails this suite. + */ +import { describe, it, expect } from 'vitest'; + +import { computeIsMention, isBotMentionedInGroup } from './whatsapp.js'; + +const BOT_PHONE_JID = '15550009999@s.whatsapp.net'; +const BOT_LID_USER = '987654321'; + +describe('isBotMentionedInGroup (#2560)', () => { + it('detects the bot phone JID in extendedTextMessage.contextInfo.mentionedJid', () => { + const normalized = { + extendedTextMessage: { + text: 'hey @15550009999 take a look', + contextInfo: { mentionedJid: [BOT_PHONE_JID] }, + }, + }; + expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(true); + }); + + it('returns false when the bot is not in mentionedJid', () => { + const normalized = { + extendedTextMessage: { + text: 'hey @15551112222 take a look', + contextInfo: { mentionedJid: ['15551112222@s.whatsapp.net'] }, + }, + }; + expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(false); + }); + + it('detects an LID-only mention when no phone JID is in the list', () => { + // Modern WhatsApp clients increasingly emit the LID even when the + // human typed a phone-number mention; the phone JID may not appear. + const normalized = { + extendedTextMessage: { + contextInfo: { mentionedJid: [`${BOT_LID_USER}@lid`] }, + }, + }; + expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(true); + }); + + it('detects a mention in an image caption', () => { + const normalized = { + imageMessage: { + caption: 'check this @15550009999', + contextInfo: { mentionedJid: [BOT_PHONE_JID] }, + }, + }; + expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(true); + }); + + it('returns false on an empty / missing mentionedJid array', () => { + expect(isBotMentionedInGroup({}, BOT_PHONE_JID, BOT_LID_USER)).toBe(false); + expect( + isBotMentionedInGroup( + { extendedTextMessage: { contextInfo: { mentionedJid: [] } } }, + BOT_PHONE_JID, + BOT_LID_USER, + ), + ).toBe(false); + }); + + it('returns false when neither bot identifier is known', () => { + const normalized = { + extendedTextMessage: { + contextInfo: { mentionedJid: [BOT_PHONE_JID, `${BOT_LID_USER}@lid`] }, + }, + }; + expect(isBotMentionedInGroup(normalized, undefined, undefined)).toBe(false); + }); +}); + +describe('InboundMessage.isMention semantics (#2560)', () => { + it('is undefined for a group message with no bot mention', () => { + expect(computeIsMention(true, false)).toBeUndefined(); + }); + + it('is true for a group message where the bot is mentioned', () => { + expect(computeIsMention(true, true)).toBe(true); + }); + + it('is true for a DM regardless of mention state', () => { + // DMs are unconditionally mentions — the helper isn't consulted there. + expect(computeIsMention(false, false)).toBe(true); + expect(computeIsMention(false, true)).toBe(true); + }); +}); diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index 901762e95..ee88bccfe 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -157,6 +157,64 @@ function formatWhatsApp(text: string): string { return segments.map(({ content, isProtected }) => (isProtected ? content : transformForWhatsApp(content))).join(''); } +/** + * Subset of a normalized Baileys message content carrying the message + * types that can host a `contextInfo.mentionedJid` array. Kept as a + * structural type so the helper (and its tests) don't pull in the full + * `proto.IMessage` shape just to construct fixtures. + */ +type MentionContextSource = { + extendedTextMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null; + imageMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null; + videoMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null; + documentMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null; +}; + +/** + * Detect an explicit @-mention of the bot in a WhatsApp group message. + * WhatsApp carries mentions in `contextInfo.mentionedJid` on the text + + * caption-bearing message types. Matches against both the bot's phone + * JID and LID — most modern clients emit the LID even when the human + * typed a phone-number mention. + * + * Exported for unit testing. The inbound construction site calls this + * to set `InboundMessage.isMention` for group messages (#2560). DMs are + * unconditionally mentions and don't go through this helper. + */ +export function isBotMentionedInGroup( + normalized: MentionContextSource, + botPhoneJid: string | undefined, + botLidUser: string | undefined, +): boolean { + if (!botPhoneJid && !botLidUser) return false; + const mentionedJids: string[] = [ + ...(normalized.extendedTextMessage?.contextInfo?.mentionedJid ?? []), + ...(normalized.imageMessage?.contextInfo?.mentionedJid ?? []), + ...(normalized.videoMessage?.contextInfo?.mentionedJid ?? []), + ...(normalized.documentMessage?.contextInfo?.mentionedJid ?? []), + ]; + const botLidJid = botLidUser ? `${botLidUser}@lid` : undefined; + return mentionedJids.some((jid) => { + if (!jid) return false; + const bare = jid.split(':')[0]; + return bare === botPhoneJid || bare === botLidJid; + }); +} + +/** + * Compute `InboundMessage.isMention` for a WhatsApp message: + * - DMs are always mentions (router auto-engages on the bot's behalf). + * - Group messages are mentions only when the bot is explicitly tagged. + * + * Returns `true | undefined` rather than `true | false` because the + * `InboundMessage` field is `isMention?: boolean` and downstream code + * treats `undefined` differently than an explicit `false` (#2560). + */ +export function computeIsMention(isGroup: boolean, botMentionedInGroup: boolean): true | undefined { + if (!isGroup) return true; + return botMentionedInGroup ? true : undefined; +} + /** Map file extension to Baileys media message type. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function buildMediaMessage(data: Buffer, filename: string, ext: string, caption?: string): any { @@ -613,14 +671,21 @@ registerChannelAdapter('whatsapp', { } } + // Detect explicit @-mentions of the bot in groups. Detail in + // isBotMentionedInGroup(); short version is contextInfo.mentionedJid + // on text + caption-bearing messages, matched against the bot's + // phone JID and LID (#2560). + const botMentionedInGroup = isGroup && isBotMentionedInGroup(normalized, botPhoneJid, botLidUser); + const inbound: InboundMessage = { id: msg.key.id || `wa-${Date.now()}`, kind: 'chat', // DMs are addressed to the bot by definition. Mark them as // platform-confirmed mentions so the router auto-creates an // approval-required messaging_group when the chat is unknown, - // instead of silently dropping. - isMention: !isGroup ? true : undefined, + // instead of silently dropping. In groups, only an explicit + // @-mention counts. + isMention: computeIsMention(isGroup, botMentionedInGroup), isGroup, content: { text: content,