From 9cd2662a86a559e93202644e9ddcf1f9137358d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 02:39:50 +0100 Subject: [PATCH] refactor: centralize WhatsApp target normalization --- src/cron/isolated-agent.ts | 16 ++--- src/gateway/server-methods/agent.ts | 12 ++-- src/infra/heartbeat-runner.test.ts | 15 +++++ src/infra/outbound/targets.test.ts | 91 ++++++++++++++++++----------- src/infra/outbound/targets.ts | 18 ++++-- src/utils.test.ts | 51 ---------------- src/utils.ts | 28 --------- src/whatsapp/normalize.test.ts | 57 ++++++++++++++++++ src/whatsapp/normalize.ts | 33 +++++++++++ 9 files changed, 191 insertions(+), 130 deletions(-) create mode 100644 src/whatsapp/normalize.test.ts create mode 100644 src/whatsapp/normalize.ts diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index b84c53fae..ebb83aeb1 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -48,12 +48,11 @@ import { import { registerAgentRunContext } from "../infra/agent-events.js"; import { parseTelegramTarget } from "../telegram/targets.js"; import { resolveTelegramToken } from "../telegram/token.js"; +import { normalizeE164, truncateUtf16Safe } from "../utils.js"; import { isWhatsAppGroupJid, - normalizeE164, normalizeWhatsAppTarget, - truncateUtf16Safe, -} from "../utils.js"; +} from "../whatsapp/normalize.js"; import type { CronJob } from "./types.js"; export type RunCronAgentTurnResult = { @@ -209,21 +208,21 @@ function resolveDeliveryTarget( const sanitizedWhatsappTo = (() => { if (provider !== "whatsapp") return rawTo; if (rawTo && isWhatsAppGroupJid(rawTo)) { - return normalizeWhatsAppTarget(rawTo) || rawTo; + return normalizeWhatsAppTarget(rawTo) ?? rawTo; } const rawAllow = cfg.whatsapp?.allowFrom ?? []; if (rawAllow.includes("*")) { - return rawTo ? normalizeWhatsAppTarget(rawTo) : rawTo; + return rawTo ? (normalizeWhatsAppTarget(rawTo) ?? rawTo) : rawTo; } const allowFrom = rawAllow .map((val) => normalizeE164(val)) .filter((val) => val.length > 1); if (allowFrom.length === 0) { - return rawTo ? normalizeWhatsAppTarget(rawTo) : rawTo; + return rawTo ? (normalizeWhatsAppTarget(rawTo) ?? rawTo) : rawTo; } if (!rawTo) return allowFrom[0]; const normalized = normalizeWhatsAppTarget(rawTo); - if (allowFrom.includes(normalized)) return normalized; + if (normalized && allowFrom.includes(normalized)) return normalized; return allowFrom[0]; })(); @@ -555,7 +554,8 @@ export async function runCronIsolatedAgentTurn(params: { summary: "Delivery skipped (no WhatsApp recipient).", }; } - const to = normalizeWhatsAppTarget(resolvedDelivery.to); + const rawTo = resolvedDelivery.to; + const to = normalizeWhatsAppTarget(rawTo) ?? rawTo; try { await deliverPayloadsWithMedia({ payloads, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 746420bba..9ee67c9fe 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -18,11 +18,11 @@ import { isGatewayMessageProvider, normalizeMessageProvider, } from "../../utils/message-provider.js"; +import { normalizeE164 } from "../../utils.js"; import { isWhatsAppGroupJid, - normalizeE164, normalizeWhatsAppTarget, -} from "../../utils.js"; +} from "../../whatsapp/normalize.js"; import { type AgentWaitParams, ErrorCodes, @@ -227,16 +227,18 @@ export const agentHandlers: GatewayRequestHandlers = { : undefined; if (explicit) { if (!resolvedTo) return resolvedTo; - return normalizeWhatsAppTarget(resolvedTo) || resolvedTo; + return normalizeWhatsAppTarget(resolvedTo) ?? resolvedTo; } if (resolvedTo && isWhatsAppGroupJid(resolvedTo)) { - return normalizeWhatsAppTarget(resolvedTo) || resolvedTo; + return normalizeWhatsAppTarget(resolvedTo) ?? resolvedTo; } const cfg = cfgForAgent ?? loadConfig(); const rawAllow = cfg.whatsapp?.allowFrom ?? []; if (rawAllow.includes("*")) { - return resolvedTo ? normalizeWhatsAppTarget(resolvedTo) : resolvedTo; + return resolvedTo + ? (normalizeWhatsAppTarget(resolvedTo) ?? resolvedTo) + : resolvedTo; } const allowFrom = rawAllow .map((val) => normalizeE164(val)) diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index 5768ae186..cbad5f025 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -96,6 +96,21 @@ describe("resolveHeartbeatDeliveryTarget", () => { }); }); + it("normalizes explicit WhatsApp targets when allowFrom is '*'", () => { + const cfg: ClawdbotConfig = { + agents: { + defaults: { + heartbeat: { target: "whatsapp", to: "whatsapp:(555) 123" }, + }, + }, + whatsapp: { allowFrom: ["*"] }, + }; + expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({ + provider: "whatsapp", + to: "+555123", + }); + }); + it("skips when last route is webchat", () => { const cfg: ClawdbotConfig = {}; const entry = { diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index b18083d90..44c548312 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -3,39 +3,64 @@ import { describe, expect, it } from "vitest"; import { resolveOutboundTarget } from "./targets.js"; describe("resolveOutboundTarget", () => { - it("falls back to whatsapp allowFrom", () => { - const res = resolveOutboundTarget({ - provider: "whatsapp", - to: "", - allowFrom: ["+1555"], - }); - expect(res).toEqual({ ok: true, to: "+1555" }); - }); - - it("normalizes whatsapp allowFrom fallback targets", () => { - const res = resolveOutboundTarget({ - provider: "whatsapp", - to: "", - allowFrom: ["whatsapp:(555) 123-4567"], - }); - expect(res).toEqual({ ok: true, to: "+5551234567" }); - }); - - it("normalizes whatsapp target when provided", () => { - const res = resolveOutboundTarget({ - provider: "whatsapp", - to: " (555) 123-4567 ", - }); - if (!res.ok) throw res.error; - expect(res.to).toBe("+5551234567"); - }); - - it("keeps whatsapp group targets", () => { - const res = resolveOutboundTarget({ - provider: "whatsapp", - to: "120363401234567890@g.us", - }); - expect(res).toEqual({ ok: true, to: "120363401234567890@g.us" }); + it.each([ + { + name: "normalizes whatsapp target when provided", + input: { provider: "whatsapp" as const, to: " (555) 123-4567 " }, + expected: { ok: true as const, to: "+5551234567" }, + }, + { + name: "keeps whatsapp group targets", + input: { provider: "whatsapp" as const, to: "120363401234567890@g.us" }, + expected: { ok: true as const, to: "120363401234567890@g.us" }, + }, + { + name: "normalizes prefixed/uppercase whatsapp group targets", + input: { + provider: "whatsapp" as const, + to: " WhatsApp:Group:120363401234567890@G.US ", + }, + expected: { ok: true as const, to: "120363401234567890@g.us" }, + }, + { + name: "falls back to whatsapp allowFrom", + input: { provider: "whatsapp" as const, to: "", allowFrom: ["+1555"] }, + expected: { ok: true as const, to: "+1555" }, + }, + { + name: "normalizes whatsapp allowFrom fallback targets", + input: { + provider: "whatsapp" as const, + to: "", + allowFrom: ["whatsapp:(555) 123-4567"], + }, + expected: { ok: true as const, to: "+5551234567" }, + }, + { + name: "rejects invalid whatsapp target", + input: { provider: "whatsapp" as const, to: "wat" }, + expectedErrorIncludes: "WhatsApp", + }, + { + name: "rejects whatsapp without to when allowFrom missing", + input: { provider: "whatsapp" as const, to: " " }, + expectedErrorIncludes: "WhatsApp", + }, + { + name: "rejects whatsapp allowFrom fallback when invalid", + input: { provider: "whatsapp" as const, to: "", allowFrom: ["wat"] }, + expectedErrorIncludes: "WhatsApp", + }, + ])("$name", ({ input, expected, expectedErrorIncludes }) => { + const res = resolveOutboundTarget(input); + if (expected) { + expect(res).toEqual(expected); + return; + } + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error.message).toContain(expectedErrorIncludes); + } }); it("rejects telegram with missing target", () => { diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index e2ba36568..63656e837 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -4,11 +4,11 @@ import type { DeliverableMessageProvider, GatewayMessageProvider, } from "../../utils/message-provider.js"; +import { normalizeE164 } from "../../utils.js"; import { isWhatsAppGroupJid, - normalizeE164, normalizeWhatsAppTarget, -} from "../../utils.js"; +} from "../../whatsapp/normalize.js"; export type OutboundProvider = DeliverableMessageProvider | "none"; @@ -24,7 +24,7 @@ export type OutboundTargetResolution = | { ok: true; to: string } | { ok: false; error: Error }; -export function resolveOutboundTarget(params: { +export function normalizeOutboundTarget(params: { provider: GatewayMessageProvider; to?: string; allowFrom?: string[]; @@ -129,6 +129,14 @@ export function resolveOutboundTarget(params: { }; } +export function resolveOutboundTarget(params: { + provider: GatewayMessageProvider; + to?: string; + allowFrom?: string[]; +}): OutboundTargetResolution { + return normalizeOutboundTarget(params); +} + export function resolveHeartbeatDeliveryTarget(params: { cfg: ClawdbotConfig; entry?: SessionEntry; @@ -194,14 +202,14 @@ export function resolveHeartbeatDeliveryTarget(params: { } if (provider !== "whatsapp") { - const resolved = resolveOutboundTarget({ provider, to }); + const resolved = normalizeOutboundTarget({ provider, to }); return resolved.ok ? { provider, to: resolved.to } : { provider: "none", reason: "no-target" }; } const rawAllow = cfg.whatsapp?.allowFrom ?? []; - const resolved = resolveOutboundTarget({ + const resolved = normalizeOutboundTarget({ provider: "whatsapp", to, allowFrom: rawAllow, diff --git a/src/utils.test.ts b/src/utils.test.ts index 7f100f991..e4e21c7c7 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -6,11 +6,9 @@ import { assertProvider, CONFIG_DIR, ensureDir, - isWhatsAppGroupJid, jidToE164, normalizeE164, normalizePath, - normalizeWhatsAppTarget, resolveJidToE164, resolveUserPath, sleep, @@ -86,55 +84,6 @@ describe("normalizeE164 & toWhatsappJid", () => { }); }); -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", - ); - expect( - normalizeWhatsAppTarget("whatsapp:group:120363401234567890@g.us"), - ).toBe("120363401234567890@g.us"); - expect(normalizeWhatsAppTarget("group:123456789-987654321@g.us")).toBe( - "123456789-987654321@g.us", - ); - expect( - normalizeWhatsAppTarget(" WhatsApp:Group:123456789-987654321@G.US "), - ).toBe("123456789-987654321@g.us"); - }); - - it("normalizes direct JIDs to E.164", () => { - expect(normalizeWhatsAppTarget("1555123@s.whatsapp.net")).toBe("+1555123"); - }); - - it("rejects invalid targets", () => { - expect(normalizeWhatsAppTarget("wat")).toBe(""); - expect(normalizeWhatsAppTarget("whatsapp:")).toBe(""); - expect(normalizeWhatsAppTarget("@g.us")).toBe(""); - expect(normalizeWhatsAppTarget("whatsapp:group:@g.us")).toBe(""); - }); -}); - -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( - true, - ); - 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); - }); -}); - describe("jidToE164", () => { it("maps @lid using reverse mapping file", () => { const mappingPath = path.join( diff --git a/src/utils.ts b/src/utils.ts index 4d023b3e8..b1d373702 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -25,34 +25,6 @@ export function withWhatsAppPrefix(number: string): string { return number.startsWith("whatsapp:") ? number : `whatsapp:${number}`; } -function stripWhatsAppTargetPrefixes(value: string): string { - const trimmed = value.trim(); - return trimmed - .replace(/^whatsapp:/i, "") - .replace(/^group:/i, "") - .trim(); -} - -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); -} - -export function normalizeWhatsAppTarget(value: string): string { - const candidate = stripWhatsAppTargetPrefixes(value); - if (!candidate) return ""; - if (isWhatsAppGroupJid(candidate)) { - const localPart = candidate.slice(0, candidate.length - "@g.us".length); - return `${localPart}@g.us`; - } - const normalized = normalizeE164(candidate); - return normalized.length > 1 ? normalized : ""; -} - export function normalizeE164(number: string): string { const withoutPrefix = number.replace(/^whatsapp:/, "").trim(); const digits = withoutPrefix.replace(/[^\d+]/g, ""); diff --git a/src/whatsapp/normalize.test.ts b/src/whatsapp/normalize.test.ts new file mode 100644 index 000000000..493414411 --- /dev/null +++ b/src/whatsapp/normalize.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { isWhatsAppGroupJid, 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", + ); + expect( + normalizeWhatsAppTarget("whatsapp:group:120363401234567890@g.us"), + ).toBe("120363401234567890@g.us"); + expect(normalizeWhatsAppTarget("group:123456789-987654321@g.us")).toBe( + "123456789-987654321@g.us", + ); + expect( + normalizeWhatsAppTarget(" WhatsApp:Group:123456789-987654321@G.US "), + ).toBe("123456789-987654321@g.us"); + }); + + it("normalizes direct JIDs to E.164", () => { + expect(normalizeWhatsAppTarget("1555123@s.whatsapp.net")).toBe("+1555123"); + }); + + it("rejects invalid targets", () => { + expect(normalizeWhatsAppTarget("wat")).toBeNull(); + expect(normalizeWhatsAppTarget("whatsapp:")).toBeNull(); + expect(normalizeWhatsAppTarget("@g.us")).toBeNull(); + expect(normalizeWhatsAppTarget("whatsapp:group:@g.us")).toBeNull(); + }); + + it("handles repeated prefixes", () => { + expect(normalizeWhatsAppTarget("whatsapp:whatsapp:+1555")).toBe("+1555"); + expect(normalizeWhatsAppTarget("group:group:120@g.us")).toBe("120@g.us"); + }); +}); + +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( + true, + ); + 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); + }); +}); diff --git a/src/whatsapp/normalize.ts b/src/whatsapp/normalize.ts new file mode 100644 index 000000000..8fa18677b --- /dev/null +++ b/src/whatsapp/normalize.ts @@ -0,0 +1,33 @@ +import { normalizeE164 } from "../utils.js"; + +function stripWhatsAppTargetPrefixes(value: string): string { + let candidate = value.trim(); + for (;;) { + const before = candidate; + candidate = candidate + .replace(/^whatsapp:/i, "") + .replace(/^group:/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); +} + +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`; + } + const normalized = normalizeE164(candidate); + return normalized.length > 1 ? normalized : null; +}