From f0700e9778f218bddf9b50ef0503d7ddd4cf7aef Mon Sep 17 00:00:00 2001 From: Xin Date: Sat, 10 Jan 2026 00:07:09 +0000 Subject: [PATCH] fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) --- CHANGELOG.md | 1 + src/cron/isolated-agent.ts | 22 ++++++++++--- src/gateway/server-methods/agent.ts | 20 ++++++++--- src/infra/heartbeat-runner.test.ts | 30 +++++++++++++++++ src/infra/outbound/targets.test.ts | 17 ++++++++++ src/infra/outbound/targets.ts | 25 +++++++++++--- src/utils.test.ts | 51 +++++++++++++++++++++++++++++ src/utils.ts | 28 ++++++++++++++++ 8 files changed, 181 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b120b0c..8b18c3f30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Models/Auth: add OpenCode Zen (multi-model proxy) onboarding. (#623) — thanks @magimetal - WhatsApp: refactor vCard parsing helper and improve empty contact card summaries. (#624) — thanks @steipete - WhatsApp: include phone numbers when multiple contacts are shared. (#625) — thanks @mahmoudashraf93 +- WhatsApp: preserve group JIDs when normalizing delivery targets. (#631) — thanks @imfing - Agents: warn on small context windows (<32k) and block unusable ones (<16k). — thanks @steipete - Pairing: cap pending DM pairing requests at 3 per provider and avoid pairing replies for outbound DMs. — thanks @steipete - macOS: replace relay smoke test with version check in packaging script. (#615) — thanks @YuriNachos diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 2ef6a3a65..b84c53fae 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -48,7 +48,12 @@ 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"; import type { CronJob } from "./types.js"; export type RunCronAgentTurnResult = { @@ -203,14 +208,21 @@ function resolveDeliveryTarget( const sanitizedWhatsappTo = (() => { if (provider !== "whatsapp") return rawTo; + if (rawTo && isWhatsAppGroupJid(rawTo)) { + return normalizeWhatsAppTarget(rawTo) || rawTo; + } const rawAllow = cfg.whatsapp?.allowFrom ?? []; - if (rawAllow.includes("*")) return rawTo; + if (rawAllow.includes("*")) { + return rawTo ? normalizeWhatsAppTarget(rawTo) : rawTo; + } const allowFrom = rawAllow .map((val) => normalizeE164(val)) .filter((val) => val.length > 1); - if (allowFrom.length === 0) return rawTo; + if (allowFrom.length === 0) { + return rawTo ? normalizeWhatsAppTarget(rawTo) : rawTo; + } if (!rawTo) return allowFrom[0]; - const normalized = normalizeE164(rawTo); + const normalized = normalizeWhatsAppTarget(rawTo); if (allowFrom.includes(normalized)) return normalized; return allowFrom[0]; })(); @@ -543,7 +555,7 @@ export async function runCronIsolatedAgentTurn(params: { summary: "Delivery skipped (no WhatsApp recipient).", }; } - const to = normalizeE164(resolvedDelivery.to); + const to = normalizeWhatsAppTarget(resolvedDelivery.to); try { await deliverPayloadsWithMedia({ payloads, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 7b605106a..746420bba 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -18,7 +18,11 @@ import { isGatewayMessageProvider, normalizeMessageProvider, } from "../../utils/message-provider.js"; -import { normalizeE164 } from "../../utils.js"; +import { + isWhatsAppGroupJid, + normalizeE164, + normalizeWhatsAppTarget, +} from "../../utils.js"; import { type AgentWaitParams, ErrorCodes, @@ -221,11 +225,19 @@ export const agentHandlers: GatewayRequestHandlers = { typeof request.to === "string" && request.to.trim() ? request.to.trim() : undefined; - if (explicit) return resolvedTo; + if (explicit) { + if (!resolvedTo) return resolvedTo; + return normalizeWhatsAppTarget(resolvedTo) || resolvedTo; + } + if (resolvedTo && isWhatsAppGroupJid(resolvedTo)) { + return normalizeWhatsAppTarget(resolvedTo) || resolvedTo; + } const cfg = cfgForAgent ?? loadConfig(); const rawAllow = cfg.whatsapp?.allowFrom ?? []; - if (rawAllow.includes("*")) return resolvedTo; + if (rawAllow.includes("*")) { + return resolvedTo ? normalizeWhatsAppTarget(resolvedTo) : resolvedTo; + } const allowFrom = rawAllow .map((val) => normalizeE164(val)) .filter((val) => val.length > 1); @@ -233,7 +245,7 @@ export const agentHandlers: GatewayRequestHandlers = { const normalizedLast = typeof resolvedTo === "string" && resolvedTo.trim() - ? normalizeE164(resolvedTo) + ? normalizeWhatsAppTarget(resolvedTo) : undefined; if (normalizedLast && allowFrom.includes(normalizedLast)) { return normalizedLast; diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index beee3a992..5768ae186 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -126,6 +126,36 @@ describe("resolveHeartbeatDeliveryTarget", () => { }); }); + it("keeps WhatsApp group targets even with allowFrom set", () => { + const cfg: ClawdbotConfig = { + whatsapp: { allowFrom: ["+1555"] }, + }; + const entry = { + ...baseEntry, + lastProvider: "whatsapp" as const, + lastTo: "120363401234567890@g.us", + }; + expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({ + provider: "whatsapp", + to: "120363401234567890@g.us", + }); + }); + + it("normalizes prefixed WhatsApp group targets for heartbeat delivery", () => { + const cfg: ClawdbotConfig = { + whatsapp: { allowFrom: ["+1555"] }, + }; + const entry = { + ...baseEntry, + lastProvider: "whatsapp" as const, + lastTo: "whatsapp:group:120363401234567890@G.US", + }; + expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({ + provider: "whatsapp", + to: "120363401234567890@g.us", + }); + }); + it("keeps explicit telegram targets", () => { const cfg: ClawdbotConfig = { agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } }, diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 725acb66a..b18083d90 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -12,6 +12,15 @@ describe("resolveOutboundTarget", () => { 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", @@ -21,6 +30,14 @@ describe("resolveOutboundTarget", () => { 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("rejects telegram with missing target", () => { const res = resolveOutboundTarget({ provider: "telegram", to: " " }); expect(res.ok).toBe(false); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 60cefaa73..e2ba36568 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -4,7 +4,11 @@ import type { DeliverableMessageProvider, GatewayMessageProvider, } from "../../utils/message-provider.js"; -import { normalizeE164 } from "../../utils.js"; +import { + isWhatsAppGroupJid, + normalizeE164, + normalizeWhatsAppTarget, +} from "../../utils.js"; export type OutboundProvider = DeliverableMessageProvider | "none"; @@ -28,16 +32,28 @@ export function resolveOutboundTarget(params: { const trimmed = params.to?.trim() || ""; if (params.provider === "whatsapp") { if (trimmed) { - return { ok: true, to: normalizeE164(trimmed) }; + const normalized = normalizeWhatsAppTarget(trimmed); + if (!normalized) { + return { + ok: false, + error: new Error( + "Delivering to WhatsApp requires --to or whatsapp.allowFrom[0]", + ), + }; + } + return { ok: true, to: normalized }; } const fallback = params.allowFrom?.[0]?.trim(); if (fallback) { - return { ok: true, to: fallback }; + const normalized = normalizeWhatsAppTarget(fallback); + if (normalized) { + return { ok: true, to: normalized }; + } } return { ok: false, error: new Error( - "Delivering to WhatsApp requires --to or whatsapp.allowFrom[0]", + "Delivering to WhatsApp requires --to or whatsapp.allowFrom[0]", ), }; } @@ -194,6 +210,7 @@ export function resolveHeartbeatDeliveryTarget(params: { return { provider: "none", reason: "no-target" }; } if (rawAllow.includes("*")) return { provider, to: resolved.to }; + if (isWhatsAppGroupJid(resolved.to)) return { provider, to: resolved.to }; const allowFrom = rawAllow .map((val) => normalizeE164(val)) .filter((val) => val.length > 1); diff --git a/src/utils.test.ts b/src/utils.test.ts index e4e21c7c7..7f100f991 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -6,9 +6,11 @@ import { assertProvider, CONFIG_DIR, ensureDir, + isWhatsAppGroupJid, jidToE164, normalizeE164, normalizePath, + normalizeWhatsAppTarget, resolveJidToE164, resolveUserPath, sleep, @@ -84,6 +86,55 @@ 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 b1d373702..4d023b3e8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -25,6 +25,34 @@ 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, "");