fix(whatsapp): resolve lid mappings for inbound

This commit is contained in:
Peter Steinberger
2026-01-09 21:46:11 +01:00
parent 5fa26bfec7
commit a5065b354e
5 changed files with 208 additions and 38 deletions

View File

@@ -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

View File

@@ -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", () => {

View File

@@ -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<string | null>;
};
function resolveLidMappingDirs(opts?: JidToE164Options): string[] {
const dirs = new Set<string>();
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<string | null> {
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));
}

View File

@@ -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<string | null> => {
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<string | null> =>
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;

View File

@@ -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",