fix(whatsapp): resolve lid mappings for inbound
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
88
src/utils.ts
88
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<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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user