From 6444258ad3746e33ef173c7183e201a0f4b693fa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Jan 2026 01:14:57 +0100 Subject: [PATCH] fix: handle WhatsApp LID mentions (#692) (thanks @peschee) --- CHANGELOG.md | 1 + pnpm-lock.yaml | 10 +-- src/web/auto-reply.test.ts | 145 +++++++++++++++++++++++++++++++++++++ src/web/auto-reply.ts | 60 +++++++++++---- src/web/session.ts | 2 +- 5 files changed, 198 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 663bb2e7b..c854cb80a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,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/pnpm-lock.yaml b/pnpm-lock.yaml index 96ea38d3b..17d826333 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: patchedDependencies: '@mariozechner/pi-ai@0.42.2': - hash: 626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a + hash: d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97 path: patches/@mariozechner__pi-ai@0.42.2.patch importers: @@ -36,7 +36,7 @@ importers: version: 0.42.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-ai': specifier: ^0.42.2 - version: 0.42.2(patch_hash=626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a)(ws@8.19.0)(zod@4.3.5) + version: 0.42.2(patch_hash=d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-coding-agent': specifier: ^0.42.2 version: 0.42.2(ws@8.19.0)(zod@4.3.5) @@ -3777,7 +3777,7 @@ snapshots: '@mariozechner/pi-agent-core@0.42.2(ws@8.19.0)(zod@4.3.5)': dependencies: - '@mariozechner/pi-ai': 0.42.2(patch_hash=626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-ai': 0.42.2(patch_hash=d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': 0.42.2 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -3787,7 +3787,7 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.42.2(patch_hash=626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a)(ws@8.19.0)(zod@4.3.5)': + '@mariozechner/pi-ai@0.42.2(patch_hash=d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97)(ws@8.19.0)(zod@4.3.5)': dependencies: '@anthropic-ai/sdk': 0.71.2(zod@4.3.5) '@google/genai': 1.34.0 @@ -3811,7 +3811,7 @@ snapshots: dependencies: '@mariozechner/clipboard': 0.3.0 '@mariozechner/pi-agent-core': 0.42.2(ws@8.19.0)(zod@4.3.5) - '@mariozechner/pi-ai': 0.42.2(patch_hash=626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-ai': 0.42.2(patch_hash=d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': 0.42.2 chalk: 5.6.2 cli-highlight: 2.1.11 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 8306b8186..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,26 +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, - authDir?: string, + 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, authDir ? { authDir } : undefined) ?? 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. @@ -214,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; @@ -233,14 +255,20 @@ function debugMention( mentionCfg: MentionConfig, authDir?: string, ): { wasMentioned: boolean; details: Record } { - const result = isBotMentioned(msg, mentionCfg, authDir); + 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 }; } @@ -1586,7 +1614,11 @@ export async function monitorWebProvider( groupHistories.set(groupHistoryKey, history); } - const mentionDebug = debugMention(msg, mentionConfig, account.authDir); + 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;