refactor: centralize WhatsApp target normalization

This commit is contained in:
Peter Steinberger
2026-01-10 02:39:50 +01:00
parent 8f8caa8d89
commit 9cd2662a86
9 changed files with 191 additions and 130 deletions

View File

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

View File

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

View File

@@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

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

33
src/whatsapp/normalize.ts Normal file
View File

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