diff --git a/CHANGELOG.md b/CHANGELOG.md index 6362fea75..10884763c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ ### Fixes - CLI/Status: expand tables to full terminal width; improve update + daemon summary lines; keep `status --all` gateway log tail pasteable. +- WhatsApp: detect @lid mentions in groups using authDir reverse mapping + resolve self JID E.164 for mention gating. (#692) — thanks @peschee. ## 2026.1.10 diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 7e0c78c6e..a731a13a2 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -1135,6 +1135,151 @@ describe("web auto-reply", () => { expect(payload.Body).toContain("[from: Bob (+222)]"); }); + it("detects LID mentions using authDir mapping", async () => { + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "ok" }); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const authDir = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-wa-auth-"), + ); + + try { + await fs.writeFile( + path.join(authDir, "lid-mapping-555_reverse.json"), + JSON.stringify("15551234"), + ); + + setLoadConfigMock(() => ({ + whatsapp: { + allowFrom: ["*"], + accounts: { + default: { authDir }, + }, + }, + })); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello group", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + id: "g1", + senderE164: "+111", + senderName: "Alice", + selfE164: "+15551234", + sendComposing, + reply, + sendMedia, + }); + + await capturedOnMessage?.({ + body: "@bot ping", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + id: "g2", + senderE164: "+222", + senderName: "Bob", + mentionedJids: ["555@lid"], + selfE164: "+15551234", + selfJid: "15551234@s.whatsapp.net", + sendComposing, + reply, + sendMedia, + }); + + expect(resolver).toHaveBeenCalledTimes(1); + } finally { + resetLoadConfigMock(); + await rmDirWithRetries(authDir); + } + }); + + it("derives self E.164 from LID selfJid for mention gating", async () => { + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "ok" }); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const authDir = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-wa-auth-"), + ); + + try { + await fs.writeFile( + path.join(authDir, "lid-mapping-777_reverse.json"), + JSON.stringify("15550077"), + ); + + setLoadConfigMock(() => ({ + whatsapp: { + allowFrom: ["*"], + accounts: { + default: { authDir }, + }, + }, + })); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "@bot ping", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + id: "g3", + senderE164: "+333", + senderName: "Cara", + mentionedJids: ["777@lid"], + selfJid: "777@lid", + sendComposing, + reply, + sendMedia, + }); + + expect(resolver).toHaveBeenCalledTimes(1); + } finally { + resetLoadConfigMock(); + await rmDirWithRetries(authDir); + } + }); + it("sets OriginatingTo to the sender for queued routing", async () => { const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index ee9786680..c41f6de11 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -178,6 +178,12 @@ type MentionConfig = { allowFrom?: Array; }; +type MentionTargets = { + normalizedMentions: string[]; + selfE164: string | null; + selfJid: string | null; +}; + function buildMentionConfig( cfg: ReturnType, agentId?: string, @@ -186,25 +192,42 @@ function buildMentionConfig( return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom }; } -function isBotMentioned( +function resolveMentionTargets( + msg: WebInboundMsg, + authDir?: string, +): MentionTargets { + const jidOptions = authDir ? { authDir } : undefined; + const normalizedMentions = msg.mentionedJids?.length + ? msg.mentionedJids + .map((jid) => jidToE164(jid, jidOptions) ?? jid) + .filter(Boolean) + : []; + const selfE164 = + msg.selfE164 ?? (msg.selfJid ? jidToE164(msg.selfJid, jidOptions) : null); + const selfJid = msg.selfJid ? msg.selfJid.replace(/:\\d+/, "") : null; + return { normalizedMentions, selfE164, selfJid }; +} + +function isBotMentionedFromTargets( msg: WebInboundMsg, mentionCfg: MentionConfig, + targets: MentionTargets, ): boolean { const clean = (text: string) => // Remove zero-width and directionality markers WhatsApp injects around display names normalizeMentionText(text); - const isSelfChat = isSelfChatMode(msg.selfE164, mentionCfg.allowFrom); + const isSelfChat = isSelfChatMode(targets.selfE164, mentionCfg.allowFrom); if (msg.mentionedJids?.length && !isSelfChat) { - const normalizedMentions = msg.mentionedJids - .map((jid) => jidToE164(jid) ?? jid) - .filter(Boolean); - if (msg.selfE164 && normalizedMentions.includes(msg.selfE164)) return true; - if (msg.selfJid && msg.selfE164) { + if ( + targets.selfE164 && + targets.normalizedMentions.includes(targets.selfE164) + ) + return true; + if (targets.selfJid && targets.selfE164) { // Some mentions use the bare JID; match on E.164 to be safe. - const bareSelf = msg.selfJid.replace(/:\\d+/, ""); - if (normalizedMentions.includes(bareSelf)) return true; + if (targets.normalizedMentions.includes(targets.selfJid)) return true; } } else if (msg.mentionedJids?.length && isSelfChat) { // Self-chat mode: ignore WhatsApp @mention JIDs, otherwise @mentioning the owner in group chats triggers the bot. @@ -213,8 +236,8 @@ function isBotMentioned( if (mentionCfg.mentionRegexes.some((re) => re.test(bodyClean))) return true; // Fallback: detect body containing our own number (with or without +, spacing) - if (msg.selfE164) { - const selfDigits = msg.selfE164.replace(/\D/g, ""); + if (targets.selfE164) { + const selfDigits = targets.selfE164.replace(/\D/g, ""); if (selfDigits) { const bodyDigits = bodyClean.replace(/[^\d]/g, ""); if (bodyDigits.includes(selfDigits)) return true; @@ -230,15 +253,22 @@ function isBotMentioned( function debugMention( msg: WebInboundMsg, mentionCfg: MentionConfig, + authDir?: string, ): { wasMentioned: boolean; details: Record } { - const result = isBotMentioned(msg, mentionCfg); + const mentionTargets = resolveMentionTargets(msg, authDir); + const result = isBotMentionedFromTargets(msg, mentionCfg, mentionTargets); const details = { from: msg.from, body: msg.body, bodyClean: normalizeMentionText(msg.body), mentionedJids: msg.mentionedJids ?? null, + normalizedMentionedJids: mentionTargets.normalizedMentions.length + ? mentionTargets.normalizedMentions + : null, selfJid: msg.selfJid ?? null, + selfJidBare: mentionTargets.selfJid, selfE164: msg.selfE164 ?? null, + resolvedSelfE164: mentionTargets.selfE164, }; return { wasMentioned: result, details }; } @@ -1584,7 +1614,11 @@ export async function monitorWebProvider( groupHistories.set(groupHistoryKey, history); } - const mentionDebug = debugMention(msg, mentionConfig); + const mentionDebug = debugMention( + msg, + mentionConfig, + account.authDir, + ); replyLogger.debug( { conversationId, diff --git a/src/web/session.ts b/src/web/session.ts index f56fdd006..90e67a1bd 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -405,7 +405,7 @@ export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) { const raw = fsSync.readFileSync(credsPath, "utf-8"); const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined; const jid = parsed?.me?.id ?? null; - const e164 = jid ? jidToE164(jid) : null; + const e164 = jid ? jidToE164(jid, { authDir }) : null; return { e164, jid } as const; } catch { return { e164: null, jid: null } as const;