refactor: move whatsapp to setup wizard

This commit is contained in:
Peter Steinberger 2026-03-15 17:56:31 -07:00
parent 33495f32e9
commit 0da588d2d2
No known key found for this signature in database
4 changed files with 167 additions and 171 deletions

View File

@ -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<ResolvedWhatsAppAccount> = {
forceAccountBinding: true,
preferSessionLookupForAnnounceTarget: true,
},
onboarding: whatsappOnboardingAdapter,
setupWizard: whatsappSetupWizard,
agentTools: () => [getWhatsAppRuntime().channel.whatsapp.createLoginTool()],
pairing: {
idLabel: "whatsappSenderId",
@ -163,49 +160,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
});
},
},
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,

View File

@ -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<typeof createPrompterHarness>;
cfg?: Parameters<typeof whatsappOnboardingAdapter.configure>[0]["cfg"];
cfg?: Parameters<typeof whatsappConfigureAdapter.configure>[0]["cfg"];
runtime?: RuntimeEnv;
options?: Parameters<typeof whatsappOnboardingAdapter.configure>[0]["options"];
accountOverrides?: Parameters<typeof whatsappOnboardingAdapter.configure>[0]["accountOverrides"];
options?: Parameters<typeof whatsappConfigureAdapter.configure>[0]["options"];
accountOverrides?: Parameters<typeof whatsappConfigureAdapter.configure>[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);

View File

@ -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<NonNullable<ChannelSetupWizard["finalize"]>>[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<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
title: string;
}): Promise<OpenClawConfig> {
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<OpenClawConfig> {
const existingPolicy = cfg.channels?.whatsapp?.dmPolicy ?? "pairing";
const existingAllowFrom = cfg.channels?.whatsapp?.allowFrom ?? [];
async function promptWhatsAppDmAccess(params: {
cfg: OpenClawConfig;
forceAllowFrom: boolean;
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
}): Promise<OpenClawConfig> {
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);
},

View File

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