Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
72
openclaw/src/whatsapp/normalize.test.ts
Normal file
72
openclaw/src/whatsapp/normalize.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isWhatsAppGroupJid, isWhatsAppUserTarget, normalizeWhatsAppTarget } from "./normalize.js";
|
||||
|
||||
describe("normalizeWhatsAppTarget", () => {
|
||||
it("preserves group JIDs", () => {
|
||||
expect(normalizeWhatsAppTarget("120363401234567890@g.us")).toBe("120363401234567890@g.us");
|
||||
expect(normalizeWhatsAppTarget("123456789-987654321@g.us")).toBe("123456789-987654321@g.us");
|
||||
expect(normalizeWhatsAppTarget("whatsapp:120363401234567890@g.us")).toBe(
|
||||
"120363401234567890@g.us",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes direct JIDs to E.164", () => {
|
||||
expect(normalizeWhatsAppTarget("1555123@s.whatsapp.net")).toBe("+1555123");
|
||||
});
|
||||
|
||||
it("normalizes user JIDs with device suffix to E.164", () => {
|
||||
// This is the bug fix: JIDs like "41796666864:0@s.whatsapp.net" should
|
||||
// normalize to "+41796666864", not "+417966668640" (extra digit from ":0")
|
||||
expect(normalizeWhatsAppTarget("41796666864:0@s.whatsapp.net")).toBe("+41796666864");
|
||||
expect(normalizeWhatsAppTarget("1234567890:123@s.whatsapp.net")).toBe("+1234567890");
|
||||
// Without device suffix still works
|
||||
expect(normalizeWhatsAppTarget("41796666864@s.whatsapp.net")).toBe("+41796666864");
|
||||
});
|
||||
|
||||
it("normalizes LID JIDs to E.164", () => {
|
||||
expect(normalizeWhatsAppTarget("123456789@lid")).toBe("+123456789");
|
||||
expect(normalizeWhatsAppTarget("123456789@LID")).toBe("+123456789");
|
||||
});
|
||||
|
||||
it("rejects invalid targets", () => {
|
||||
expect(normalizeWhatsAppTarget("wat")).toBeNull();
|
||||
expect(normalizeWhatsAppTarget("whatsapp:")).toBeNull();
|
||||
expect(normalizeWhatsAppTarget("@g.us")).toBeNull();
|
||||
expect(normalizeWhatsAppTarget("whatsapp:group:@g.us")).toBeNull();
|
||||
expect(normalizeWhatsAppTarget("whatsapp:group:120363401234567890@g.us")).toBeNull();
|
||||
expect(normalizeWhatsAppTarget("group:123456789-987654321@g.us")).toBeNull();
|
||||
expect(normalizeWhatsAppTarget(" WhatsApp:Group:123456789-987654321@G.US ")).toBeNull();
|
||||
expect(normalizeWhatsAppTarget("abc@s.whatsapp.net")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles repeated prefixes", () => {
|
||||
expect(normalizeWhatsAppTarget("whatsapp:whatsapp:+1555")).toBe("+1555");
|
||||
expect(normalizeWhatsAppTarget("group:group:120@g.us")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWhatsAppUserTarget", () => {
|
||||
it("detects user JIDs with various formats", () => {
|
||||
expect(isWhatsAppUserTarget("41796666864:0@s.whatsapp.net")).toBe(true);
|
||||
expect(isWhatsAppUserTarget("1234567890@s.whatsapp.net")).toBe(true);
|
||||
expect(isWhatsAppUserTarget("123456789@lid")).toBe(true);
|
||||
expect(isWhatsAppUserTarget("123456789@LID")).toBe(true);
|
||||
expect(isWhatsAppUserTarget("123@lid:0")).toBe(false);
|
||||
expect(isWhatsAppUserTarget("abc@s.whatsapp.net")).toBe(false);
|
||||
expect(isWhatsAppUserTarget("123456789-987654321@g.us")).toBe(false);
|
||||
expect(isWhatsAppUserTarget("+1555123")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWhatsAppGroupJid", () => {
|
||||
it("detects group JIDs with or without prefixes", () => {
|
||||
expect(isWhatsAppGroupJid("120363401234567890@g.us")).toBe(true);
|
||||
expect(isWhatsAppGroupJid("123456789-987654321@g.us")).toBe(true);
|
||||
expect(isWhatsAppGroupJid("whatsapp:120363401234567890@g.us")).toBe(true);
|
||||
expect(isWhatsAppGroupJid("whatsapp:group:120363401234567890@g.us")).toBe(false);
|
||||
expect(isWhatsAppGroupJid("x@g.us")).toBe(false);
|
||||
expect(isWhatsAppGroupJid("@g.us")).toBe(false);
|
||||
expect(isWhatsAppGroupJid("120@g.usx")).toBe(false);
|
||||
expect(isWhatsAppGroupJid("+1555123")).toBe(false);
|
||||
});
|
||||
});
|
||||
80
openclaw/src/whatsapp/normalize.ts
Normal file
80
openclaw/src/whatsapp/normalize.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
|
||||
const WHATSAPP_USER_JID_RE = /^(\d+)(?::\d+)?@s\.whatsapp\.net$/i;
|
||||
const WHATSAPP_LID_RE = /^(\d+)@lid$/i;
|
||||
|
||||
function stripWhatsAppTargetPrefixes(value: string): string {
|
||||
let candidate = value.trim();
|
||||
for (;;) {
|
||||
const before = candidate;
|
||||
candidate = candidate.replace(/^whatsapp:/i, "").trim();
|
||||
if (candidate === before) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isWhatsAppGroupJid(value: string): boolean {
|
||||
const candidate = stripWhatsAppTargetPrefixes(value);
|
||||
const lower = candidate.toLowerCase();
|
||||
if (!lower.endsWith("@g.us")) {
|
||||
return false;
|
||||
}
|
||||
const localPart = candidate.slice(0, candidate.length - "@g.us".length);
|
||||
if (!localPart || localPart.includes("@")) {
|
||||
return false;
|
||||
}
|
||||
return /^[0-9]+(-[0-9]+)*$/.test(localPart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value looks like a WhatsApp user target (e.g. "41796666864:0@s.whatsapp.net" or "123@lid").
|
||||
*/
|
||||
export function isWhatsAppUserTarget(value: string): boolean {
|
||||
const candidate = stripWhatsAppTargetPrefixes(value);
|
||||
return WHATSAPP_USER_JID_RE.test(candidate) || WHATSAPP_LID_RE.test(candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the phone number from a WhatsApp user JID.
|
||||
* "41796666864:0@s.whatsapp.net" -> "41796666864"
|
||||
* "123456@lid" -> "123456"
|
||||
*/
|
||||
function extractUserJidPhone(jid: string): string | null {
|
||||
const userMatch = jid.match(WHATSAPP_USER_JID_RE);
|
||||
if (userMatch) {
|
||||
return userMatch[1];
|
||||
}
|
||||
const lidMatch = jid.match(WHATSAPP_LID_RE);
|
||||
if (lidMatch) {
|
||||
return lidMatch[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeWhatsAppTarget(value: string): string | null {
|
||||
const candidate = stripWhatsAppTargetPrefixes(value);
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
if (isWhatsAppGroupJid(candidate)) {
|
||||
const localPart = candidate.slice(0, candidate.length - "@g.us".length);
|
||||
return `${localPart}@g.us`;
|
||||
}
|
||||
// Handle user JIDs (e.g. "41796666864:0@s.whatsapp.net")
|
||||
if (isWhatsAppUserTarget(candidate)) {
|
||||
const phone = extractUserJidPhone(candidate);
|
||||
if (!phone) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeE164(phone);
|
||||
return normalized.length > 1 ? normalized : null;
|
||||
}
|
||||
// If the caller passed a JID-ish string that we don't understand, fail fast.
|
||||
// Otherwise normalizeE164 would happily treat "group:120@g.us" as a phone number.
|
||||
if (candidate.includes("@")) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeE164(candidate);
|
||||
return normalized.length > 1 ? normalized : null;
|
||||
}
|
||||
301
openclaw/src/whatsapp/resolve-outbound-target.test.ts
Normal file
301
openclaw/src/whatsapp/resolve-outbound-target.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import * as normalize from "./normalize.js";
|
||||
import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js";
|
||||
|
||||
vi.mock("./normalize.js");
|
||||
vi.mock("../infra/outbound/target-errors.js", () => ({
|
||||
missingTargetError: (platform: string, format: string) => new Error(`${platform}: ${format}`),
|
||||
}));
|
||||
|
||||
type ResolveParams = Parameters<typeof resolveWhatsAppOutboundTarget>[0];
|
||||
|
||||
function expectResolutionError(params: ResolveParams) {
|
||||
const result = resolveWhatsAppOutboundTarget(params);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected resolution to fail");
|
||||
}
|
||||
expect(result.error.message).toContain("WhatsApp");
|
||||
}
|
||||
|
||||
function expectResolutionOk(params: ResolveParams, expectedTarget: string) {
|
||||
const result = resolveWhatsAppOutboundTarget(params);
|
||||
expect(result).toEqual({ ok: true, to: expectedTarget });
|
||||
}
|
||||
|
||||
describe("resolveWhatsAppOutboundTarget", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("empty/missing to parameter", () => {
|
||||
it.each([
|
||||
["null", null],
|
||||
["undefined", undefined],
|
||||
["empty string", ""],
|
||||
["whitespace only", " "],
|
||||
])("returns error when to is %s", (_label, to) => {
|
||||
expectResolutionError({ to, allowFrom: undefined, mode: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalization failures", () => {
|
||||
it("returns error when normalizeWhatsAppTarget returns null/undefined", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce(null);
|
||||
expectResolutionError({
|
||||
to: "+1234567890",
|
||||
allowFrom: undefined,
|
||||
mode: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("group JID handling", () => {
|
||||
it("returns success for valid group JID regardless of mode", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce("120363123456789@g.us");
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(true);
|
||||
|
||||
expectResolutionOk(
|
||||
{
|
||||
to: "120363123456789@g.us",
|
||||
allowFrom: undefined,
|
||||
mode: "implicit",
|
||||
},
|
||||
"120363123456789@g.us",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns success for group JID in heartbeat mode", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce("120363999888777@g.us");
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(true);
|
||||
|
||||
expectResolutionOk(
|
||||
{
|
||||
to: "120363999888777@g.us",
|
||||
allowFrom: undefined,
|
||||
mode: "heartbeat",
|
||||
},
|
||||
"120363999888777@g.us",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("implicit/heartbeat mode with allowList", () => {
|
||||
it("allows message when wildcard is present", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget)
|
||||
.mockReturnValueOnce("+11234567890")
|
||||
.mockReturnValueOnce("+11234567890");
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
|
||||
|
||||
expectResolutionOk(
|
||||
{
|
||||
to: "+11234567890",
|
||||
allowFrom: ["*"],
|
||||
mode: "implicit",
|
||||
},
|
||||
"+11234567890",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows message when allowList is empty", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget)
|
||||
.mockReturnValueOnce("+11234567890")
|
||||
.mockReturnValueOnce("+11234567890");
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
|
||||
|
||||
expectResolutionOk(
|
||||
{
|
||||
to: "+11234567890",
|
||||
allowFrom: [],
|
||||
mode: "implicit",
|
||||
},
|
||||
"+11234567890",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows message when target is in allowList", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget)
|
||||
.mockReturnValueOnce("+11234567890")
|
||||
.mockReturnValueOnce("+11234567890");
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
|
||||
|
||||
expectResolutionOk(
|
||||
{
|
||||
to: "+11234567890",
|
||||
allowFrom: ["+11234567890"],
|
||||
mode: "implicit",
|
||||
},
|
||||
"+11234567890",
|
||||
);
|
||||
});
|
||||
|
||||
it("denies message when target is not in allowList", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget)
|
||||
.mockReturnValueOnce("+11234567890")
|
||||
.mockReturnValueOnce("+19876543210");
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
|
||||
|
||||
expectResolutionError({
|
||||
to: "+11234567890",
|
||||
allowFrom: ["+19876543210"],
|
||||
mode: "implicit",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles mixed numeric and string allowList entries", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget)
|
||||
.mockReturnValueOnce("+11234567890") // for 'to' param
|
||||
.mockReturnValueOnce("+11234567890") // for allowFrom[0]
|
||||
.mockReturnValueOnce("+11234567890"); // for allowFrom[1]
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
|
||||
|
||||
expectResolutionOk(
|
||||
{
|
||||
to: "+11234567890",
|
||||
allowFrom: [1234567890, "+11234567890"],
|
||||
mode: "implicit",
|
||||
},
|
||||
"+11234567890",
|
||||
);
|
||||
});
|
||||
|
||||
it("filters out invalid normalized entries from allowList", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget)
|
||||
.mockReturnValueOnce(null) // for allowFrom[0] "invalid" (processed first)
|
||||
.mockReturnValueOnce("+11234567890") // for allowFrom[1] "+11234567890"
|
||||
.mockReturnValueOnce("+11234567890"); // for 'to' param (processed last)
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
|
||||
|
||||
expectResolutionOk(
|
||||
{
|
||||
to: "+11234567890",
|
||||
allowFrom: ["invalid", "+11234567890"],
|
||||
mode: "implicit",
|
||||
},
|
||||
"+11234567890",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("heartbeat mode", () => {
|
||||
it("allows message when target is in allowList in heartbeat mode", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget)
|
||||
.mockReturnValueOnce("+11234567890")
|
||||
.mockReturnValueOnce("+11234567890");
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
|
||||
|
||||
expectResolutionOk(
|
||||
{
|
||||
to: "+11234567890",
|
||||
allowFrom: ["+11234567890"],
|
||||
mode: "heartbeat",
|
||||
},
|
||||
"+11234567890",
|
||||
);
|
||||
});
|
||||
|
||||
it("denies message when target is not in allowList in heartbeat mode", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget)
|
||||
.mockReturnValueOnce("+11234567890")
|
||||
.mockReturnValueOnce("+19876543210");
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
|
||||
|
||||
expectResolutionError({
|
||||
to: "+11234567890",
|
||||
allowFrom: ["+19876543210"],
|
||||
mode: "heartbeat",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("explicit/custom modes", () => {
|
||||
it("allows message in null mode when allowList is not set", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce("+11234567890");
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
|
||||
|
||||
expectResolutionOk(
|
||||
{
|
||||
to: "+11234567890",
|
||||
allowFrom: undefined,
|
||||
mode: null,
|
||||
},
|
||||
"+11234567890",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows message in undefined mode when allowList is not set", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce("+11234567890");
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
|
||||
|
||||
expectResolutionOk(
|
||||
{
|
||||
to: "+11234567890",
|
||||
allowFrom: undefined,
|
||||
mode: undefined,
|
||||
},
|
||||
"+11234567890",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces allowList in custom mode string", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget)
|
||||
.mockReturnValueOnce("+19876543210") // for allowFrom[0] (happens first!)
|
||||
.mockReturnValueOnce("+11234567890"); // for 'to' param (happens second)
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
|
||||
|
||||
expectResolutionError({
|
||||
to: "+11234567890",
|
||||
allowFrom: ["+19876543210"],
|
||||
mode: "broadcast",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows message in custom mode string when target is in allowList", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget)
|
||||
.mockReturnValueOnce("+11234567890") // for allowFrom[0]
|
||||
.mockReturnValueOnce("+11234567890"); // for 'to' param
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
|
||||
|
||||
expectResolutionOk(
|
||||
{
|
||||
to: "+11234567890",
|
||||
allowFrom: ["+11234567890"],
|
||||
mode: "broadcast",
|
||||
},
|
||||
"+11234567890",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("whitespace handling", () => {
|
||||
it("trims whitespace from to parameter", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce("+11234567890");
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
|
||||
|
||||
expectResolutionOk(
|
||||
{
|
||||
to: " +11234567890 ",
|
||||
allowFrom: undefined,
|
||||
mode: undefined,
|
||||
},
|
||||
"+11234567890",
|
||||
);
|
||||
expect(vi.mocked(normalize.normalizeWhatsAppTarget)).toHaveBeenCalledWith("+11234567890");
|
||||
});
|
||||
|
||||
it("trims whitespace from allowList entries", () => {
|
||||
vi.mocked(normalize.normalizeWhatsAppTarget)
|
||||
.mockReturnValueOnce("+11234567890")
|
||||
.mockReturnValueOnce("+11234567890");
|
||||
vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false);
|
||||
|
||||
resolveWhatsAppOutboundTarget({
|
||||
to: "+11234567890",
|
||||
allowFrom: [" +11234567890 "],
|
||||
mode: undefined,
|
||||
});
|
||||
|
||||
expect(vi.mocked(normalize.normalizeWhatsAppTarget)).toHaveBeenCalledWith("+11234567890");
|
||||
});
|
||||
});
|
||||
});
|
||||
52
openclaw/src/whatsapp/resolve-outbound-target.ts
Normal file
52
openclaw/src/whatsapp/resolve-outbound-target.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { missingTargetError } from "../infra/outbound/target-errors.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js";
|
||||
|
||||
export type WhatsAppOutboundTargetResolution =
|
||||
| { ok: true; to: string }
|
||||
| { ok: false; error: Error };
|
||||
|
||||
export function resolveWhatsAppOutboundTarget(params: {
|
||||
to: string | null | undefined;
|
||||
allowFrom: Array<string | number> | null | undefined;
|
||||
mode: string | null | undefined;
|
||||
}): WhatsAppOutboundTargetResolution {
|
||||
const trimmed = params.to?.trim() ?? "";
|
||||
const allowListRaw = (params.allowFrom ?? [])
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
const hasWildcard = allowListRaw.includes("*");
|
||||
const allowList = allowListRaw
|
||||
.filter((entry) => entry !== "*")
|
||||
.map((entry) => normalizeWhatsAppTarget(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
|
||||
if (trimmed) {
|
||||
const normalizedTo = normalizeWhatsAppTarget(trimmed);
|
||||
if (!normalizedTo) {
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError("WhatsApp", "<E.164|group JID>"),
|
||||
};
|
||||
}
|
||||
if (isWhatsAppGroupJid(normalizedTo)) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
// Enforce allowFrom for all direct-message send modes (including explicit).
|
||||
// Group destinations are handled by group policy and are allowed above.
|
||||
if (hasWildcard || allowList.length === 0) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
if (allowList.includes(normalizedTo)) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError("WhatsApp", "<E.164|group JID>"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError("WhatsApp", "<E.164|group JID>"),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user