fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing)

This commit is contained in:
Xin
2026-01-10 00:07:09 +00:00
committed by Peter Steinberger
parent a6822e1210
commit f0700e9778
8 changed files with 181 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <E.164|group JID> 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 <E.164> or whatsapp.allowFrom[0]",
"Delivering to WhatsApp requires --to <E.164|group JID> 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);

View File

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

View File

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