diff --git a/src/channels/whatsapp.test.ts b/src/channels/whatsapp.test.ts index b3e59bc1d..eddaa3b75 100644 --- a/src/channels/whatsapp.test.ts +++ b/src/channels/whatsapp.test.ts @@ -1,6 +1,104 @@ +/** + * 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 { parseWhatsAppMentions } from './whatsapp.js'; +import { computeIsMention, isBotMentionedInGroup, parseWhatsAppMentions } 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); + }); +}); describe('parseWhatsAppMentions', () => { it('returns empty mentions for plain text', () => { diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index 468845248..6ff66aa5a 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -188,6 +188,64 @@ function formatWhatsApp(text: string): { text: string; mentions: string[] } { return { text: out, mentions: [...mentions] }; } +/** + * 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 { @@ -511,6 +569,17 @@ registerChannelAdapter('whatsapp', { }); } else { log.info('WhatsApp logged out'); + // Delete auth credentials immediately. Keeping stale credentials + // causes the next service restart to attempt authentication with an + // invalidated session, producing a second 401 that can trigger + // WhatsApp's re-link cooldown ("can't link new devices now"). + try { + fs.rmSync(authDir, { recursive: true, force: true }); + fs.mkdirSync(authDir, { recursive: true }); + log.info('WhatsApp auth cleared — set WHATSAPP_ENABLED=true and restart to re-link'); + } catch (err) { + log.error('Failed to clear WhatsApp auth after logout', { err }); + } if (rejectFirstOpen) { rejectFirstOpen(new Error('WhatsApp logged out')); rejectFirstOpen = undefined; @@ -653,14 +722,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,