From f08fe02a1b19791fe5f18f3fce1792ccbc9bfb1d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 26 Feb 2026 01:14:57 -0500 Subject: [PATCH] Onboarding: support plugin-owned interactive channel flows (#27191) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 53872cf8e75562db012b66f888928524daff08d2 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/tools/plugin.md | 23 ++ src/channels/plugins/onboarding-types.ts | 13 + src/commands/channel-test-helpers.ts | 24 ++ src/commands/onboard-channels.test.ts | 308 ++++++++++++++++++++++- src/commands/onboard-channels.ts | 64 ++++- 6 files changed, 426 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b9a53cecfb..5f2ce6018e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz. - Heartbeat/Config: replace heartbeat DM toggle with `agents.defaults.heartbeat.directPolicy` (`allow` | `block`; also supported per-agent via `agents.list[].heartbeat.directPolicy`) for clearer delivery semantics. - Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening. +- Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras. - Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. - Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow. - Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 9250501f2d9..3dc575088eb 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -452,6 +452,29 @@ Notes: - `meta.preferOver` lists channel ids to skip auto-enable when both are configured. - `meta.detailLabel` and `meta.systemImage` let UIs show richer channel labels/icons. +### Channel onboarding hooks + +Channel plugins can define optional onboarding hooks on `plugin.onboarding`: + +- `configure(ctx)` is the baseline setup flow. +- `configureInteractive(ctx)` can fully own interactive setup for both configured and unconfigured states. +- `configureWhenConfigured(ctx)` can override behavior only for already configured channels. + +Hook precedence in the wizard: + +1. `configureInteractive` (if present) +2. `configureWhenConfigured` (only when channel status is already configured) +3. fallback to `configure` + +Context details: + +- `configureInteractive` and `configureWhenConfigured` receive: + - `configured` (`true` or `false`) + - `label` (user-facing channel name used by prompts) + - plus the shared config/runtime/prompter/options fields +- Returning `"skip"` leaves selection and account tracking unchanged. +- Returning `{ cfg, accountId? }` applies config updates and records account selection. + ### Write a new messaging channel (step‑by‑step) Use this when you want a **new chat surface** (a "messaging channel"), not a model provider. diff --git a/src/channels/plugins/onboarding-types.ts b/src/channels/plugins/onboarding-types.ts index 897487a49c6..342f29bf5b5 100644 --- a/src/channels/plugins/onboarding-types.ts +++ b/src/channels/plugins/onboarding-types.ts @@ -62,6 +62,13 @@ export type ChannelOnboardingResult = { accountId?: string; }; +export type ChannelOnboardingConfiguredResult = ChannelOnboardingResult | "skip"; + +export type ChannelOnboardingInteractiveContext = ChannelOnboardingConfigureContext & { + configured: boolean; + label: string; +}; + export type ChannelOnboardingDmPolicy = { label: string; channel: ChannelId; @@ -80,6 +87,12 @@ export type ChannelOnboardingAdapter = { channel: ChannelId; getStatus: (ctx: ChannelOnboardingStatusContext) => Promise; configure: (ctx: ChannelOnboardingConfigureContext) => Promise; + configureInteractive?: ( + ctx: ChannelOnboardingInteractiveContext, + ) => Promise; + configureWhenConfigured?: ( + ctx: ChannelOnboardingInteractiveContext, + ) => Promise; dmPolicy?: ChannelOnboardingDmPolicy; onAccountRecorded?: (accountId: string, options?: SetupChannelsOptions) => void; disable?: (cfg: OpenClawConfig) => OpenClawConfig; diff --git a/src/commands/channel-test-helpers.ts b/src/commands/channel-test-helpers.ts index fd7e6f36278..65745a55d5e 100644 --- a/src/commands/channel-test-helpers.ts +++ b/src/commands/channel-test-helpers.ts @@ -6,6 +6,9 @@ import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import type { ChannelChoice } from "./onboard-types.js"; +import { getChannelOnboardingAdapter } from "./onboarding/registry.js"; +import type { ChannelOnboardingAdapter } from "./onboarding/types.js"; export function setDefaultChannelPluginRegistryForTests(): void { const channels = [ @@ -18,3 +21,24 @@ export function setDefaultChannelPluginRegistryForTests(): void { ] as unknown as Parameters[0]; setActivePluginRegistry(createTestRegistry(channels)); } + +export function patchChannelOnboardingAdapter( + channel: ChannelChoice, + patch: Pick, +): () => void { + const adapter = getChannelOnboardingAdapter(channel); + if (!adapter) { + throw new Error(`missing onboarding adapter for ${channel}`); + } + const keys = Object.keys(patch) as K[]; + const previous = {} as Pick; + for (const key of keys) { + previous[key] = adapter[key]; + adapter[key] = patch[key]; + } + return () => { + for (const key of keys) { + adapter[key] = previous[key]; + } + }; +} diff --git a/src/commands/onboard-channels.test.ts b/src/commands/onboard-channels.test.ts index d6c0669e4fd..cd146b82c09 100644 --- a/src/commands/onboard-channels.test.ts +++ b/src/commands/onboard-channels.test.ts @@ -3,7 +3,10 @@ import type { OpenClawConfig } from "../config/config.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; -import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js"; +import { + patchChannelOnboardingAdapter, + setDefaultChannelPluginRegistryForTests, +} from "./channel-test-helpers.js"; import { setupChannels } from "./onboard-channels.js"; import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js"; @@ -249,4 +252,307 @@ describe("setupChannels", () => { expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" })); expect(multiselect).not.toHaveBeenCalled(); }); + + it("uses configureInteractive skip without mutating selection/account state", async () => { + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "telegram"; + } + return "__done__"; + }); + const selection = vi.fn(); + const onAccountId = vi.fn(); + const configureInteractive = vi.fn(async () => "skip" as const); + const restore = patchChannelOnboardingAdapter("telegram", { + getStatus: vi.fn(async ({ cfg }) => ({ + channel: "telegram", + configured: Boolean(cfg.channels?.telegram?.botToken), + statusLines: [], + })), + configureInteractive, + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + const runtime = createExitThrowingRuntime(); + try { + const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, { + skipConfirm: true, + quickstartDefaults: true, + onSelection: selection, + onAccountId, + }); + + expect(configureInteractive).toHaveBeenCalledWith( + expect.objectContaining({ configured: false, label: expect.any(String) }), + ); + expect(selection).toHaveBeenCalledWith([]); + expect(onAccountId).not.toHaveBeenCalled(); + expect(cfg.channels?.telegram?.botToken).toBeUndefined(); + } finally { + restore(); + } + }); + + it("applies configureInteractive result cfg/account updates", async () => { + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "telegram"; + } + return "__done__"; + }); + const selection = vi.fn(); + const onAccountId = vi.fn(); + const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + cfg: { + ...cfg, + channels: { + ...cfg.channels, + telegram: { ...cfg.channels?.telegram, botToken: "new-token" }, + }, + } as OpenClawConfig, + accountId: "acct-1", + })); + const configure = vi.fn(async () => { + throw new Error("configure should not be called when configureInteractive is present"); + }); + const restore = patchChannelOnboardingAdapter("telegram", { + getStatus: vi.fn(async ({ cfg }) => ({ + channel: "telegram", + configured: Boolean(cfg.channels?.telegram?.botToken), + statusLines: [], + })), + configureInteractive, + configure, + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + const runtime = createExitThrowingRuntime(); + try { + const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, { + skipConfirm: true, + quickstartDefaults: true, + onSelection: selection, + onAccountId, + }); + + expect(configureInteractive).toHaveBeenCalledTimes(1); + expect(configure).not.toHaveBeenCalled(); + expect(selection).toHaveBeenCalledWith(["telegram"]); + expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-1"); + expect(cfg.channels?.telegram?.botToken).toBe("new-token"); + } finally { + restore(); + } + }); + + it("uses configureWhenConfigured when channel is already configured", async () => { + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "telegram"; + } + return "__done__"; + }); + const selection = vi.fn(); + const onAccountId = vi.fn(); + const configureWhenConfigured = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + cfg: { + ...cfg, + channels: { + ...cfg.channels, + telegram: { ...cfg.channels?.telegram, botToken: "updated-token" }, + }, + } as OpenClawConfig, + accountId: "acct-2", + })); + const configure = vi.fn(async () => { + throw new Error( + "configure should not be called when configureWhenConfigured handles updates", + ); + }); + const restore = patchChannelOnboardingAdapter("telegram", { + getStatus: vi.fn(async ({ cfg }) => ({ + channel: "telegram", + configured: Boolean(cfg.channels?.telegram?.botToken), + statusLines: [], + })), + configureInteractive: undefined, + configureWhenConfigured, + configure, + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + const runtime = createExitThrowingRuntime(); + try { + const cfg = await setupChannels( + { + channels: { + telegram: { + botToken: "old-token", + }, + }, + } as OpenClawConfig, + runtime, + prompter, + { + skipConfirm: true, + quickstartDefaults: true, + onSelection: selection, + onAccountId, + }, + ); + + expect(configureWhenConfigured).toHaveBeenCalledTimes(1); + expect(configureWhenConfigured).toHaveBeenCalledWith( + expect.objectContaining({ configured: true, label: expect.any(String) }), + ); + expect(configure).not.toHaveBeenCalled(); + expect(selection).toHaveBeenCalledWith(["telegram"]); + expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-2"); + expect(cfg.channels?.telegram?.botToken).toBe("updated-token"); + } finally { + restore(); + } + }); + + it("respects configureWhenConfigured skip without mutating selection or account state", async () => { + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "telegram"; + } + throw new Error(`unexpected select prompt: ${message}`); + }); + const selection = vi.fn(); + const onAccountId = vi.fn(); + const configureWhenConfigured = vi.fn(async () => "skip" as const); + const configure = vi.fn(async () => { + throw new Error("configure should not run when configureWhenConfigured handles skip"); + }); + const restore = patchChannelOnboardingAdapter("telegram", { + getStatus: vi.fn(async ({ cfg }) => ({ + channel: "telegram", + configured: Boolean(cfg.channels?.telegram?.botToken), + statusLines: [], + })), + configureInteractive: undefined, + configureWhenConfigured, + configure, + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + const runtime = createExitThrowingRuntime(); + try { + const cfg = await setupChannels( + { + channels: { + telegram: { + botToken: "old-token", + }, + }, + } as OpenClawConfig, + runtime, + prompter, + { + skipConfirm: true, + quickstartDefaults: true, + onSelection: selection, + onAccountId, + }, + ); + + expect(configureWhenConfigured).toHaveBeenCalledWith( + expect.objectContaining({ configured: true, label: expect.any(String) }), + ); + expect(configure).not.toHaveBeenCalled(); + expect(selection).toHaveBeenCalledWith([]); + expect(onAccountId).not.toHaveBeenCalled(); + expect(cfg.channels?.telegram?.botToken).toBe("old-token"); + } finally { + restore(); + } + }); + + it("prefers configureInteractive over configureWhenConfigured when both hooks exist", async () => { + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "telegram"; + } + throw new Error(`unexpected select prompt: ${message}`); + }); + const selection = vi.fn(); + const onAccountId = vi.fn(); + const configureInteractive = vi.fn(async () => "skip" as const); + const configureWhenConfigured = vi.fn(async () => { + throw new Error("configureWhenConfigured should not run when configureInteractive exists"); + }); + const restore = patchChannelOnboardingAdapter("telegram", { + getStatus: vi.fn(async ({ cfg }) => ({ + channel: "telegram", + configured: Boolean(cfg.channels?.telegram?.botToken), + statusLines: [], + })), + configureInteractive, + configureWhenConfigured, + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + const runtime = createExitThrowingRuntime(); + try { + await setupChannels( + { + channels: { + telegram: { + botToken: "old-token", + }, + }, + } as OpenClawConfig, + runtime, + prompter, + { + skipConfirm: true, + quickstartDefaults: true, + onSelection: selection, + onAccountId, + }, + ); + + expect(configureInteractive).toHaveBeenCalledWith( + expect.objectContaining({ configured: true, label: expect.any(String) }), + ); + expect(configureWhenConfigured).not.toHaveBeenCalled(); + expect(selection).toHaveBeenCalledWith([]); + expect(onAccountId).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); }); diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 32510c29f39..6e79379e1f1 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -27,7 +27,9 @@ import { listChannelOnboardingAdapters, } from "./onboarding/registry.js"; import type { + ChannelOnboardingConfiguredResult, ChannelOnboardingDmPolicy, + ChannelOnboardingResult, ChannelOnboardingStatus, SetupChannelsOptions, } from "./onboarding/types.js"; @@ -488,6 +490,26 @@ export async function setupChannels( return true; }; + const applyOnboardingResult = async (channel: ChannelChoice, result: ChannelOnboardingResult) => { + next = result.cfg; + if (result.accountId) { + recordAccount(channel, result.accountId); + } + addSelection(channel); + await refreshStatus(channel); + }; + + const applyCustomOnboardingResult = async ( + channel: ChannelChoice, + result: ChannelOnboardingConfiguredResult, + ) => { + if (result === "skip") { + return false; + } + await applyOnboardingResult(channel, result); + return true; + }; + const configureChannel = async (channel: ChannelChoice) => { const adapter = getChannelOnboardingAdapter(channel); if (!adapter) { @@ -503,17 +525,29 @@ export async function setupChannels( shouldPromptAccountIds, forceAllowFrom: forceAllowFromChannels.has(channel), }); - next = result.cfg; - if (result.accountId) { - recordAccount(channel, result.accountId); - } - addSelection(channel); - await refreshStatus(channel); + await applyOnboardingResult(channel, result); }; const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => { const plugin = getChannelPlugin(channel); const adapter = getChannelOnboardingAdapter(channel); + if (adapter?.configureWhenConfigured) { + const custom = await adapter.configureWhenConfigured({ + cfg: next, + runtime, + prompter, + options, + accountOverrides, + shouldPromptAccountIds, + forceAllowFrom: forceAllowFromChannels.has(channel), + configured: true, + label, + }); + if (!(await applyCustomOnboardingResult(channel, custom))) { + return; + } + return; + } const supportsDisable = Boolean( options?.allowDisable && (plugin?.config.setAccountEnabled || adapter?.disable), ); @@ -615,9 +649,27 @@ export async function setupChannels( } const plugin = getChannelPlugin(channel); + const adapter = getChannelOnboardingAdapter(channel); const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel; const status = statusByChannel.get(channel); const configured = status?.configured ?? false; + if (adapter?.configureInteractive) { + const custom = await adapter.configureInteractive({ + cfg: next, + runtime, + prompter, + options, + accountOverrides, + shouldPromptAccountIds, + forceAllowFrom: forceAllowFromChannels.has(channel), + configured, + label, + }); + if (!(await applyCustomOnboardingResult(channel, custom))) { + return; + } + return; + } if (configured) { await handleConfiguredChannel(channel, label); return;