diff --git a/extensions/whatsapp/src/inbound/access-control.test.ts b/extensions/whatsapp/src/inbound/access-control.test.ts index 2d3e26650c7..0b05cf99267 100644 --- a/extensions/whatsapp/src/inbound/access-control.test.ts +++ b/extensions/whatsapp/src/inbound/access-control.test.ts @@ -157,4 +157,31 @@ describe("WhatsApp dmPolicy precedence", () => { expect(upsertPairingRequestMock).not.toHaveBeenCalled(); expect(sendMessageMock).not.toHaveBeenCalled(); }); + + it("allows BR contacts when allowFrom and sender differ only by the ninth digit", async () => { + setAccessControlTestConfig({ + channels: { + whatsapp: { + dmPolicy: "allowlist", + allowFrom: ["+553598627740"], + }, + }, + }); + + const result = await checkInboundAccessControl({ + accountId: "default", + from: "+5535998627740", + selfE164: "+5519999835286", + senderE164: "+5535998627740", + group: false, + pushName: "Jordan", + isFromMe: false, + sock: { sendMessage: sendMessageMock }, + remoteJid: "553598627740@s.whatsapp.net", + }); + + expect(result.allowed).toBe(true); + expect(upsertPairingRequestMock).not.toHaveBeenCalled(); + expect(sendMessageMock).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/whatsapp/src/inbound/access-control.ts b/extensions/whatsapp/src/inbound/access-control.ts index 95fe6dd487a..09d95b4a75e 100644 --- a/extensions/whatsapp/src/inbound/access-control.ts +++ b/extensions/whatsapp/src/inbound/access-control.ts @@ -12,6 +12,7 @@ import { resolveDmGroupAccessWithLists, } from "openclaw/plugin-sdk/security-runtime"; import { isSelfChatMode, normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; +import { areEquivalentWhatsAppDirectTargets } from "openclaw/plugin-sdk/whatsapp-shared"; import { resolveWhatsAppAccount } from "../accounts.js"; export type InboundAccessControlResult = { @@ -115,17 +116,22 @@ export async function checkInboundAccessControl(params: { if (hasWildcard) { return true; } - const normalizedEntrySet = new Set( - allowEntries - .map((entry) => normalizeE164(String(entry))) - .filter((entry): entry is string => Boolean(entry)), - ); + const normalizedEntries = allowEntries + .map((entry) => normalizeE164(String(entry))) + .filter((entry): entry is string => Boolean(entry)); if (!params.group && isSamePhone) { return true; } return params.group - ? Boolean(normalizedGroupSender && normalizedEntrySet.has(normalizedGroupSender)) - : normalizedEntrySet.has(normalizedDmSender); + ? Boolean( + normalizedGroupSender && + normalizedEntries.some((entry) => + areEquivalentWhatsAppDirectTargets(entry, normalizedGroupSender), + ), + ) + : normalizedEntries.some((entry) => + areEquivalentWhatsAppDirectTargets(entry, normalizedDmSender), + ); }, }); if (params.group && access.decision !== "allow") { diff --git a/src/plugin-sdk/whatsapp-shared.ts b/src/plugin-sdk/whatsapp-shared.ts index d1794898bc3..463af4d7360 100644 --- a/src/plugin-sdk/whatsapp-shared.ts +++ b/src/plugin-sdk/whatsapp-shared.ts @@ -6,4 +6,9 @@ export { resolveWhatsAppMentionStripRegexes, } from "../channels/plugins/whatsapp-shared.js"; export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp-heartbeat.js"; -export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; +export { + areEquivalentWhatsAppDirectTargets, + expandWhatsAppDirectTargetVariants, + isWhatsAppGroupJid, + normalizeWhatsAppTarget, +} from "../whatsapp/normalize.js"; diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index 0c4e0a5048b..1163faceb66 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -40,7 +40,12 @@ export { } from "../channels/plugins/group-policy-warnings.js"; export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; export { resolveWhatsAppOutboundTarget } from "../whatsapp/resolve-outbound-target.js"; -export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; +export { + areEquivalentWhatsAppDirectTargets, + expandWhatsAppDirectTargetVariants, + isWhatsAppGroupJid, + normalizeWhatsAppTarget, +} from "../whatsapp/normalize.js"; export { resolveAllowlistProviderRuntimeGroupPolicy, diff --git a/src/whatsapp/normalize.test.ts b/src/whatsapp/normalize.test.ts index 330a1022588..7865bb70fd4 100644 --- a/src/whatsapp/normalize.test.ts +++ b/src/whatsapp/normalize.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { isWhatsAppGroupJid, isWhatsAppUserTarget, normalizeWhatsAppTarget } from "./normalize.js"; +import { + areEquivalentWhatsAppDirectTargets, + expandWhatsAppDirectTargetVariants, + isWhatsAppGroupJid, + isWhatsAppUserTarget, + normalizeWhatsAppTarget, +} from "./normalize.js"; describe("normalizeWhatsAppTarget", () => { it("preserves group JIDs", () => { @@ -58,6 +64,36 @@ describe("isWhatsAppUserTarget", () => { }); }); +describe("expandWhatsAppDirectTargetVariants", () => { + it("treats BR mobile numbers with and without the ninth digit as equivalent", () => { + expect(expandWhatsAppDirectTargetVariants("+5535998627740")).toEqual([ + "+5535998627740", + "+553598627740", + ]); + expect(expandWhatsAppDirectTargetVariants("+553598627740")).toEqual([ + "+553598627740", + "+5535998627740", + ]); + }); + + it("does not invent ninth-digit variants for non-BR numbers or groups", () => { + expect(expandWhatsAppDirectTargetVariants("+15551234567")).toEqual(["+15551234567"]); + expect(expandWhatsAppDirectTargetVariants("120363401234567890@g.us")).toEqual([ + "120363401234567890@g.us", + ]); + }); +}); + +describe("areEquivalentWhatsAppDirectTargets", () => { + it("matches BR ninth-digit variants", () => { + expect(areEquivalentWhatsAppDirectTargets("+5535998627740", "+553598627740")).toBe(true); + }); + + it("does not match unrelated numbers", () => { + expect(areEquivalentWhatsAppDirectTargets("+5535998627740", "+5535998627741")).toBe(false); + }); +}); + describe("isWhatsAppGroupJid", () => { it("detects group JIDs with or without prefixes", () => { expect(isWhatsAppGroupJid("120363401234567890@g.us")).toBe(true); diff --git a/src/whatsapp/normalize.ts b/src/whatsapp/normalize.ts index 1d661ddc75f..3661e41e834 100644 --- a/src/whatsapp/normalize.ts +++ b/src/whatsapp/normalize.ts @@ -78,3 +78,39 @@ export function normalizeWhatsAppTarget(value: string): string | null { const normalized = normalizeE164(candidate); return normalized.length > 1 ? normalized : null; } + +/** + * Expand direct-target variants that WhatsApp may surface differently from the human-facing phone. + * + * Brazil is the main footgun here: some chats/contacts resolve with the mobile ninth digit removed, + * while humans/config entries often keep the full E.164 with the extra 9. Treat both forms as + * equivalent for WhatsApp matching. + */ +export function expandWhatsAppDirectTargetVariants(value: string): string[] { + const normalized = normalizeWhatsAppTarget(value); + if (!normalized || isWhatsAppGroupJid(normalized)) { + return normalized ? [normalized] : []; + } + const variants = new Set([normalized]); + const digits = normalized.replace(/\D/g, ""); + + // BR mobile with ninth digit: +55 AA 9 XXXXXXXX -> +55 AA XXXXXXXX + const withNinthDigit = digits.match(/^55(\d{2})9([6-9]\d{7})$/); + if (withNinthDigit) { + variants.add(`+55${withNinthDigit[1]}${withNinthDigit[2]}`); + } + + // BR mobile without ninth digit: +55 AA XXXXXXXX -> +55 AA 9 XXXXXXXX + const withoutNinthDigit = digits.match(/^55(\d{2})([6-9]\d{7})$/); + if (withoutNinthDigit) { + variants.add(`+55${withoutNinthDigit[1]}9${withoutNinthDigit[2]}`); + } + + return [...variants]; +} + +export function areEquivalentWhatsAppDirectTargets(a: string, b: string): boolean { + const left = expandWhatsAppDirectTargetVariants(a); + const right = new Set(expandWhatsAppDirectTargetVariants(b)); + return left.some((candidate) => right.has(candidate)); +} diff --git a/src/whatsapp/resolve-outbound-target.test.ts b/src/whatsapp/resolve-outbound-target.test.ts index 4d7d16b4393..9a1009ed6db 100644 --- a/src/whatsapp/resolve-outbound-target.test.ts +++ b/src/whatsapp/resolve-outbound-target.test.ts @@ -137,6 +137,23 @@ describe("resolveWhatsAppOutboundTarget", () => { expectAllowedForTarget({ allowFrom: [PRIMARY_TARGET], mode: "implicit" }); }); + it("reuses the allowlist's canonical BR variant when ninth-digit forms differ", () => { + vi.mocked(normalize.normalizeWhatsAppTarget) + .mockReturnValueOnce("+553598627740") + .mockReturnValueOnce("+5535998627740"); + vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); + vi.mocked(normalize.areEquivalentWhatsAppDirectTargets).mockReturnValueOnce(true); + + expectResolutionOk( + { + to: "+5535998627740", + allowFrom: ["+553598627740"], + mode: "implicit", + }, + "+553598627740", + ); + }); + it("denies message when target is not in allowList", () => { mockNormalizedDirectMessage(PRIMARY_TARGET, SECONDARY_TARGET); expectDeniedForTarget({ allowFrom: [SECONDARY_TARGET], mode: "implicit" }); diff --git a/src/whatsapp/resolve-outbound-target.ts b/src/whatsapp/resolve-outbound-target.ts index 7ad7af1cd4c..cda64bc9729 100644 --- a/src/whatsapp/resolve-outbound-target.ts +++ b/src/whatsapp/resolve-outbound-target.ts @@ -1,5 +1,9 @@ import { missingTargetError } from "../infra/outbound/target-errors.js"; -import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js"; +import { + areEquivalentWhatsAppDirectTargets, + isWhatsAppGroupJid, + normalizeWhatsAppTarget, +} from "./normalize.js"; export type WhatsAppOutboundTargetResolution = | { ok: true; to: string } @@ -36,8 +40,14 @@ export function resolveWhatsAppOutboundTarget(params: { if (hasWildcard || allowList.length === 0) { return { ok: true, to: normalizedTo }; } - if (allowList.includes(normalizedTo)) { - return { ok: true, to: normalizedTo }; + const matchedAllowTarget = allowList.find( + (entry) => entry === normalizedTo || areEquivalentWhatsAppDirectTargets(entry, normalizedTo), + ); + if (matchedAllowTarget) { + // Prefer the allowlist's canonical entry so outbound sends reuse the exact JID/E.164 form + // that the operator previously authorized (important for BR contacts that surface without + // the mobile ninth digit on WhatsApp). + return { ok: true, to: matchedAllowTarget }; } return { ok: false,