From 68a500c465cc2a44561c46d8ee14a01e471097f7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 20:59:01 -0700 Subject: [PATCH] fix(whatsapp): normalize onboarding allowlist numbers Normalize WhatsApp onboarding allowlist entries to digit-only WhatsApp IDs and reject invalid owner-phone inputs during prompt validation. --- CHANGELOG.md | 1 + extensions/whatsapp/src/channel.setup.test.ts | 17 ++++++++++++++ extensions/whatsapp/src/channel.ts | 2 ++ .../whatsapp/src/config-accessors.test.ts | 2 +- extensions/whatsapp/src/normalize-target.ts | 23 +++++++++++++++++-- extensions/whatsapp/src/normalize.ts | 1 + extensions/whatsapp/src/setup-finalize.ts | 23 +++++++++++-------- extensions/whatsapp/src/setup-test-helpers.ts | 11 +++++---- 8 files changed, 62 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee620d81a61..84177388258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- WhatsApp/onboarding: canonicalize setup and pairing allowlist entries to WhatsApp's digit-only phone ids while still accepting E.164, JID, and `whatsapp:` inputs, so personal-phone allowlists match WhatsApp Web sender ids after setup. Thanks @vincentkoc. - Slack/subagents: keep resumed parent `message.send` calls in the originating Slack thread when ambient session thread context is present, and suppress successful silent child completion rows from follow-up findings. Thanks @bek91. - Infra/Windows: skip the POSIX `/tmp/openclaw` preferred path on Windows in `resolvePreferredOpenClawTmpDir` so log files, TTS temp files, and other writes land in `%TEMP%\openclaw-` instead of `C:\tmp\openclaw`. Fixes #60713. Thanks @juan-flores077. - Gateway/diagnostics: make stuck-session recovery outcome-driven and generation-guarded, add `diagnostics.stuckSessionAbortMs`, and emit structured recovery requested/completed events so stale or skipped recovery no longer looks like a successful abort. diff --git a/extensions/whatsapp/src/channel.setup.test.ts b/extensions/whatsapp/src/channel.setup.test.ts index 77d75a77453..03773cb9fdc 100644 --- a/extensions/whatsapp/src/channel.setup.test.ts +++ b/extensions/whatsapp/src/channel.setup.test.ts @@ -172,6 +172,23 @@ describe("whatsapp setup wizard", () => { expectWhatsAppOwnerAllowlistSetup(result.cfg, harness); }); + it("rejects invalid owner numbers during prompt validation", async () => { + const harness = createWhatsAppOwnerAllowlistHarness(createQueuedWizardPrompter); + + await runConfigureWithHarness({ + harness, + forceAllowFrom: true, + }); + + const prompt = harness.text.mock.calls[0]?.[0] as + | { validate?: (value: string) => string | undefined } + | undefined; + expect(prompt?.validate).toEqual(expect.any(Function)); + expect(prompt?.validate?.("abc")).toBe("Invalid number: abc"); + expect(prompt?.validate?.("whatsapp:")).toBe("Invalid number: whatsapp:"); + expect(prompt?.validate?.("+1 (555) 555-0123")).toBeUndefined(); + }); + it("supports disabled DM policy for separate-phone setup", async () => { const { harness, result } = await runSeparatePhoneFlow({ selectValues: ["separate", "disabled"], diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 2ddd9ff9857..e69b7806bbe 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -30,6 +30,7 @@ import { isWhatsAppGroupJid, isWhatsAppNewsletterJid, looksLikeWhatsAppTargetId, + normalizeWhatsAppAllowFromEntry, normalizeWhatsAppMessagingTarget, normalizeWhatsAppTarget, } from "./normalize.js"; @@ -69,6 +70,7 @@ export const whatsappPlugin: ChannelPlugin = createChatChannelPlugin({ pairing: { idLabel: "whatsappSenderId", + normalizeAllowEntry: (entry) => normalizeWhatsAppAllowFromEntry(entry) ?? "", }, outbound: whatsappChannelOutbound, threading: { diff --git a/extensions/whatsapp/src/config-accessors.test.ts b/extensions/whatsapp/src/config-accessors.test.ts index c9cd221ae90..d0b33f924e6 100644 --- a/extensions/whatsapp/src/config-accessors.test.ts +++ b/extensions/whatsapp/src/config-accessors.test.ts @@ -29,6 +29,6 @@ describe("whatsapp config accessors", () => { it("normalizes allowFrom entries like the channel plugin", () => { expect( formatWhatsAppConfigAllowFromEntries([" whatsapp:+49123 ", "*", "49124@s.whatsapp.net"]), - ).toEqual(["+49123", "*", "+49124"]); + ).toEqual(["49123", "*", "49124"]); }); }); diff --git a/extensions/whatsapp/src/normalize-target.ts b/extensions/whatsapp/src/normalize-target.ts index e8cf26f6a49..5a3f6fdd99e 100644 --- a/extensions/whatsapp/src/normalize-target.ts +++ b/extensions/whatsapp/src/normalize-target.ts @@ -101,11 +101,30 @@ export function normalizeWhatsAppMessagingTarget(raw: string): string | undefine } export function normalizeWhatsAppAllowFromEntries(allowFrom: Array): string[] { - return allowFrom + const seen = new Set(); + const normalized = allowFrom .map((entry) => String(entry).trim()) .filter((entry): entry is string => Boolean(entry)) - .map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry))) + .map(normalizeWhatsAppAllowFromEntry) .filter((entry): entry is string => Boolean(entry)); + return normalized.filter((entry) => { + if (seen.has(entry)) { + return false; + } + seen.add(entry); + return true; + }); +} + +export function normalizeWhatsAppAllowFromEntry(entry: string): string | null { + if (entry === "*") { + return entry; + } + const normalized = normalizeWhatsAppTarget(entry); + if (!normalized) { + return null; + } + return normalized.startsWith("+") ? normalized.slice(1) : normalized; } export function looksLikeWhatsAppTargetId(raw: string): boolean { diff --git a/extensions/whatsapp/src/normalize.ts b/extensions/whatsapp/src/normalize.ts index a782eecd8da..e4bc35a3a3f 100644 --- a/extensions/whatsapp/src/normalize.ts +++ b/extensions/whatsapp/src/normalize.ts @@ -1,5 +1,6 @@ export { looksLikeWhatsAppTargetId, + normalizeWhatsAppAllowFromEntry, normalizeWhatsAppMessagingTarget, isWhatsAppGroupJid, isWhatsAppNewsletterJid, diff --git a/extensions/whatsapp/src/setup-finalize.ts b/extensions/whatsapp/src/setup-finalize.ts index 0ea0a12711e..daa30302084 100644 --- a/extensions/whatsapp/src/setup-finalize.ts +++ b/extensions/whatsapp/src/setup-finalize.ts @@ -1,7 +1,6 @@ import path from "node:path"; import { DEFAULT_ACCOUNT_ID, - normalizeAllowFromEntries, normalizeE164, pathExists, splitSetupEntries, @@ -15,6 +14,10 @@ import { resolveWhatsAppAccount, resolveWhatsAppAuthDir, } from "./accounts.js"; +import { + normalizeWhatsAppAllowFromEntries, + normalizeWhatsAppAllowFromEntry, +} from "./normalize-target.js"; import { whatsappSetupAdapter } from "./setup-core.js"; type SetupPrompter = Parameters>[0]["prompter"]; @@ -177,7 +180,7 @@ async function promptWhatsAppOwnerAllowFrom(params: { if (!raw) { return "Required"; } - const normalized = normalizeE164(raw); + const normalized = normalizeWhatsAppAllowFromEntry(raw); if (!normalized) { return `Invalid number: ${raw}`; } @@ -185,14 +188,14 @@ async function promptWhatsAppOwnerAllowFrom(params: { }, }); - const normalized = normalizeE164(trimPromptText(entry)); + const normalized = normalizeWhatsAppAllowFromEntry(trimPromptText(entry)); if (!normalized) { throw new Error("Invalid WhatsApp owner number (expected E.164 after validation)."); } - const allowFrom = normalizeAllowFromEntries( - [...existingAllowFrom.filter((item) => item !== "*"), normalized], - normalizeE164, - ); + const allowFrom = normalizeWhatsAppAllowFromEntries([ + ...existingAllowFrom.filter((item) => item !== "*"), + normalized, + ]); return { normalized, allowFrom }; } @@ -229,13 +232,13 @@ function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invali entries.push("*"); continue; } - const normalized = normalizeE164(part); + const normalized = normalizeWhatsAppAllowFromEntry(part); if (!normalized) { return { entries: [], invalidEntry: part }; } entries.push(normalized); } - return { entries: normalizeAllowFromEntries(entries, normalizeE164) }; + return { entries: normalizeWhatsAppAllowFromEntries(entries) }; } async function promptWhatsAppDmAccess(params: { @@ -313,7 +316,7 @@ async function promptWhatsAppDmAccess(params: { let next = setWhatsAppSelfChatMode(params.cfg, accountId, false); next = setWhatsAppDmPolicy(next, accountId, policy); if (policy === "open") { - const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164); + const allowFrom = normalizeWhatsAppAllowFromEntries(["*", ...existingAllowFrom]); next = setWhatsAppAllowFrom(next, accountId, allowFrom.length > 0 ? allowFrom : ["*"]); return next; } diff --git a/extensions/whatsapp/src/setup-test-helpers.ts b/extensions/whatsapp/src/setup-test-helpers.ts index 279ac078c92..23d2c9959ab 100644 --- a/extensions/whatsapp/src/setup-test-helpers.ts +++ b/extensions/whatsapp/src/setup-test-helpers.ts @@ -24,9 +24,10 @@ type QueuedWizardPrompterFactory = (params: { }) => T; const WHATSAPP_OWNER_NUMBER_INPUT = "+1 (555) 555-0123"; -const WHATSAPP_OWNER_NUMBER = "+15555550123"; +const WHATSAPP_OWNER_NUMBER_E164 = "+15555550123"; +const WHATSAPP_OWNER_NUMBER = "15555550123"; const WHATSAPP_PERSONAL_NUMBER_INPUT = "+1 (555) 111-2222"; -const WHATSAPP_PERSONAL_NUMBER = "+15551112222"; +const WHATSAPP_PERSONAL_NUMBER = "15551112222"; const WHATSAPP_ACCESS_NOTE_TITLE = "WhatsApp DM access"; const WHATSAPP_LOGIN_NOTE_TITLE = "WhatsApp"; @@ -34,7 +35,7 @@ export function createWhatsAppRootAllowFromConfig(): WhatsAppSetupConfig { return { channels: { whatsapp: { - allowFrom: [WHATSAPP_OWNER_NUMBER], + allowFrom: [WHATSAPP_OWNER_NUMBER_E164], }, }, }; @@ -78,7 +79,7 @@ export function createWhatsAppWorkAccountConfig( whatsapp: { ...(params.defaultAccount ? { defaultAccount: params.defaultAccount } : {}), dmPolicy: "disabled", - allowFrom: [WHATSAPP_OWNER_NUMBER], + allowFrom: [WHATSAPP_OWNER_NUMBER_E164], accounts: { work: { authDir: "/tmp/work", @@ -118,7 +119,7 @@ function expectWhatsAppDmAccess( export function expectWhatsAppWorkAccountOpenAccess(cfg: WhatsAppSetupConfig): void { expect(cfg.channels?.whatsapp?.dmPolicy).toBe("disabled"); - expect(cfg.channels?.whatsapp?.allowFrom).toEqual([WHATSAPP_OWNER_NUMBER]); + expect(cfg.channels?.whatsapp?.allowFrom).toEqual([WHATSAPP_OWNER_NUMBER_E164]); expect(cfg.channels?.whatsapp?.accounts?.work?.dmPolicy).toBe("open"); expect(cfg.channels?.whatsapp?.accounts?.work?.allowFrom).toEqual(["*", WHATSAPP_OWNER_NUMBER]); }