fix(whatsapp): resolve lid mappings for inbound
This commit is contained in:
@@ -31,6 +31,7 @@
|
|||||||
- Hooks: normalize hook agent providers (aliases + msteams support).
|
- 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: 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: 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
|
- 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
|
- 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
|
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
jidToE164,
|
jidToE164,
|
||||||
normalizeE164,
|
normalizeE164,
|
||||||
normalizePath,
|
normalizePath,
|
||||||
|
resolveJidToE164,
|
||||||
resolveUserPath,
|
resolveUserPath,
|
||||||
sleep,
|
sleep,
|
||||||
toWhatsappJid,
|
toWhatsappJid,
|
||||||
@@ -97,6 +98,60 @@ describe("jidToE164", () => {
|
|||||||
expect(jidToE164("123@lid")).toBe("+5551234");
|
expect(jidToE164("123@lid")).toBe("+5551234");
|
||||||
spy.mockRestore();
|
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", () => {
|
describe("resolveUserPath", () => {
|
||||||
|
|||||||
88
src/utils.ts
88
src/utils.ts
@@ -1,6 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { resolveOAuthDir } from "./config/paths.js";
|
||||||
import { logVerbose, shouldLogVerbose } from "./globals.js";
|
import { logVerbose, shouldLogVerbose } from "./globals.js";
|
||||||
|
|
||||||
export async function ensureDir(dir: string) {
|
export async function ensureDir(dir: string) {
|
||||||
@@ -61,36 +62,93 @@ export function toWhatsappJid(number: string): string {
|
|||||||
return `${digits}@s.whatsapp.net`;
|
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.
|
// 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) {
|
if (match) {
|
||||||
const digits = match[1];
|
const digits = match[1];
|
||||||
return `+${digits}`;
|
return `+${digits}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Support @lid format (WhatsApp Linked ID) - look up reverse mapping
|
// 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) {
|
if (lidMatch) {
|
||||||
const lid = lidMatch[1];
|
const lid = lidMatch[1];
|
||||||
try {
|
const phone = readLidReverseMapping(lid, opts);
|
||||||
const mappingPath = `${CONFIG_DIR}/credentials/lid-mapping-${lid}_reverse.json`;
|
if (phone) return phone;
|
||||||
const data = fs.readFileSync(mappingPath, "utf8");
|
const shouldLog = opts?.logMissing ?? shouldLogVerbose();
|
||||||
const phone = JSON.parse(data);
|
if (shouldLog) {
|
||||||
if (phone) return `+${phone}`;
|
logVerbose(`LID mapping not found for ${lid}; skipping inbound message`);
|
||||||
} catch {
|
|
||||||
if (shouldLogVerbose()) {
|
|
||||||
logVerbose(
|
|
||||||
`LID mapping not found for ${lid}; skipping inbound message`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Mapping not found, fall through
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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) {
|
export function sleep(ms: number) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
isSelfChatMode,
|
isSelfChatMode,
|
||||||
jidToE164,
|
jidToE164,
|
||||||
normalizeE164,
|
normalizeE164,
|
||||||
|
resolveJidToE164,
|
||||||
toWhatsappJid,
|
toWhatsappJid,
|
||||||
} from "../utils.js";
|
} from "../utils.js";
|
||||||
import { resolveWhatsAppAccount } from "./accounts.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 GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
const lidLookup = sock.signalRepository?.lidMapping;
|
const lidLookup = sock.signalRepository?.lidMapping;
|
||||||
|
|
||||||
const resolveJidToE164 = async (
|
const resolveInboundJid = async (
|
||||||
jid: string | null | undefined,
|
jid: string | null | undefined,
|
||||||
): Promise<string | null> => {
|
): Promise<string | null> =>
|
||||||
if (!jid) return null;
|
resolveJidToE164(jid, { authDir: options.authDir, lidLookup });
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getGroupMeta = async (jid: string) => {
|
const getGroupMeta = async (jid: string) => {
|
||||||
const cached = groupMetaCache.get(jid);
|
const cached = groupMetaCache.get(jid);
|
||||||
@@ -150,7 +138,7 @@ export async function monitorWebInbox(options: {
|
|||||||
(
|
(
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
meta.participants?.map(async (p) => {
|
meta.participants?.map(async (p) => {
|
||||||
const mapped = await resolveJidToE164(p.id);
|
const mapped = await resolveInboundJid(p.id);
|
||||||
return mapped ?? p.id;
|
return mapped ?? p.id;
|
||||||
}) ?? [],
|
}) ?? [],
|
||||||
)
|
)
|
||||||
@@ -191,12 +179,12 @@ export async function monitorWebInbox(options: {
|
|||||||
continue;
|
continue;
|
||||||
const group = isJidGroup(remoteJid);
|
const group = isJidGroup(remoteJid);
|
||||||
const participantJid = msg.key?.participant ?? undefined;
|
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
|
// Skip if we still can't resolve an id to key conversation
|
||||||
if (!from) continue;
|
if (!from) continue;
|
||||||
const senderE164 = group
|
const senderE164 = group
|
||||||
? participantJid
|
? participantJid
|
||||||
? await resolveJidToE164(participantJid)
|
? await resolveInboundJid(participantJid)
|
||||||
: null
|
: null
|
||||||
: from;
|
: from;
|
||||||
let groupSubject: string | undefined;
|
let groupSubject: string | undefined;
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|||||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||||
import { monitorWebInbox } from "./inbound.js";
|
import { monitorWebInbox } from "./inbound.js";
|
||||||
|
|
||||||
|
const ACCOUNT_ID = "default";
|
||||||
|
let authDir: string;
|
||||||
|
|
||||||
describe("web monitor inbox", () => {
|
describe("web monitor inbox", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -86,12 +89,14 @@ describe("web monitor inbox", () => {
|
|||||||
code: "PAIRCODE",
|
code: "PAIRCODE",
|
||||||
created: true,
|
created: true,
|
||||||
});
|
});
|
||||||
|
authDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
resetLogger();
|
resetLogger();
|
||||||
setLoggerOverride(null);
|
setLoggerOverride(null);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
fsSync.rmSync(authDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("streams inbound messages", async () => {
|
it("streams inbound messages", async () => {
|
||||||
@@ -100,7 +105,12 @@ describe("web monitor inbox", () => {
|
|||||||
await msg.reply("pong");
|
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();
|
const sock = await createWaSocket();
|
||||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available");
|
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available");
|
||||||
const upsert = {
|
const upsert = {
|
||||||
@@ -146,7 +156,12 @@ describe("web monitor inbox", () => {
|
|||||||
return;
|
return;
|
||||||
});
|
});
|
||||||
|
|
||||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
const listener = await monitorWebInbox({
|
||||||
|
verbose: false,
|
||||||
|
onMessage,
|
||||||
|
accountId: ACCOUNT_ID,
|
||||||
|
authDir,
|
||||||
|
});
|
||||||
const sock = await createWaSocket();
|
const sock = await createWaSocket();
|
||||||
const getPNForLID = vi.spyOn(
|
const getPNForLID = vi.spyOn(
|
||||||
sock.signalRepository.lidMapping,
|
sock.signalRepository.lidMapping,
|
||||||
@@ -178,12 +193,60 @@ describe("web monitor inbox", () => {
|
|||||||
await listener.close();
|
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 () => {
|
it("resolves group participant LID JIDs via Baileys mapping", async () => {
|
||||||
const onMessage = vi.fn(async () => {
|
const onMessage = vi.fn(async () => {
|
||||||
return;
|
return;
|
||||||
});
|
});
|
||||||
|
|
||||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
const listener = await monitorWebInbox({
|
||||||
|
verbose: false,
|
||||||
|
onMessage,
|
||||||
|
accountId: ACCOUNT_ID,
|
||||||
|
authDir,
|
||||||
|
});
|
||||||
const sock = await createWaSocket();
|
const sock = await createWaSocket();
|
||||||
const getPNForLID = vi.spyOn(
|
const getPNForLID = vi.spyOn(
|
||||||
sock.signalRepository.lidMapping,
|
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 sock = await createWaSocket();
|
||||||
const upsert = {
|
const upsert = {
|
||||||
type: "notify",
|
type: "notify",
|
||||||
|
|||||||
Reference in New Issue
Block a user