From a5065b354e20d282da2c74c892e442041d85596b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 21:46:11 +0100 Subject: [PATCH] fix(whatsapp): resolve lid mappings for inbound --- CHANGELOG.md | 1 + src/utils.test.ts | 55 ++++++++++++++++++++++ src/utils.ts | 88 +++++++++++++++++++++++++++++------ src/web/inbound.ts | 26 +++-------- src/web/monitor-inbox.test.ts | 76 ++++++++++++++++++++++++++++-- 5 files changed, 208 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f8a9f010..98409349f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ - Hooks: normalize hook agent providers (aliases + msteams support). - WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj - WhatsApp: add broadcast groups for multi-agent replies. (#547) — thanks @pasogott +- WhatsApp: resolve @lid inbound senders via auth-dir mapping fallback + shared resolver. (#365) - iMessage: isolate group-ish threads by chat_id. (#535) — thanks @mdahmann - Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223 - Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj diff --git a/src/utils.test.ts b/src/utils.test.ts index bef3ea296..bf8d4fadf 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -9,6 +9,7 @@ import { jidToE164, normalizeE164, normalizePath, + resolveJidToE164, resolveUserPath, sleep, toWhatsappJid, @@ -97,6 +98,60 @@ describe("jidToE164", () => { expect(jidToE164("123@lid")).toBe("+5551234"); spy.mockRestore(); }); + + it("maps @lid from authDir mapping files", () => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-")); + const mappingPath = path.join(authDir, "lid-mapping-456_reverse.json"); + fs.writeFileSync(mappingPath, JSON.stringify("5559876")); + expect(jidToE164("456@lid", { authDir })).toBe("+5559876"); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + it("maps @hosted.lid from authDir mapping files", () => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-")); + const mappingPath = path.join(authDir, "lid-mapping-789_reverse.json"); + fs.writeFileSync(mappingPath, JSON.stringify(4440001)); + expect(jidToE164("789@hosted.lid", { authDir })).toBe("+4440001"); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + it("accepts hosted PN JIDs", () => { + expect(jidToE164("1555000:2@hosted")).toBe("+1555000"); + }); + + it("falls back through lidMappingDirs in order", () => { + const first = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-lid-a-")); + const second = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-lid-b-")); + const mappingPath = path.join(second, "lid-mapping-321_reverse.json"); + fs.writeFileSync(mappingPath, JSON.stringify("123321")); + expect(jidToE164("321@lid", { lidMappingDirs: [first, second] })).toBe( + "+123321", + ); + fs.rmSync(first, { recursive: true, force: true }); + fs.rmSync(second, { recursive: true, force: true }); + }); +}); + +describe("resolveJidToE164", () => { + it("resolves @lid via lidLookup when mapping file is missing", async () => { + const lidLookup = { + getPNForLID: vi.fn().mockResolvedValue("777:0@s.whatsapp.net"), + }; + await expect(resolveJidToE164("777@lid", { lidLookup })).resolves.toBe( + "+777", + ); + expect(lidLookup.getPNForLID).toHaveBeenCalledWith("777@lid"); + }); + + it("skips lidLookup for non-lid JIDs", async () => { + const lidLookup = { + getPNForLID: vi.fn().mockResolvedValue("888:0@s.whatsapp.net"), + }; + await expect( + resolveJidToE164("888@s.whatsapp.net", { lidLookup }), + ).resolves.toBe("+888"); + expect(lidLookup.getPNForLID).not.toHaveBeenCalled(); + }); }); describe("resolveUserPath", () => { diff --git a/src/utils.ts b/src/utils.ts index 0ddfc6ccf..b1d373702 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { resolveOAuthDir } from "./config/paths.js"; import { logVerbose, shouldLogVerbose } from "./globals.js"; export async function ensureDir(dir: string) { @@ -61,36 +62,93 @@ export function toWhatsappJid(number: string): string { return `${digits}@s.whatsapp.net`; } -export function jidToE164(jid: string): string | null { +export type JidToE164Options = { + authDir?: string; + lidMappingDirs?: string[]; + logMissing?: boolean; +}; + +type LidLookup = { + getPNForLID?: (jid: string) => Promise; +}; + +function resolveLidMappingDirs(opts?: JidToE164Options): string[] { + const dirs = new Set(); + const addDir = (dir?: string | null) => { + if (!dir) return; + dirs.add(resolveUserPath(dir)); + }; + addDir(opts?.authDir); + for (const dir of opts?.lidMappingDirs ?? []) addDir(dir); + addDir(resolveOAuthDir()); + addDir(path.join(CONFIG_DIR, "credentials")); + return [...dirs]; +} + +function readLidReverseMapping( + lid: string, + opts?: JidToE164Options, +): string | null { + const mappingFilename = `lid-mapping-${lid}_reverse.json`; + const mappingDirs = resolveLidMappingDirs(opts); + for (const dir of mappingDirs) { + const mappingPath = path.join(dir, mappingFilename); + try { + const data = fs.readFileSync(mappingPath, "utf8"); + const phone = JSON.parse(data) as string | number | null; + if (phone === null || phone === undefined) continue; + return normalizeE164(String(phone)); + } catch { + // Try the next location. + } + } + return null; +} + +export function jidToE164(jid: string, opts?: JidToE164Options): string | null { // Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234. - const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/); + const match = jid.match(/^(\d+)(?::\d+)?@(s\.whatsapp\.net|hosted)$/); if (match) { const digits = match[1]; return `+${digits}`; } // Support @lid format (WhatsApp Linked ID) - look up reverse mapping - const lidMatch = jid.match(/^(\d+)(?::\d+)?@lid$/); + const lidMatch = jid.match(/^(\d+)(?::\d+)?@(lid|hosted\.lid)$/); if (lidMatch) { const lid = lidMatch[1]; - try { - const mappingPath = `${CONFIG_DIR}/credentials/lid-mapping-${lid}_reverse.json`; - const data = fs.readFileSync(mappingPath, "utf8"); - const phone = JSON.parse(data); - if (phone) return `+${phone}`; - } catch { - if (shouldLogVerbose()) { - logVerbose( - `LID mapping not found for ${lid}; skipping inbound message`, - ); - } - // Mapping not found, fall through + const phone = readLidReverseMapping(lid, opts); + if (phone) return phone; + const shouldLog = opts?.logMissing ?? shouldLogVerbose(); + if (shouldLog) { + logVerbose(`LID mapping not found for ${lid}; skipping inbound message`); } } return null; } +export async function resolveJidToE164( + jid: string | null | undefined, + opts?: JidToE164Options & { lidLookup?: LidLookup }, +): Promise { + if (!jid) return null; + const direct = jidToE164(jid, opts); + if (direct) return direct; + if (!/(@lid|@hosted\.lid)$/.test(jid)) return null; + if (!opts?.lidLookup?.getPNForLID) return null; + try { + const pnJid = await opts.lidLookup.getPNForLID(jid); + if (!pnJid) return null; + return jidToE164(pnJid, opts); + } catch (err) { + if (shouldLogVerbose()) { + logVerbose(`LID mapping lookup failed for ${jid}: ${String(err)}`); + } + return null; + } +} + export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/src/web/inbound.ts b/src/web/inbound.ts index f456d351e..2674f68f5 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -30,6 +30,7 @@ import { isSelfChatMode, jidToE164, normalizeE164, + resolveJidToE164, toWhatsappJid, } from "../utils.js"; import { resolveWhatsAppAccount } from "./accounts.js"; @@ -123,23 +124,10 @@ export async function monitorWebInbox(options: { const GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes const lidLookup = sock.signalRepository?.lidMapping; - const resolveJidToE164 = async ( + const resolveInboundJid = async ( jid: string | null | undefined, - ): Promise => { - if (!jid) return null; - const direct = jidToE164(jid); - if (direct) return direct; - if (!/(@lid|@hosted\.lid)$/.test(jid)) return null; - if (!lidLookup?.getPNForLID) return null; - try { - const pnJid = await lidLookup.getPNForLID(jid); - if (!pnJid) return null; - return jidToE164(pnJid); - } catch (err) { - logVerbose(`LID mapping lookup failed for ${jid}: ${String(err)}`); - return null; - } - }; + ): Promise => + resolveJidToE164(jid, { authDir: options.authDir, lidLookup }); const getGroupMeta = async (jid: string) => { const cached = groupMetaCache.get(jid); @@ -150,7 +138,7 @@ export async function monitorWebInbox(options: { ( await Promise.all( meta.participants?.map(async (p) => { - const mapped = await resolveJidToE164(p.id); + const mapped = await resolveInboundJid(p.id); return mapped ?? p.id; }) ?? [], ) @@ -191,12 +179,12 @@ export async function monitorWebInbox(options: { continue; const group = isJidGroup(remoteJid); const participantJid = msg.key?.participant ?? undefined; - const from = group ? remoteJid : await resolveJidToE164(remoteJid); + const from = group ? remoteJid : await resolveInboundJid(remoteJid); // Skip if we still can't resolve an id to key conversation if (!from) continue; const senderE164 = group ? participantJid - ? await resolveJidToE164(participantJid) + ? await resolveInboundJid(participantJid) : null : from; let groupSubject: string | undefined; diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index f0ba00419..f36a11a0f 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -78,6 +78,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { resetLogger, setLoggerOverride } from "../logging.js"; import { monitorWebInbox } from "./inbound.js"; +const ACCOUNT_ID = "default"; +let authDir: string; + describe("web monitor inbox", () => { beforeEach(() => { vi.clearAllMocks(); @@ -86,12 +89,14 @@ describe("web monitor inbox", () => { code: "PAIRCODE", created: true, }); + authDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-")); }); afterEach(() => { resetLogger(); setLoggerOverride(null); vi.useRealTimers(); + fsSync.rmSync(authDir, { recursive: true, force: true }); }); it("streams inbound messages", async () => { @@ -100,7 +105,12 @@ describe("web monitor inbox", () => { await msg.reply("pong"); }); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ + verbose: false, + onMessage, + accountId: ACCOUNT_ID, + authDir, + }); const sock = await createWaSocket(); expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); const upsert = { @@ -146,7 +156,12 @@ describe("web monitor inbox", () => { return; }); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ + verbose: false, + onMessage, + accountId: ACCOUNT_ID, + authDir, + }); const sock = await createWaSocket(); const getPNForLID = vi.spyOn( sock.signalRepository.lidMapping, @@ -178,12 +193,60 @@ describe("web monitor inbox", () => { await listener.close(); }); + it("resolves LID JIDs via authDir mapping files", async () => { + const onMessage = vi.fn(async () => { + return; + }); + fsSync.writeFileSync( + path.join(authDir, "lid-mapping-555_reverse.json"), + JSON.stringify("1555"), + ); + + const listener = await monitorWebInbox({ + verbose: false, + onMessage, + accountId: ACCOUNT_ID, + authDir, + }); + const sock = await createWaSocket(); + const getPNForLID = vi.spyOn( + sock.signalRepository.lidMapping, + "getPNForLID", + ); + const upsert = { + type: "notify", + messages: [ + { + key: { id: "abc", fromMe: false, remoteJid: "555@lid" }, + message: { conversation: "ping" }, + messageTimestamp: 1_700_000_000, + pushName: "Tester", + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ body: "ping", from: "+1555", to: "+123" }), + ); + expect(getPNForLID).not.toHaveBeenCalled(); + + await listener.close(); + }); + it("resolves group participant LID JIDs via Baileys mapping", async () => { const onMessage = vi.fn(async () => { return; }); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ + verbose: false, + onMessage, + accountId: ACCOUNT_ID, + authDir, + }); const sock = await createWaSocket(); const getPNForLID = vi.spyOn( sock.signalRepository.lidMapping, @@ -234,7 +297,12 @@ describe("web monitor inbox", () => { } }); - const listener = await monitorWebInbox({ verbose: false, onMessage }); + const listener = await monitorWebInbox({ + verbose: false, + onMessage, + accountId: ACCOUNT_ID, + authDir, + }); const sock = await createWaSocket(); const upsert = { type: "notify",