Merge 5247c293314e3d898cb28fbfe9e9c97c44f242ff into d78e13f545136fcbba1feceecc5e0485a06c33a6

This commit is contained in:
thiagorragazzo 2026-03-21 05:00:08 +00:00 committed by GitHub
commit 1d6c7d641c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 290 additions and 21 deletions

View File

@ -350,6 +350,41 @@ describe("web processMessage inbound context", () => {
expect(updateLastRouteMock).toHaveBeenCalledTimes(1);
});
it("authorizes control commands when BR ninth-digit variants differ", async () => {
capturedCtx = undefined;
await processMessage(
makeProcessMessageArgs({
routeSessionKey: "agent:main:whatsapp:direct:+5535998627740",
groupHistoryKey: "+5535998627740",
cfg: {
channels: {
whatsapp: {
dmPolicy: "allowlist",
allowFrom: ["+553598627740"],
},
},
commands: { useAccessGroups: true },
messages: {},
session: { store: sessionStorePath },
} as unknown as ReturnType<typeof import("../../../../../src/config/config.js").loadConfig>,
msg: {
id: "msg-command-br-variant",
from: "+5535998627740",
to: "+2000",
chatType: "direct",
body: "/status",
senderE164: "+5535998627740",
selfE164: "+5519999835286",
accountId: "default",
},
}),
);
expect(capturedCtx).toBeTruthy();
expect((capturedCtx as { CommandAuthorized?: boolean }).CommandAuthorized).toBe(true);
});
it("does not update main last route for isolated DM scope sessions", async () => {
const updateLastRouteMock = vi.mocked(updateLastRouteInBackground);
updateLastRouteMock.mockClear();
@ -440,4 +475,45 @@ describe("web processMessage inbound context", () => {
expect(updateLastRouteMock).toHaveBeenCalledTimes(1);
});
it("updates main last route when pinned owner and sender differ only by BR ninth digit", async () => {
const updateLastRouteMock = vi.mocked(updateLastRouteInBackground);
updateLastRouteMock.mockClear();
const args = makeProcessMessageArgs({
routeSessionKey: "agent:main:main",
groupHistoryKey: "+5535998627740",
cfg: {
channels: {
whatsapp: {
allowFrom: ["+553598627740"],
},
},
messages: {},
session: { store: sessionStorePath, dmScope: "main" },
} as unknown as ReturnType<typeof import("../../../../../src/config/config.js").loadConfig>,
msg: {
id: "msg-last-route-br-variant",
from: "+5535998627740",
to: "+2000",
chatType: "direct",
body: "hello",
senderE164: "+5535998627740",
},
});
args.route = {
...args.route,
sessionKey: "agent:main:main",
mainSessionKey: "agent:main:main",
};
await processMessage(args);
expect(updateLastRouteMock).toHaveBeenCalledTimes(1);
expect(updateLastRouteMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "+553598627740",
}),
);
});
});

View File

@ -32,6 +32,7 @@ import {
resolveDmGroupAccessWithCommandGate,
} from "openclaw/plugin-sdk/security-runtime";
import { jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { areEquivalentWhatsAppDirectTargets } from "openclaw/plugin-sdk/whatsapp-shared";
import { resolveWhatsAppAccount } from "../../accounts.js";
import { newConnectionId } from "../../reconnect.js";
import { formatError } from "../../session.js";
@ -103,7 +104,9 @@ async function resolveWhatsAppCommandAuthorized(params: {
const normalizedEntries = allowEntries
.map((entry) => normalizeE164(String(entry)))
.filter((entry): entry is string => Boolean(entry));
return normalizedEntries.includes(senderE164);
return normalizedEntries.some((entry) =>
areEquivalentWhatsAppDirectTargets(entry, senderE164),
);
},
command: {
useAccessGroups,
@ -343,14 +346,18 @@ export async function processMessage(params: {
cfg: params.cfg,
msg: params.msg,
});
const shouldUpdateMainLastRoute =
!pinnedMainDmRecipient || pinnedMainDmRecipient === dmRouteTarget;
const canonicalMainLastRouteTarget = pinnedMainDmRecipient
? dmRouteTarget && areEquivalentWhatsAppDirectTargets(pinnedMainDmRecipient, dmRouteTarget)
? pinnedMainDmRecipient
: null
: dmRouteTarget;
const shouldUpdateMainLastRoute = Boolean(canonicalMainLastRouteTarget);
const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
route: params.route,
sessionKey: params.route.sessionKey,
});
if (
dmRouteTarget &&
canonicalMainLastRouteTarget &&
inboundLastRouteSessionKey === params.route.mainSessionKey &&
shouldUpdateMainLastRoute
) {
@ -360,7 +367,7 @@ export async function processMessage(params: {
storeAgentId: params.route.agentId,
sessionKey: params.route.mainSessionKey,
channel: "whatsapp",
to: dmRouteTarget,
to: canonicalMainLastRouteTarget,
accountId: params.route.accountId,
ctx: ctxPayload,
warn: params.replyLogger.warn.bind(params.replyLogger),

View File

@ -157,4 +157,58 @@ describe("WhatsApp dmPolicy precedence", () => {
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
expect(sendMessageMock).not.toHaveBeenCalled();
});
it("treats BR ninth-digit variants as the same self-chat phone", async () => {
setAccessControlTestConfig({
channels: {
whatsapp: {
dmPolicy: "pairing",
allowFrom: ["+5511988887777"],
},
},
});
const result = await checkInboundAccessControl({
accountId: "default",
from: "+5535998627740",
selfE164: "+553598627740",
senderE164: "+5535998627740",
group: false,
pushName: "Owner",
isFromMe: false,
sock: { sendMessage: sendMessageMock },
remoteJid: "5535998627740@s.whatsapp.net",
});
expect(result.allowed).toBe(true);
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 = {
@ -72,7 +73,9 @@ export async function checkInboundAccessControl(params: {
const dmAllowFrom = configuredAllowFrom.length > 0 ? configuredAllowFrom : defaultAllowFrom;
const groupAllowFrom =
account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined);
const isSamePhone = params.from === params.selfE164;
const isSamePhone = params.selfE164
? areEquivalentWhatsAppDirectTargets(params.from, params.selfE164)
: false;
const isSelfChat = account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom);
const pairingGraceMs =
typeof params.pairingGraceMs === "number" && params.pairingGraceMs > 0
@ -115,17 +118,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

@ -66,6 +66,17 @@ describe("resolveWhatsAppHeartbeatRecipients", () => {
expect(result).toEqual({ recipients: ["+15550000001"], source: "session-single" });
});
it("treats BR ninth-digit variants as authorized session recipients", () => {
setSessionStore({
a: { lastChannel: "whatsapp", lastTo: "+5535998627740", updatedAt: 2, sessionId: "a" },
});
setAllowFromStore(["+553598627740"]);
const result = resolveWith();
expect(result).toEqual({ recipients: ["+5535998627740"], source: "session-single" });
});
it("falls back to allowFrom when no session recipient is authorized", () => {
setSingleUnauthorizedSessionWithAllowFrom();

View File

@ -3,6 +3,7 @@ import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { normalizeE164 } from "../../utils.js";
import { areEquivalentWhatsAppDirectTargets } from "../../whatsapp/normalize.js";
import { normalizeChatChannelId } from "../registry.js";
type HeartbeatRecipientsResult = { recipients: string[]; source: string };
@ -72,10 +73,13 @@ export function resolveWhatsAppHeartbeatRecipients(
}
if (allowFrom.length > 0) {
const allowSet = new Set(allowFrom);
const authorizedSessionRecipients = sessionRecipients
.map((entry) => entry.to)
.filter((recipient) => allowSet.has(recipient));
.filter((recipient) =>
allowFrom.some((allowedRecipient) =>
areEquivalentWhatsAppDirectTargets(allowedRecipient, recipient),
),
);
if (authorizedSessionRecipients.length === 1) {
return { recipients: [authorizedSessionRecipients[0]], source: "session-single" };
}

View File

@ -11,4 +11,9 @@ export {
normalizeWhatsAppMessagingTarget,
} from "../channels/plugins/normalize/whatsapp.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,