Merge 5247c293314e3d898cb28fbfe9e9c97c44f242ff into d78e13f545136fcbba1feceecc5e0485a06c33a6
This commit is contained in:
commit
1d6c7d641c
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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" };
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user