fix(whatsapp): resolve approval reactions across jid drift (#95935)

This commit is contained in:
Marcus Castro
2026-06-23 00:44:50 -03:00
committed by GitHub
parent ad3b2f4b88
commit 02387e747d
6 changed files with 369 additions and 147 deletions

View File

@@ -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",
});

View File

@@ -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<typeof import("./approval-resolver.js")> | 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<readonly string[]>;
logVerboseMessage?: (message: string) => void;
}): Promise<ResolvedWhatsAppApprovalReactionTarget | null> {
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<string | null>;
resolveReactionTargetJids?: (jid: string) => Promise<readonly string[]>;
logVerboseMessage?: (message: string) => void;
}): Promise<boolean> {
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?.(

View File

@@ -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<string | null> =>
resolveJidToE164(jid, { authDir: options.authDir, lidLookup });
const resolveReactionTargetJids = async (jid: string): Promise<string[]> =>
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),
})
) {

View File

@@ -75,10 +75,89 @@ export type JidToE164Options = {
logMissing?: boolean;
};
type LidLookup = {
export type LidLookup = {
getLIDForPN?: (jid: string) => Promise<string | null>;
getPNForLID?: (jid: string) => Promise<string | null>;
};
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<string | null> | undefined) | undefined,
): Promise<string | null> {
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<string[]> {
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<string>();
const addDir = (dir?: string | null) => {

View File

@@ -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"]);
});
});
});

View File

@@ -11,9 +11,11 @@ export {
isSelfChatMode,
jidToE164,
markdownToWhatsApp,
resolveEquivalentWhatsAppDirectChatJids,
resolveJidToE164,
toWhatsappJid,
toWhatsappJidWithLid,
type JidToE164Options,
type LidLookup,
type WebChannel,
} from "./targets-runtime.js";