whatsapp: normalize BR ninth-digit variants

This commit is contained in:
Thiago Roque Ragazzo 2026-03-20 03:06:20 -03:00
parent 9af42c6590
commit eabcac5ca2
8 changed files with 155 additions and 13 deletions

View File

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

View File

@ -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") {

View File

@ -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";

View File

@ -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,

View File

@ -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);

View File

@ -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<string>([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));
}

View File

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

View File

@ -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,