From 0da588d2d2559df33f8df3a028ab44053a1c1a8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:56:31 -0700 Subject: [PATCH] refactor: move whatsapp to setup wizard --- extensions/whatsapp/src/channel.ts | 51 +--- extensions/whatsapp/src/onboarding.test.ts | 18 +- .../src/{onboarding.ts => setup-surface.ts} | 263 ++++++++++-------- src/commands/onboarding/registry.ts | 6 +- 4 files changed, 167 insertions(+), 171 deletions(-) rename extensions/whatsapp/src/{onboarding.ts => setup-surface.ts} (60%) diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 1745f8caa74..e240824c743 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,5 +1,4 @@ import { - applyAccountNameToChannelSection, buildChannelConfigSchema, buildAccountScopedDmSecurityPolicy, collectAllowlistProviderGroupPolicyWarnings, @@ -10,8 +9,6 @@ import { getChatChannelMeta, listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, - migrateBaseNameToDefaultAccount, - normalizeAccountId, normalizeE164, formatWhatsAppConfigAllowFromEntries, readStringParam, @@ -35,8 +32,8 @@ import { type ResolvedWhatsAppAccount, } from "./accounts.js"; import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js"; -import { whatsappOnboardingAdapter } from "./onboarding.js"; import { getWhatsAppRuntime } from "./runtime.js"; +import { whatsappSetupAdapter, whatsappSetupWizard } from "./setup-surface.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; const meta = getChatChannelMeta("whatsapp"); @@ -50,7 +47,7 @@ export const whatsappPlugin: ChannelPlugin = { forceAccountBinding: true, preferSessionLookupForAnnounceTarget: true, }, - onboarding: whatsappOnboardingAdapter, + setupWizard: whatsappSetupWizard, agentTools: () => [getWhatsAppRuntime().channel.whatsapp.createLoginTool()], pairing: { idLabel: "whatsappSenderId", @@ -163,49 +160,7 @@ export const whatsappPlugin: ChannelPlugin = { }); }, }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "whatsapp", - accountId, - name, - alwaysUseAccounts: true, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "whatsapp", - accountId, - name: input.name, - alwaysUseAccounts: true, - }); - const next = migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "whatsapp", - alwaysUseAccounts: true, - }); - const entry = { - ...next.channels?.whatsapp?.accounts?.[accountId], - ...(input.authDir ? { authDir: input.authDir } : {}), - enabled: true, - }; - return { - ...next, - channels: { - ...next.channels, - whatsapp: { - ...next.channels?.whatsapp, - accounts: { - ...next.channels?.whatsapp?.accounts, - [accountId]: entry, - }, - }, - }, - }; - }, - }, + setup: whatsappSetupAdapter, groups: { resolveRequireMention: resolveWhatsAppGroupRequireMention, resolveToolPolicy: resolveWhatsAppGroupToolPolicy, diff --git a/extensions/whatsapp/src/onboarding.test.ts b/extensions/whatsapp/src/onboarding.test.ts index b046928cf15..bf816e3f03d 100644 --- a/extensions/whatsapp/src/onboarding.test.ts +++ b/extensions/whatsapp/src/onboarding.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import type { RuntimeEnv } from "../../../src/runtime.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { whatsappOnboardingAdapter } from "./onboarding.js"; +import { whatsappPlugin } from "./channel.js"; const loginWebMock = vi.hoisted(() => vi.fn(async () => {})); const pathExistsMock = vi.hoisted(() => vi.fn(async () => false)); @@ -82,16 +83,21 @@ function createRuntime(): RuntimeEnv { } as unknown as RuntimeEnv; } +const whatsappConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: whatsappPlugin, + wizard: whatsappPlugin.setupWizard!, +}); + async function runConfigureWithHarness(params: { harness: ReturnType; - cfg?: Parameters[0]["cfg"]; + cfg?: Parameters[0]["cfg"]; runtime?: RuntimeEnv; - options?: Parameters[0]["options"]; - accountOverrides?: Parameters[0]["accountOverrides"]; + options?: Parameters[0]["options"]; + accountOverrides?: Parameters[0]["accountOverrides"]; shouldPromptAccountIds?: boolean; forceAllowFrom?: boolean; }) { - return await whatsappOnboardingAdapter.configure({ + return await whatsappConfigureAdapter.configure({ cfg: params.cfg ?? {}, runtime: params.runtime ?? createRuntime(), prompter: params.harness.prompter, @@ -122,7 +128,7 @@ async function runSeparatePhoneFlow(params: { selectValues: string[]; textValues return { harness, result }; } -describe("whatsappOnboardingAdapter.configure", () => { +describe("whatsapp setup wizard", () => { beforeEach(() => { vi.clearAllMocks(); pathExistsMock.mockResolvedValue(false); diff --git a/extensions/whatsapp/src/onboarding.ts b/extensions/whatsapp/src/setup-surface.ts similarity index 60% rename from extensions/whatsapp/src/onboarding.ts rename to extensions/whatsapp/src/setup-surface.ts index e68fc42a5c3..180f84a3fbf 100644 --- a/extensions/whatsapp/src/onboarding.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -1,26 +1,24 @@ import path from "node:path"; import { loginWeb } from "../../../src/channel-web.js"; -import type { ChannelOnboardingAdapter } from "../../../src/channels/plugins/onboarding-types.js"; import { normalizeAllowFromEntries, - resolveAccountIdForConfigure, - resolveOnboardingAccountId, splitOnboardingEntries, } from "../../../src/channels/plugins/onboarding/helpers.js"; +import { setOnboardingChannelEnabled } from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { formatCliCommand } from "../../../src/cli/command-format.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { mergeWhatsAppConfig } from "../../../src/config/merge-config.js"; import type { DmPolicy } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { normalizeE164, pathExists } from "../../../src/utils.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAuthDir, -} from "./accounts.js"; +import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; const channel = "whatsapp" as const; @@ -43,8 +41,8 @@ async function detectWhatsAppLinked(cfg: OpenClawConfig, accountId: string): Pro } async function promptWhatsAppOwnerAllowFrom(params: { - prompter: WizardPrompter; existingAllowFrom: string[]; + prompter: Parameters>[0]["prompter"]; }): Promise<{ normalized: string; allowFrom: string[] }> { const { prompter, existingAllowFrom } = params; @@ -82,10 +80,10 @@ async function promptWhatsAppOwnerAllowFrom(params: { async function applyWhatsAppOwnerAllowlist(params: { cfg: OpenClawConfig; - prompter: WizardPrompter; existingAllowFrom: string[]; - title: string; messageLines: string[]; + prompter: Parameters>[0]["prompter"]; + title: string; }): Promise { const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({ prompter: params.prompter, @@ -121,27 +119,26 @@ function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invali return { entries: normalizeAllowFromEntries(entries, normalizeE164) }; } -async function promptWhatsAppAllowFrom( - cfg: OpenClawConfig, - _runtime: RuntimeEnv, - prompter: WizardPrompter, - options?: { forceAllowlist?: boolean }, -): Promise { - const existingPolicy = cfg.channels?.whatsapp?.dmPolicy ?? "pairing"; - const existingAllowFrom = cfg.channels?.whatsapp?.allowFrom ?? []; +async function promptWhatsAppDmAccess(params: { + cfg: OpenClawConfig; + forceAllowFrom: boolean; + prompter: Parameters>[0]["prompter"]; +}): Promise { + const existingPolicy = params.cfg.channels?.whatsapp?.dmPolicy ?? "pairing"; + const existingAllowFrom = params.cfg.channels?.whatsapp?.allowFrom ?? []; const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; - if (options?.forceAllowlist) { + if (params.forceAllowFrom) { return await applyWhatsAppOwnerAllowlist({ - cfg, - prompter, + cfg: params.cfg, + prompter: params.prompter, existingAllowFrom, title: "WhatsApp allowlist", messageLines: ["Allowlist mode enabled."], }); } - await prompter.note( + await params.prompter.note( [ "WhatsApp direct chats are gated by `channels.whatsapp.dmPolicy` + `channels.whatsapp.allowFrom`.", "- pairing (default): unknown senders get a pairing code; owner approves", @@ -155,7 +152,7 @@ async function promptWhatsAppAllowFrom( "WhatsApp DM access", ); - const phoneMode = await prompter.select({ + const phoneMode = await params.prompter.select({ message: "WhatsApp phone setup", options: [ { value: "personal", label: "This is my personal phone number" }, @@ -165,8 +162,8 @@ async function promptWhatsAppAllowFrom( if (phoneMode === "personal") { return await applyWhatsAppOwnerAllowlist({ - cfg, - prompter, + cfg: params.cfg, + prompter: params.prompter, existingAllowFrom, title: "WhatsApp personal phone", messageLines: [ @@ -176,7 +173,7 @@ async function promptWhatsAppAllowFrom( }); } - const policy = (await prompter.select({ + const policy = (await params.prompter.select({ message: "WhatsApp DM policy", options: [ { value: "pairing", label: "Pairing (recommended)" }, @@ -186,7 +183,7 @@ async function promptWhatsAppAllowFrom( ], })) as DmPolicy; - let next = setWhatsAppSelfChatMode(cfg, false); + let next = setWhatsAppSelfChatMode(params.cfg, false); next = setWhatsAppDmPolicy(next, policy); if (policy === "open") { const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164); @@ -212,7 +209,7 @@ async function promptWhatsAppAllowFrom( { value: "list", label: "Set allowFrom to specific numbers" }, ] as const); - const mode = await prompter.select({ + const mode = await params.prompter.select({ message: "WhatsApp allowFrom (optional pre-allowlist)", options: allowOptions.map((opt) => ({ value: opt.value, @@ -221,92 +218,123 @@ async function promptWhatsAppAllowFrom( }); if (mode === "keep") { - // Keep allowFrom as-is. - } else if (mode === "unset") { - next = setWhatsAppAllowFrom(next, undefined); - } else { - const allowRaw = await prompter.text({ - message: "Allowed sender numbers (comma-separated, E.164)", - placeholder: "+15555550123, +447700900123", - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - const parsed = parseWhatsAppAllowFromEntries(raw); - if (parsed.entries.length === 0 && !parsed.invalidEntry) { - return "Required"; - } - if (parsed.invalidEntry) { - return `Invalid number: ${parsed.invalidEntry}`; - } - return undefined; - }, - }); - - const parsed = parseWhatsAppAllowFromEntries(String(allowRaw)); - next = setWhatsAppAllowFrom(next, parsed.entries); + return next; + } + if (mode === "unset") { + return setWhatsAppAllowFrom(next, undefined); } - return next; + const allowRaw = await params.prompter.text({ + message: "Allowed sender numbers (comma-separated, E.164)", + placeholder: "+15555550123, +447700900123", + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + const parsed = parseWhatsAppAllowFromEntries(raw); + if (parsed.entries.length === 0 && !parsed.invalidEntry) { + return "Required"; + } + if (parsed.invalidEntry) { + return `Invalid number: ${parsed.invalidEntry}`; + } + return undefined; + }, + }); + + const parsed = parseWhatsAppAllowFromEntries(String(allowRaw)); + return setWhatsAppAllowFrom(next, parsed.entries); } -export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg, accountOverrides }) => { - const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg); - const accountId = resolveOnboardingAccountId({ - accountId: accountOverrides.whatsapp, - defaultAccountId, - }); - const linked = await detectWhatsAppLinked(cfg, accountId); - const accountLabel = accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId; - return { - channel, - configured: linked, - statusLines: [`WhatsApp (${accountLabel}): ${linked ? "linked" : "not linked"}`], - selectionHint: linked ? "linked" : "not linked", - quickstartScore: linked ? 5 : 4, - }; - }, - configure: async ({ - cfg, - runtime, - prompter, - options, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - const accountId = await resolveAccountIdForConfigure({ +export const whatsappSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ cfg, - prompter, - label: "WhatsApp", - accountOverride: accountOverrides.whatsapp, - shouldPromptAccountIds: Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId), - listAccountIds: listWhatsAppAccountIds, - defaultAccountId: resolveDefaultWhatsAppAccountId(cfg), + channelKey: channel, + accountId, + name, + alwaysUseAccounts: true, + }), + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + alwaysUseAccounts: true, }); - - let next = cfg; - if (accountId !== DEFAULT_ACCOUNT_ID) { - next = { - ...next, - channels: { - ...next.channels, - whatsapp: { - ...next.channels?.whatsapp, - accounts: { - ...next.channels?.whatsapp?.accounts, - [accountId]: { - ...next.channels?.whatsapp?.accounts?.[accountId], - enabled: next.channels?.whatsapp?.accounts?.[accountId]?.enabled ?? true, - }, - }, + const next = migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + alwaysUseAccounts: true, + }); + const entry = { + ...next.channels?.whatsapp?.accounts?.[accountId], + ...(input.authDir ? { authDir: input.authDir } : {}), + enabled: true, + }; + return { + ...next, + channels: { + ...next.channels, + whatsapp: { + ...next.channels?.whatsapp, + accounts: { + ...next.channels?.whatsapp?.accounts, + [accountId]: entry, }, }, - }; - } + }, + }; + }, +}; + +export const whatsappSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "linked", + unconfiguredLabel: "not linked", + configuredHint: "linked", + unconfiguredHint: "not linked", + configuredScore: 5, + unconfiguredScore: 4, + resolveConfigured: async ({ cfg }) => { + for (const accountId of listWhatsAppAccountIds(cfg)) { + if (await detectWhatsAppLinked(cfg, accountId)) { + return true; + } + } + return false; + }, + resolveStatusLines: async ({ cfg, configured }) => { + const linkedAccountId = ( + await Promise.all( + listWhatsAppAccountIds(cfg).map(async (accountId) => ({ + accountId, + linked: await detectWhatsAppLinked(cfg, accountId), + })), + ) + ).find((entry) => entry.linked)?.accountId; + const label = linkedAccountId + ? `WhatsApp (${linkedAccountId === DEFAULT_ACCOUNT_ID ? "default" : linkedAccountId})` + : "WhatsApp"; + return [`${label}: ${configured ? "linked" : "not linked"}`]; + }, + }, + resolveShouldPromptAccountIds: ({ options, shouldPromptAccountIds }) => + Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId), + credentials: [], + finalize: async ({ cfg, accountId, forceAllowFrom, prompter, runtime }) => { + let next = + accountId === DEFAULT_ACCOUNT_ID + ? cfg + : whatsappSetupAdapter.applyAccountConfig({ + cfg, + accountId, + input: {}, + }); const linked = await detectWhatsAppLinked(next, accountId); const { authDir } = resolveWhatsAppAuthDir({ @@ -324,6 +352,7 @@ export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = { "WhatsApp linking", ); } + const wantsLink = await prompter.confirm({ message: linked ? "WhatsApp already linked. Re-link now?" : "Link WhatsApp now (QR)?", initialValue: !linked, @@ -331,8 +360,8 @@ export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = { if (wantsLink) { try { await loginWeb(false, undefined, runtime, accountId); - } catch (err) { - runtime.error(`WhatsApp login failed: ${String(err)}`); + } catch (error) { + runtime.error(`WhatsApp login failed: ${String(error)}`); await prompter.note(`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, "WhatsApp help"); } } else if (!linked) { @@ -342,12 +371,14 @@ export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = { ); } - next = await promptWhatsAppAllowFrom(next, runtime, prompter, { - forceAllowlist: forceAllowFrom, + next = await promptWhatsAppDmAccess({ + cfg: next, + forceAllowFrom, + prompter, }); - - return { cfg: next, accountId }; + return { cfg: next }; }, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), onAccountRecorded: (accountId, options) => { options?.onWhatsAppAccountId?.(accountId); }, diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index 40bec8720f1..14074daf193 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -3,7 +3,7 @@ import { imessagePlugin } from "../../../extensions/imessage/src/channel.js"; import { signalPlugin } from "../../../extensions/signal/src/channel.js"; import { slackPlugin } from "../../../extensions/slack/src/channel.js"; import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { whatsappOnboardingAdapter } from "../../../extensions/whatsapp/src/onboarding.js"; +import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; import type { ChannelChoice } from "../onboard-types.js"; @@ -29,6 +29,10 @@ const imessageOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ plugin: imessagePlugin, wizard: imessagePlugin.setupWizard!, }); +const whatsappOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: whatsappPlugin, + wizard: whatsappPlugin.setupWizard!, +}); const BUILTIN_ONBOARDING_ADAPTERS: ChannelOnboardingAdapter[] = [ telegramOnboardingAdapter,