diff --git a/extensions/whatsapp/src/approval-reactions.test.ts b/extensions/whatsapp/src/approval-reactions.test.ts index dbf7bb12bc8c..c227d93b52a1 100644 --- a/extensions/whatsapp/src/approval-reactions.test.ts +++ b/extensions/whatsapp/src/approval-reactions.test.ts @@ -8,6 +8,7 @@ import { registerWhatsAppApprovalReactionTargetForOutboundMessage, resolveWhatsAppApprovalReactionTargetWithPersistence, } from "./approval-reactions.js"; +import { resolveEquivalentWhatsAppDirectChatJids, type LidLookup } from "./text-runtime.js"; const resolverMocks = vi.hoisted(() => ({ resolveWhatsAppApproval: vi.fn(), @@ -19,6 +20,53 @@ vi.mock("./approval-resolver.js", () => ({ isApprovalNotFoundError: resolverMocks.isApprovalNotFoundError, })); +function approvalConfig(allowFrom: string[]) { + return { + channels: { + whatsapp: { + allowFrom, + }, + }, + }; +} + +function registerExecApprovalTarget(params: { remoteJid: string; approvalId?: string }): void { + registerWhatsAppApprovalReactionTarget({ + accountId: "default", + remoteJid: params.remoteJid, + messageId: "approval-message", + approvalId: params.approvalId ?? "exec-direct", + allowedDecisions: ["allow-once", "deny"], + }); +} + +function buildReactionMessage(params: { + remoteJid: string; + reactionRemoteJid?: string; + participant?: string; + fromMe?: boolean; + reactionFromMe?: boolean; +}) { + return { + key: { + id: "reaction-message", + remoteJid: params.remoteJid, + ...(params?.participant ? { participant: params.participant } : {}), + fromMe: params.fromMe ?? false, + }, + message: { + reactionMessage: { + text: "👍", + key: { + remoteJid: params.reactionRemoteJid ?? params.remoteJid, + id: "approval-message", + ...(params.reactionFromMe === undefined ? {} : { fromMe: params.reactionFromMe }), + }, + }, + }, + } as never; +} + describe("WhatsApp approval reactions", () => { beforeEach(() => { clearWhatsAppApprovalReactionTargetsForTest(); @@ -131,43 +179,19 @@ describe("WhatsApp approval reactions", () => { }); const handled = await maybeResolveWhatsAppApprovalReaction({ - cfg: { - channels: { - whatsapp: { - allowFrom: ["+15551230000"], - }, - }, - }, + cfg: approvalConfig(["+15551230000"]), accountId: "default", - msg: { - key: { - remoteJid: "120363401234567890@g.us", - participant: "15551230000@s.whatsapp.net", - fromMe: false, - }, - message: { - reactionMessage: { - text: "👍", - key: { - remoteJid: "120363401234567890@g.us", - id: "approval-message", - }, - }, - }, - } as never, + msg: buildReactionMessage({ + remoteJid: "120363401234567890@g.us", + participant: "15551230000@s.whatsapp.net", + }), resolveInboundJid: async (jid) => jid === "15551230000@s.whatsapp.net" ? "+15551230000" : null, }); expect(handled).toBe(true); expect(resolverMocks.resolveWhatsAppApproval).toHaveBeenCalledWith({ - cfg: { - channels: { - whatsapp: { - allowFrom: ["+15551230000"], - }, - }, - }, + cfg: approvalConfig(["+15551230000"]), approvalId: "plugin:abc", decision: "allow-once", senderId: "+15551230000", @@ -185,44 +209,20 @@ describe("WhatsApp approval reactions", () => { }); const handled = await maybeResolveWhatsAppApprovalReaction({ - cfg: { - channels: { - whatsapp: { - allowFrom: ["+15551230001"], - }, - }, - }, + cfg: approvalConfig(["+15551230001"]), accountId: "default", - msg: { - key: { - id: "reaction-message", - remoteJid: "276853659042038@lid", - fromMe: true, - }, - message: { - reactionMessage: { - text: "👍", - key: { - remoteJid: "276853659042038@lid", - id: "approval-message", - fromMe: true, - }, - }, - }, - } as never, + msg: buildReactionMessage({ + remoteJid: "276853659042038@lid", + fromMe: true, + reactionFromMe: true, + }), selfLid: "276853659042038@lid", resolveInboundJid: async (jid) => (jid === "276853659042038@lid" ? "+15551230001" : null), }); expect(handled).toBe(true); expect(resolverMocks.resolveWhatsAppApproval).toHaveBeenCalledWith({ - cfg: { - channels: { - whatsapp: { - allowFrom: ["+15551230001"], - }, - }, - }, + cfg: approvalConfig(["+15551230001"]), approvalId: "exec-self", decision: "allow-once", senderId: "+15551230001", @@ -230,6 +230,124 @@ describe("WhatsApp approval reactions", () => { }); }); + it.each([ + { + name: "stored PN target from outer chat JID", + storedRemoteJid: "15551230001@s.whatsapp.net", + eventRemoteJid: "15551230001@s.whatsapp.net", + reactionRemoteJid: "276853659042038@lid", + actorId: "+15551230001", + }, + { + name: "stored LID target from PN event", + storedRemoteJid: "276853659042038@lid", + eventRemoteJid: "15551230001@s.whatsapp.net", + actorId: "+15551230001", + lidForPn: "276853659042038@lid", + }, + { + name: "stored PN target from LID event", + storedRemoteJid: "15551230001@s.whatsapp.net", + eventRemoteJid: "276853659042038@lid", + actorId: "+15551230001", + pnForLid: "15551230001:0@s.whatsapp.net", + }, + { + name: "stored PN target from device-qualified PN event", + storedRemoteJid: "15551230001@s.whatsapp.net", + eventRemoteJid: "15551230001:0@s.whatsapp.net", + actorId: "+15551230001", + }, + { + name: "stored LID target from device-qualified LID event", + storedRemoteJid: "276853659042038@lid", + eventRemoteJid: "276853659042038:1@lid", + actorId: "+15551230001", + }, + ])("resolves direct approval reactions across PN/LID target drift: $name", async (testCase) => { + registerExecApprovalTarget({ remoteJid: testCase.storedRemoteJid }); + const lidLookup: LidLookup = { + getLIDForPN: vi.fn().mockResolvedValue(testCase.lidForPn ?? null), + getPNForLID: vi.fn().mockResolvedValue(testCase.pnForLid ?? null), + }; + + const handled = await maybeResolveWhatsAppApprovalReaction({ + cfg: approvalConfig([testCase.actorId]), + accountId: "default", + msg: buildReactionMessage({ + remoteJid: testCase.eventRemoteJid, + reactionRemoteJid: testCase.reactionRemoteJid, + }), + resolveInboundJid: async (jid) => (jid === testCase.eventRemoteJid ? testCase.actorId : null), + resolveReactionTargetJids: async (jid) => + resolveEquivalentWhatsAppDirectChatJids(jid, { lidLookup }), + }); + + expect(handled).toBe(true); + expect(resolverMocks.resolveWhatsAppApproval).toHaveBeenCalledWith({ + cfg: approvalConfig([testCase.actorId]), + approvalId: "exec-direct", + decision: "allow-once", + senderId: testCase.actorId, + gatewayUrl: undefined, + }); + }); + + it("does not use a group reaction actor as a direct-chat target candidate", async () => { + registerExecApprovalTarget({ remoteJid: "15551230000@s.whatsapp.net" }); + const lidLookup: LidLookup = { + getLIDForPN: vi.fn().mockResolvedValue("15551230000@s.whatsapp.net"), + getPNForLID: vi.fn().mockResolvedValue("15551230000@s.whatsapp.net"), + }; + + const handled = await maybeResolveWhatsAppApprovalReaction({ + cfg: approvalConfig(["+15551230000"]), + accountId: "default", + msg: buildReactionMessage({ + remoteJid: "120363401234567890@g.us", + participant: "15551230000@s.whatsapp.net", + }), + resolveInboundJid: async () => "+15551230000", + resolveReactionTargetJids: async (jid) => + resolveEquivalentWhatsAppDirectChatJids(jid, { lidLookup }), + }); + + expect(handled).toBe(false); + expect(resolverMocks.resolveWhatsAppApproval).not.toHaveBeenCalled(); + }); + + it("unregisters the matched target candidate when an approval expired", async () => { + registerExecApprovalTarget({ + remoteJid: "15551230000@s.whatsapp.net", + approvalId: "exec-expired", + }); + resolverMocks.resolveWhatsAppApproval.mockRejectedValueOnce( + new Error("unknown or expired approval id"), + ); + resolverMocks.isApprovalNotFoundError.mockReturnValue(true); + + const handled = await maybeResolveWhatsAppApprovalReaction({ + cfg: approvalConfig(["+15551230000"]), + accountId: "default", + msg: buildReactionMessage({ remoteJid: "276853659042038@lid" }), + resolveInboundJid: async () => "+15551230000", + resolveReactionTargetJids: async (jid) => + resolveEquivalentWhatsAppDirectChatJids(jid, { + lidLookup: { getPNForLID: vi.fn().mockResolvedValue("15551230000@s.whatsapp.net") }, + }), + }); + + expect(handled).toBe(true); + await expect( + resolveWhatsAppApprovalReactionTargetWithPersistence({ + accountId: "default", + remoteJid: "15551230000@s.whatsapp.net", + messageId: "approval-message", + reactionKey: "👍", + }), + ).resolves.toBeNull(); + }); + it("does not attribute a peer DM fromMe reaction to the peer", async () => { registerWhatsAppApprovalReactionTarget({ accountId: "default", @@ -240,31 +358,13 @@ describe("WhatsApp approval reactions", () => { }); const handled = await maybeResolveWhatsAppApprovalReaction({ - cfg: { - channels: { - whatsapp: { - allowFrom: ["+15551230000"], - }, - }, - }, + cfg: approvalConfig(["+15551230000"]), accountId: "default", - msg: { - key: { - id: "reaction-message", - remoteJid: "15551230000@s.whatsapp.net", - fromMe: true, - }, - message: { - reactionMessage: { - text: "👍", - key: { - remoteJid: "15551230000@s.whatsapp.net", - id: "approval-message", - fromMe: true, - }, - }, - }, - } as never, + msg: buildReactionMessage({ + remoteJid: "15551230000@s.whatsapp.net", + fromMe: true, + reactionFromMe: true, + }), selfLid: "276853659042038@lid", resolveInboundJid: async (jid) => { if (jid === "15551230000@s.whatsapp.net") { @@ -291,29 +391,9 @@ describe("WhatsApp approval reactions", () => { }); const handled = await maybeResolveWhatsAppApprovalReaction({ - cfg: { - channels: { - whatsapp: { - allowFrom: ["+15551230000"], - }, - }, - }, + cfg: approvalConfig(["+15551230000"]), accountId: "default", - msg: { - key: { - remoteJid: "120363401234567890@g.us", - fromMe: false, - }, - message: { - reactionMessage: { - text: "👍", - key: { - remoteJid: "120363401234567890@g.us", - id: "approval-message", - }, - }, - }, - } as never, + msg: buildReactionMessage({ remoteJid: "120363401234567890@g.us" }), resolveInboundJid: async () => null, }); @@ -337,21 +417,7 @@ describe("WhatsApp approval reactions", () => { }, }, accountId: "default", - msg: { - key: { - remoteJid: "15551230000@s.whatsapp.net", - fromMe: false, - }, - message: { - reactionMessage: { - text: "👍", - key: { - remoteJid: "15551230000@s.whatsapp.net", - id: "approval-message", - }, - }, - }, - } as never, + msg: buildReactionMessage({ remoteJid: "15551230000@s.whatsapp.net" }), resolveInboundJid: async () => "+15551230000", }); @@ -375,22 +441,10 @@ describe("WhatsApp approval reactions", () => { }, }, accountId: "default", - msg: { - key: { - remoteJid: "120363401234567890@g.us", - participant: "15551230000@s.whatsapp.net", - fromMe: false, - }, - message: { - reactionMessage: { - text: "👍", - key: { - remoteJid: "120363401234567890@g.us", - id: "approval-message", - }, - }, - }, - } as never, + msg: buildReactionMessage({ + remoteJid: "120363401234567890@g.us", + participant: "15551230000@s.whatsapp.net", + }), resolveInboundJid: async () => "+15551230000", }); diff --git a/extensions/whatsapp/src/approval-reactions.ts b/extensions/whatsapp/src/approval-reactions.ts index 636213c81d97..324de6e6269c 100644 --- a/extensions/whatsapp/src/approval-reactions.ts +++ b/extensions/whatsapp/src/approval-reactions.ts @@ -26,12 +26,16 @@ type WhatsAppApprovalReactionResolution = { type WhatsAppApprovalReactionTarget = ApprovalReactionTargetRecord; type WhatsAppApprovalReactionEvent = { - remoteJid: string; + remoteJids: string[]; messageId: string; actorJid: string; reactionKey: string; }; +type ResolvedWhatsAppApprovalReactionTarget = WhatsAppApprovalReactionResolution & { + remoteJid: string; +}; + let resolverRuntimePromise: Promise | undefined; const whatsappApprovalReactionTargets = @@ -63,6 +67,13 @@ function buildReactionTargetKey(params: { return `${accountId}:${remoteJid}:${messageId}`; } +function addCandidateRemoteJid(target: string[], value: string | null | undefined): void { + const remoteJid = value?.trim(); + if (remoteJid && !target.includes(remoteJid)) { + target.push(remoteJid); + } +} + function reportPersistentApprovalReactionError(error: unknown): void { try { getOptionalWhatsAppRuntime() @@ -228,6 +239,42 @@ export async function resolveWhatsAppApprovalReactionTargetWithPersistence(param }); } +async function resolveWhatsAppApprovalReactionTargetFromCandidates(params: { + accountId: string; + observedRemoteJids: readonly string[]; + messageId: string; + reactionKey: string; + resolveReactionTargetJids?: (jid: string) => Promise; + logVerboseMessage?: (message: string) => void; +}): Promise { + const candidateRemoteJids: string[] = []; + for (const observedRemoteJid of params.observedRemoteJids) { + addCandidateRemoteJid(candidateRemoteJids, observedRemoteJid); + try { + for (const candidate of (await params.resolveReactionTargetJids?.(observedRemoteJid)) ?? []) { + addCandidateRemoteJid(candidateRemoteJids, candidate); + } + } catch (error) { + params.logVerboseMessage?.( + `whatsapp: approval reaction target JID mapping failed for ${observedRemoteJid}: ${String(error)}`, + ); + } + } + + for (const remoteJid of candidateRemoteJids) { + const target = await resolveWhatsAppApprovalReactionTargetWithPersistence({ + accountId: params.accountId, + remoteJid, + messageId: params.messageId, + reactionKey: params.reactionKey, + }); + if (target) { + return { ...target, remoteJid }; + } + } + return null; +} + function readWhatsAppApprovalReactionEvent(params: { msg: WAMessage; selfJid?: string | null; @@ -237,17 +284,19 @@ function readWhatsAppApprovalReactionEvent(params: { const reaction = msg.message?.reactionMessage; const reactionKey = reaction?.text?.trim() ?? ""; const messageId = reaction?.key?.id?.trim() ?? ""; - const remoteJid = (reaction?.key?.remoteJid ?? msg.key?.remoteJid ?? "").trim(); + const remoteJids: string[] = []; + addCandidateRemoteJid(remoteJids, reaction?.key?.remoteJid); + addCandidateRemoteJid(remoteJids, msg.key?.remoteJid); const actorJid = msg.key?.participant?.trim() || (msg.key?.fromMe ? (params.selfLid?.trim() ?? params.selfJid?.trim() ?? "") : (msg.key?.remoteJid?.trim() ?? "")); - if (!reactionKey || !messageId || !remoteJid || !actorJid) { + if (!reactionKey || !messageId || remoteJids.length === 0 || !actorJid) { return null; } return { - remoteJid, + remoteJids, messageId, actorJid, reactionKey, @@ -262,6 +311,7 @@ export async function maybeResolveWhatsAppApprovalReaction(params: { selfJid?: string | null; selfLid?: string | null; resolveInboundJid: (jid: string | null | undefined) => Promise; + resolveReactionTargetJids?: (jid: string) => Promise; logVerboseMessage?: (message: string) => void; }): Promise { const event = readWhatsAppApprovalReactionEvent({ @@ -272,11 +322,13 @@ export async function maybeResolveWhatsAppApprovalReaction(params: { if (!event) { return false; } - const target = await resolveWhatsAppApprovalReactionTargetWithPersistence({ + const target = await resolveWhatsAppApprovalReactionTargetFromCandidates({ accountId: params.accountId, - remoteJid: event.remoteJid, + observedRemoteJids: event.remoteJids, messageId: event.messageId, reactionKey: event.reactionKey, + resolveReactionTargetJids: params.resolveReactionTargetJids, + logVerboseMessage: params.logVerboseMessage, }); if (!target) { return false; @@ -329,7 +381,7 @@ export async function maybeResolveWhatsAppApprovalReaction(params: { if (isApprovalNotFoundError(error)) { unregisterWhatsAppApprovalReactionTarget({ accountId: params.accountId, - remoteJid: event.remoteJid, + remoteJid: target.remoteJid, messageId: event.messageId, }); params.logVerboseMessage?.( diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index f718580e1115..1e143df6b849 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -38,7 +38,7 @@ import { type WhatsAppSocketOperationAdapter, type WhatsAppSocketTimingOptions, } from "../socket-timing.js"; -import { resolveJidToE164 } from "../text-runtime.js"; +import { resolveEquivalentWhatsAppDirectChatJids, resolveJidToE164 } from "../text-runtime.js"; import { checkInboundAccessControl, type AcceptedInboundAccessControlResult, @@ -556,6 +556,8 @@ export async function attachWebInboxToSocket( const resolveInboundJid = async (jid: string | null | undefined): Promise => resolveJidToE164(jid, { authDir: options.authDir, lidLookup }); + const resolveReactionTargetJids = async (jid: string): Promise => + resolveEquivalentWhatsAppDirectChatJids(jid, { authDir: options.authDir, lidLookup }); const rememberBaileysMessage = ( remoteJid: string | null | undefined, @@ -1357,6 +1359,7 @@ export async function attachWebInboxToSocket( selfJid: self.jid, selfLid: self.lid, resolveInboundJid, + resolveReactionTargetJids, logVerboseMessage: (message) => logWhatsAppVerbose(options.verbose, message), }) ) { diff --git a/extensions/whatsapp/src/targets-runtime.ts b/extensions/whatsapp/src/targets-runtime.ts index ef4810c431ba..2e55548d801a 100644 --- a/extensions/whatsapp/src/targets-runtime.ts +++ b/extensions/whatsapp/src/targets-runtime.ts @@ -75,10 +75,89 @@ export type JidToE164Options = { logMissing?: boolean; }; -type LidLookup = { +export type LidLookup = { + getLIDForPN?: (jid: string) => Promise; getPNForLID?: (jid: string) => Promise; }; +function addUniqueString(target: string[], value: string | null | undefined): void { + const normalized = value?.trim(); + if (normalized && !target.includes(normalized)) { + target.push(normalized); + } +} + +async function tryLookupMappedJid( + lookup: (() => Promise | undefined) | undefined, +): Promise { + if (!lookup) { + return null; + } + try { + return (await lookup()) ?? null; + } catch (err) { + if (shouldLogVerbose()) { + logVerbose(`LID mapping lookup failed: ${String(err)}`); + } + return null; + } +} + +const DIRECT_PN_JID_RE = /^(\d+)(?::\d+)?@(s\.whatsapp\.net|hosted)$/i; +const DIRECT_LID_JID_RE = /^(\d+)(?::\d+)?@(lid|hosted\.lid)$/i; + +function addEquivalentDirectChatCandidate(target: string[], jid: string | null | undefined): void { + addUniqueString(target, jid); + const pnMatch = jid?.match(DIRECT_PN_JID_RE); + if (pnMatch) { + addUniqueString(target, `${pnMatch[1]}@${pnMatch[2]}`); + return; + } + const lidMatch = jid?.match(DIRECT_LID_JID_RE); + if (lidMatch) { + addUniqueString(target, `${lidMatch[1]}@${lidMatch[2]}`); + } +} + +export async function resolveEquivalentWhatsAppDirectChatJids( + jid: string | null | undefined, + opts?: JidToE164Options & { lidLookup?: LidLookup }, +): Promise { + const normalized = jid?.trim(); + if (!normalized) { + return []; + } + + const candidates: string[] = []; + addEquivalentDirectChatCandidate(candidates, normalized); + const pnMatch = normalized.match(DIRECT_PN_JID_RE); + if (pnMatch) { + const mappedLid = await tryLookupMappedJid(() => opts?.lidLookup?.getLIDForPN?.(normalized)); + addEquivalentDirectChatCandidate(candidates, mappedLid); + + const mappedLocalLid = readLidForwardMapping({ phoneDigits: pnMatch[1], opts }); + const localLidDomain = pnMatch[2].toLowerCase() === "hosted" ? "hosted.lid" : "lid"; + addUniqueString(candidates, mappedLocalLid ? `${mappedLocalLid}@${localLidDomain}` : null); + return candidates; + } + + const lidMatch = normalized.match(DIRECT_LID_JID_RE); + if (lidMatch) { + const mappedPn = await tryLookupMappedJid(() => opts?.lidLookup?.getPNForLID?.(normalized)); + addEquivalentDirectChatCandidate(candidates, mappedPn); + + const e164 = jidToE164(normalized, { ...opts, logMissing: false }); + const localPnJid = + e164 && lidMatch[2].toLowerCase() === "hosted.lid" + ? `${e164.replace(/\D/g, "")}@hosted` + : e164 + ? toWhatsappJid(e164) + : null; + addUniqueString(candidates, localPnJid); + } + return candidates; +} + function resolveLidMappingDirs(params: { opts?: JidToE164Options }): string[] { const dirs = new Set(); const addDir = (dir?: string | null) => { diff --git a/extensions/whatsapp/src/text-runtime.test.ts b/extensions/whatsapp/src/text-runtime.test.ts index b8c39906f885..306a8711e959 100644 --- a/extensions/whatsapp/src/text-runtime.test.ts +++ b/extensions/whatsapp/src/text-runtime.test.ts @@ -7,6 +7,7 @@ import { assertWebChannel, jidToE164, markdownToWhatsApp, + resolveEquivalentWhatsAppDirectChatJids, resolveJidToE164, toWhatsappJid, toWhatsappJidWithLid, @@ -210,3 +211,34 @@ describe("resolveJidToE164", () => { expect(lidLookup.getPNForLID).toHaveBeenCalledWith("777@lid"); }); }); + +describe("resolveEquivalentWhatsAppDirectChatJids", () => { + it.each([ + ["15551230000:0@s.whatsapp.net", "15551230000@s.whatsapp.net"], + ["15551230000:2@hosted", "15551230000@hosted"], + ["777:1@lid", "777@lid"], + ["777:2@hosted.lid", "777@hosted.lid"], + ])("includes the bare direct-chat form for %s", async (observedJid, bareJid) => { + await expect(resolveEquivalentWhatsAppDirectChatJids(observedJid)).resolves.toEqual([ + observedJid, + bareJid, + ]); + }); + + it("preserves hosted direct-chat domains for local PN/LID mappings", async () => { + await withTempDir("whatsapp-hosted-lid-map-", async (authDir) => { + fs.writeFileSync(path.join(authDir, "lid-mapping-15551230000.json"), JSON.stringify("777")); + fs.writeFileSync( + path.join(authDir, "lid-mapping-777_reverse.json"), + JSON.stringify("15551230000"), + ); + + await expect( + resolveEquivalentWhatsAppDirectChatJids("15551230000@hosted", { authDir }), + ).resolves.toEqual(["15551230000@hosted", "777@hosted.lid"]); + await expect( + resolveEquivalentWhatsAppDirectChatJids("777@hosted.lid", { authDir }), + ).resolves.toEqual(["777@hosted.lid", "15551230000@hosted"]); + }); + }); +}); diff --git a/extensions/whatsapp/src/text-runtime.ts b/extensions/whatsapp/src/text-runtime.ts index 3c7dac32d44c..f03a3d245f41 100644 --- a/extensions/whatsapp/src/text-runtime.ts +++ b/extensions/whatsapp/src/text-runtime.ts @@ -11,9 +11,11 @@ export { isSelfChatMode, jidToE164, markdownToWhatsApp, + resolveEquivalentWhatsAppDirectChatJids, resolveJidToE164, toWhatsappJid, toWhatsappJidWithLid, type JidToE164Options, + type LidLookup, type WebChannel, } from "./targets-runtime.js";