diff --git a/extensions/whatsapp/src/setup-surface.test.ts b/extensions/whatsapp/src/setup-surface.test.ts index 5e286d068e8..3c5a701ab21 100644 --- a/extensions/whatsapp/src/setup-surface.test.ts +++ b/extensions/whatsapp/src/setup-surface.test.ts @@ -48,11 +48,12 @@ async function runFinalizeWithHarness(params: { cfg?: Parameters>[0]["cfg"]; runtime?: RuntimeEnv; forceAllowFrom?: boolean; + accountId?: string; }) { return await runSetupWizardFinalize({ finalize: whatsappSetupWizard.finalize, cfg: params.cfg ?? {}, - accountId: DEFAULT_ACCOUNT_ID, + accountId: params.accountId ?? DEFAULT_ACCOUNT_ID, runtime: params.runtime ?? createRuntime(), prompter: params.harness.prompter, forceAllowFrom: params.forceAllowFrom ?? false, @@ -135,6 +136,47 @@ describe("whatsapp setup wizard", () => { expect(harness.text).not.toHaveBeenCalled(); }); + it("writes named-account DM policy and allowFrom instead of the channel root", async () => { + hoisted.pathExists.mockResolvedValue(true); + const harness = createSeparatePhoneHarness({ + selectValues: ["separate", "open"], + }); + + const named = expectFinalizeResult( + await runFinalizeWithHarness({ + harness, + accountId: "work", + cfg: { + channels: { + whatsapp: { + dmPolicy: "disabled", + allowFrom: ["+15555550123"], + accounts: { + work: { + authDir: "/tmp/work", + }, + }, + }, + }, + }, + }), + ); + + expect(named.cfg.channels?.whatsapp?.dmPolicy).toBe("disabled"); + expect(named.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]); + expect(named.cfg.channels?.whatsapp?.accounts?.work?.dmPolicy).toBe("open"); + expect(named.cfg.channels?.whatsapp?.accounts?.work?.allowFrom).toEqual([ + "*", + "+15555550123", + ]); + expect(harness.note).toHaveBeenCalledWith( + expect.stringContaining( + "`channels.whatsapp.accounts.work.dmPolicy` + `channels.whatsapp.accounts.work.allowFrom`", + ), + "WhatsApp DM access", + ); + }); + it("normalizes allowFrom entries when list mode is selected", async () => { const { result } = await runSeparatePhoneFlow({ selectValues: ["separate", "allowlist", "list"], diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index e836362bca5..787d6bc9075 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -12,7 +12,7 @@ import { } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; -import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; +import { listWhatsAppAccountIds, resolveWhatsAppAccount, resolveWhatsAppAuthDir } from "./accounts.js"; import { loginWeb } from "./login.js"; import { whatsappSetupAdapter } from "./setup-core.js"; @@ -20,38 +20,74 @@ const channel = "whatsapp" as const; function mergeWhatsAppConfig( cfg: OpenClawConfig, + accountId: string, patch: Partial["whatsapp"]>>, options?: { unsetOnUndefined?: string[] }, ): OpenClawConfig { - const base = { ...(cfg.channels?.whatsapp ?? {}) } as Record; + const channelConfig = { ...(cfg.channels?.whatsapp ?? {}) } as Record; + if (accountId === DEFAULT_ACCOUNT_ID) { + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + if (options?.unsetOnUndefined?.includes(key)) { + delete channelConfig[key]; + } + continue; + } + channelConfig[key] = value; + } + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: channelConfig, + }, + }; + } + + const accounts = { + ...((channelConfig.accounts as Record | undefined> | undefined) ?? {}), + }; + const nextAccount = { ...(accounts[accountId] ?? {}) }; for (const [key, value] of Object.entries(patch)) { if (value === undefined) { if (options?.unsetOnUndefined?.includes(key)) { - delete base[key]; + delete nextAccount[key]; } continue; } - base[key] = value; + nextAccount[key] = value; } + accounts[accountId] = nextAccount; return { ...cfg, channels: { ...cfg.channels, - whatsapp: base, + whatsapp: { + ...channelConfig, + accounts, + }, }, }; } -function setWhatsAppDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return mergeWhatsAppConfig(cfg, { dmPolicy }); +function setWhatsAppDmPolicy(cfg: OpenClawConfig, accountId: string, dmPolicy: DmPolicy): OpenClawConfig { + return mergeWhatsAppConfig(cfg, accountId, { dmPolicy }); } -function setWhatsAppAllowFrom(cfg: OpenClawConfig, allowFrom?: string[]): OpenClawConfig { - return mergeWhatsAppConfig(cfg, { allowFrom }, { unsetOnUndefined: ["allowFrom"] }); +function setWhatsAppAllowFrom( + cfg: OpenClawConfig, + accountId: string, + allowFrom?: string[], +): OpenClawConfig { + return mergeWhatsAppConfig(cfg, accountId, { allowFrom }, { unsetOnUndefined: ["allowFrom"] }); } -function setWhatsAppSelfChatMode(cfg: OpenClawConfig, selfChatMode: boolean): OpenClawConfig { - return mergeWhatsAppConfig(cfg, { selfChatMode }); +function setWhatsAppSelfChatMode( + cfg: OpenClawConfig, + accountId: string, + selfChatMode: boolean, +): OpenClawConfig { + return mergeWhatsAppConfig(cfg, accountId, { selfChatMode }); } async function detectWhatsAppLinked(cfg: OpenClawConfig, accountId: string): Promise { @@ -100,6 +136,7 @@ async function promptWhatsAppOwnerAllowFrom(params: { async function applyWhatsAppOwnerAllowlist(params: { cfg: OpenClawConfig; + accountId: string; existingAllowFrom: string[]; messageLines: string[]; prompter: Parameters>[0]["prompter"]; @@ -109,9 +146,9 @@ async function applyWhatsAppOwnerAllowlist(params: { prompter: params.prompter, existingAllowFrom: params.existingAllowFrom, }); - let next = setWhatsAppSelfChatMode(params.cfg, true); - next = setWhatsAppDmPolicy(next, "allowlist"); - next = setWhatsAppAllowFrom(next, allowFrom); + let next = setWhatsAppSelfChatMode(params.cfg, params.accountId, true); + next = setWhatsAppDmPolicy(next, params.accountId, "allowlist"); + next = setWhatsAppAllowFrom(next, params.accountId, allowFrom); await params.prompter.note( [...params.messageLines, `- allowFrom includes ${normalized}`].join("\n"), params.title, @@ -141,16 +178,28 @@ function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invali async function promptWhatsAppDmAccess(params: { cfg: OpenClawConfig; + accountId: string; forceAllowFrom: boolean; prompter: Parameters>[0]["prompter"]; }): Promise { - const existingPolicy = params.cfg.channels?.whatsapp?.dmPolicy ?? "pairing"; - const existingAllowFrom = params.cfg.channels?.whatsapp?.allowFrom ?? []; + const accountId = normalizeAccountId(params.accountId); + const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId }); + const existingPolicy = account.dmPolicy ?? "pairing"; + const existingAllowFrom = account.allowFrom ?? []; const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; + const policyKey = + accountId === DEFAULT_ACCOUNT_ID + ? "channels.whatsapp.dmPolicy" + : `channels.whatsapp.accounts.${accountId}.dmPolicy`; + const allowFromKey = + accountId === DEFAULT_ACCOUNT_ID + ? "channels.whatsapp.allowFrom" + : `channels.whatsapp.accounts.${accountId}.allowFrom`; if (params.forceAllowFrom) { return await applyWhatsAppOwnerAllowlist({ cfg: params.cfg, + accountId, prompter: params.prompter, existingAllowFrom, title: "WhatsApp allowlist", @@ -160,7 +209,7 @@ async function promptWhatsAppDmAccess(params: { await params.prompter.note( [ - "WhatsApp direct chats are gated by `channels.whatsapp.dmPolicy` + `channels.whatsapp.allowFrom`.", + `WhatsApp direct chats are gated by \`${policyKey}\` + \`${allowFromKey}\`.`, "- pairing (default): unknown senders get a pairing code; owner approves", "- allowlist: unknown senders are blocked", '- open: public inbound DMs (requires allowFrom to include "*")', @@ -183,6 +232,7 @@ async function promptWhatsAppDmAccess(params: { if (phoneMode === "personal") { return await applyWhatsAppOwnerAllowlist({ cfg: params.cfg, + accountId, prompter: params.prompter, existingAllowFrom, title: "WhatsApp personal phone", @@ -203,11 +253,11 @@ async function promptWhatsAppDmAccess(params: { ], })) as DmPolicy; - let next = setWhatsAppSelfChatMode(params.cfg, false); - next = setWhatsAppDmPolicy(next, policy); + let next = setWhatsAppSelfChatMode(params.cfg, accountId, false); + next = setWhatsAppDmPolicy(next, accountId, policy); if (policy === "open") { const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164); - next = setWhatsAppAllowFrom(next, allowFrom.length > 0 ? allowFrom : ["*"]); + next = setWhatsAppAllowFrom(next, accountId, allowFrom.length > 0 ? allowFrom : ["*"]); return next; } if (policy === "disabled") { @@ -241,7 +291,7 @@ async function promptWhatsAppDmAccess(params: { return next; } if (mode === "unset") { - return setWhatsAppAllowFrom(next, undefined); + return setWhatsAppAllowFrom(next, accountId, undefined); } const allowRaw = await params.prompter.text({ @@ -264,7 +314,7 @@ async function promptWhatsAppDmAccess(params: { }); const parsed = parseWhatsAppAllowFromEntries(String(allowRaw)); - return setWhatsAppAllowFrom(next, parsed.entries); + return setWhatsAppAllowFrom(next, accountId, parsed.entries); } export const whatsappSetupWizard: ChannelSetupWizard = { @@ -349,6 +399,7 @@ export const whatsappSetupWizard: ChannelSetupWizard = { next = await promptWhatsAppDmAccess({ cfg: next, + accountId, forceAllowFrom, prompter, });