From 7891f277df5e2d7f8484d4c710df7457af5ecdd3 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 25 Feb 2026 20:05:52 -0500 Subject: [PATCH] Onboarding: support plugin-owned interactive channel flows --- extensions/matrix-js/src/onboarding.ts | 607 ++++++++++++++--------- src/channels/plugins/onboarding-types.ts | 13 + src/commands/channels/add.ts | 64 +++ src/commands/onboard-channels.test.ts | 111 +++++ src/commands/onboard-channels.ts | 45 ++ 5 files changed, 603 insertions(+), 237 deletions(-) diff --git a/extensions/matrix-js/src/onboarding.ts b/extensions/matrix-js/src/onboarding.ts index ded1a13809d..dd52b635b7b 100644 --- a/extensions/matrix-js/src/onboarding.ts +++ b/extensions/matrix-js/src/onboarding.ts @@ -3,13 +3,23 @@ import { addWildcardAllowFrom, formatDocsLink, mergeAllowFromEntries, + normalizeAccountId, + promptAccountId, promptChannelAccessConfig, + type RuntimeEnv, type ChannelOnboardingAdapter, type ChannelOnboardingDmPolicy, type WizardPrompter, } from "openclaw/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; +import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js"; import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; -import { resolveMatrixAccount } from "./matrix/accounts.js"; +import { + listMatrixAccountIds, + resolveDefaultMatrixAccountId, + resolveMatrixAccount, + resolveMatrixAccountConfig, +} from "./matrix/accounts.js"; import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; import { resolveMatrixTargets } from "./resolve-targets.js"; import type { CoreConfig } from "./types.js"; @@ -169,6 +179,50 @@ function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { }; } +function upsertMatrixAccountConfig( + cfg: CoreConfig, + accountId: string, + patch: { + name?: string; + enabled?: boolean; + homeserver?: string; + userId?: string; + accessToken?: string; + password?: string; + deviceName?: string; + encryption?: boolean; + }, +): CoreConfig { + const matrix = cfg.channels?.["matrix-js"] ?? {}; + const normalizedAccountId = normalizeAccountId(accountId); + return { + ...cfg, + channels: { + ...cfg.channels, + "matrix-js": { + ...matrix, + enabled: true, + accounts: { + ...matrix.accounts, + [normalizedAccountId]: { + ...matrix.accounts?.[normalizedAccountId], + ...(patch.name?.trim() ? { name: patch.name.trim() } : {}), + ...(typeof patch.enabled === "boolean" + ? { enabled: patch.enabled } + : { enabled: true }), + ...(patch.homeserver ? { homeserver: patch.homeserver } : {}), + ...(patch.userId ? { userId: patch.userId } : {}), + ...(patch.accessToken ? { accessToken: patch.accessToken } : {}), + ...(patch.password ? { password: patch.password } : {}), + ...(patch.deviceName ? { deviceName: patch.deviceName } : {}), + ...(typeof patch.encryption === "boolean" ? { encryption: patch.encryption } : {}), + }, + }, + }, + }, + }; +} + const dmPolicy: ChannelOnboardingDmPolicy = { label: "Matrix", channel, @@ -179,6 +233,266 @@ const dmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptMatrixAllowFrom, }; +type MatrixConfigureIntent = "update" | "add-account"; + +async function runMatrixConfigure(params: { + cfg: CoreConfig; + runtime: RuntimeEnv; + prompter: WizardPrompter; + forceAllowFrom: boolean; + accountOverrides?: Partial>; + shouldPromptAccountIds?: boolean; + intent: MatrixConfigureIntent; +}): Promise<{ cfg: CoreConfig; accountId: string }> { + let next = migrateMatrixLegacyCredentialsToDefaultAccount(params.cfg); + await ensureMatrixSdkInstalled({ + runtime: params.runtime, + confirm: async (message) => + await params.prompter.confirm({ + message, + initialValue: true, + }), + }); + const defaultAccountId = resolveDefaultMatrixAccountId(next); + let accountId = defaultAccountId || DEFAULT_ACCOUNT_ID; + if (params.intent === "add-account") { + const enteredName = String( + await params.prompter.text({ + message: "Matrix account name", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + accountId = normalizeAccountId(enteredName); + if (enteredName !== accountId) { + await params.prompter.note(`Account id will be "${accountId}".`, "Matrix account"); + } + next = upsertMatrixAccountConfig(next, accountId, { name: enteredName, enabled: true }); + } else { + const override = params.accountOverrides?.[channel]?.trim(); + if (override) { + accountId = normalizeAccountId(override); + } else if (params.shouldPromptAccountIds) { + accountId = await promptAccountId({ + cfg: next, + prompter: params.prompter, + label: "Matrix-js", + currentId: accountId, + listAccountIds: (inputCfg) => listMatrixAccountIds(inputCfg as CoreConfig), + defaultAccountId, + }); + } + } + + const existing = resolveMatrixAccountConfig({ cfg: next, accountId }); + const account = resolveMatrixAccount({ cfg: next, accountId }); + if (!account.configured) { + await noteMatrixAuthHelp(params.prompter); + } + + const envHomeserver = process.env.MATRIX_HOMESERVER?.trim(); + const envUserId = process.env.MATRIX_USER_ID?.trim(); + const envAccessToken = process.env.MATRIX_ACCESS_TOKEN?.trim(); + const envPassword = process.env.MATRIX_PASSWORD?.trim(); + const envReady = Boolean(envHomeserver && (envAccessToken || (envUserId && envPassword))); + + if ( + envReady && + !existing.homeserver && + !existing.userId && + !existing.accessToken && + !existing.password + ) { + const useEnv = await params.prompter.confirm({ + message: "Matrix env vars detected. Use env values?", + initialValue: true, + }); + if (useEnv) { + next = upsertMatrixAccountConfig(next, accountId, { enabled: true }); + if (params.forceAllowFrom) { + next = await promptMatrixAllowFrom({ cfg: next, prompter: params.prompter }); + } + return { cfg: next, accountId }; + } + } + + const homeserver = String( + await params.prompter.text({ + message: "Matrix homeserver URL", + initialValue: existing.homeserver ?? envHomeserver, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + if (!/^https?:\/\//i.test(raw)) { + return "Use a full URL (https://...)"; + } + return undefined; + }, + }), + ).trim(); + + let accessToken = existing.accessToken ?? ""; + let password = existing.password ?? ""; + let userId = existing.userId ?? ""; + + if (accessToken || password) { + const keep = await params.prompter.confirm({ + message: "Matrix credentials already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + accessToken = ""; + password = ""; + userId = ""; + } + } + + if (!accessToken && !password) { + const authMode = await params.prompter.select({ + message: "Matrix auth method", + options: [ + { value: "token", label: "Access token (user ID fetched automatically)" }, + { value: "password", label: "Password (requires user ID)" }, + ], + }); + + if (authMode === "token") { + accessToken = String( + await params.prompter.text({ + message: "Matrix access token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + userId = ""; + } else { + userId = String( + await params.prompter.text({ + message: "Matrix user ID", + initialValue: existing.userId ?? envUserId, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + if (!raw.startsWith("@")) { + return "Matrix user IDs should start with @"; + } + if (!raw.includes(":")) { + return "Matrix user IDs should include a server (:server)"; + } + return undefined; + }, + }), + ).trim(); + password = String( + await params.prompter.text({ + message: "Matrix password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } + + const deviceName = String( + await params.prompter.text({ + message: "Matrix device name (optional)", + initialValue: existing.deviceName ?? "OpenClaw Gateway", + }), + ).trim(); + + const enableEncryption = await params.prompter.confirm({ + message: "Enable end-to-end encryption (E2EE)?", + initialValue: existing.encryption ?? false, + }); + + next = upsertMatrixAccountConfig(next, accountId, { + enabled: true, + homeserver, + userId: userId || undefined, + accessToken: accessToken || undefined, + password: password || undefined, + deviceName: deviceName || undefined, + encryption: enableEncryption || undefined, + }); + + if (params.forceAllowFrom) { + next = await promptMatrixAllowFrom({ cfg: next, prompter: params.prompter }); + } + + const existingGroups = + next.channels?.["matrix-js"]?.groups ?? next.channels?.["matrix-js"]?.rooms; + const accessConfig = await promptChannelAccessConfig({ + prompter: params.prompter, + label: "Matrix rooms", + currentPolicy: next.channels?.["matrix-js"]?.groupPolicy ?? "allowlist", + currentEntries: Object.keys(existingGroups ?? {}), + placeholder: "!roomId:server, #alias:server, Project Room", + updatePrompt: Boolean(existingGroups), + }); + if (accessConfig) { + if (accessConfig.policy !== "allowlist") { + next = setMatrixGroupPolicy(next, accessConfig.policy); + } else { + let roomKeys = accessConfig.entries; + if (accessConfig.entries.length > 0) { + try { + const resolvedIds: string[] = []; + const unresolved: string[] = []; + for (const entry of accessConfig.entries) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); + if (cleaned.startsWith("!") && cleaned.includes(":")) { + resolvedIds.push(cleaned); + continue; + } + const matches = await listMatrixDirectoryGroupsLive({ + cfg: next, + query: trimmed, + limit: 10, + }); + const exact = matches.find( + (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), + ); + const best = exact ?? matches[0]; + if (best?.id) { + resolvedIds.push(best.id); + } else { + unresolved.push(entry); + } + } + roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + if (resolvedIds.length > 0 || unresolved.length > 0) { + await params.prompter.note( + [ + resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined, + unresolved.length > 0 + ? `Unresolved (kept as typed): ${unresolved.join(", ")}` + : undefined, + ] + .filter(Boolean) + .join("\n"), + "Matrix rooms", + ); + } + } catch (err) { + await params.prompter.note( + `Room lookup failed; keeping entries as typed. ${String(err)}`, + "Matrix rooms", + ); + } + } + next = setMatrixGroupPolicy(next, "allowlist"); + next = setMatrixGroupRooms(next, roomKeys); + } + } + + return { cfg: next, accountId }; +} + export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { channel, getStatus: async ({ cfg }) => { @@ -194,245 +508,64 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { selectionHint: !sdkReady ? "install matrix-js-sdk" : configured ? "configured" : "needs auth", }; }, - configure: async ({ cfg, runtime, prompter, forceAllowFrom }) => { - let next = cfg as CoreConfig; - await ensureMatrixSdkInstalled({ + configure: async ({ + cfg, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + }) => + await runMatrixConfigure({ + cfg: cfg as CoreConfig, runtime, - confirm: async (message) => - await prompter.confirm({ - message, - initialValue: true, - }), - }); - const existing = next.channels?.["matrix-js"] ?? {}; - const account = resolveMatrixAccount({ cfg: next }); - if (!account.configured) { - await noteMatrixAuthHelp(prompter); - } - - const envHomeserver = process.env.MATRIX_HOMESERVER?.trim(); - const envUserId = process.env.MATRIX_USER_ID?.trim(); - const envAccessToken = process.env.MATRIX_ACCESS_TOKEN?.trim(); - const envPassword = process.env.MATRIX_PASSWORD?.trim(); - const envReady = Boolean(envHomeserver && (envAccessToken || (envUserId && envPassword))); - - if ( - envReady && - !existing.homeserver && - !existing.userId && - !existing.accessToken && - !existing.password - ) { - const useEnv = await prompter.confirm({ - message: "Matrix env vars detected. Use env values?", - initialValue: true, - }); - if (useEnv) { - next = { - ...next, - channels: { - ...next.channels, - "matrix-js": { - ...next.channels?.["matrix-js"], - enabled: true, - }, - }, - }; - if (forceAllowFrom) { - next = await promptMatrixAllowFrom({ cfg: next, prompter }); - } - return { cfg: next }; - } - } - - const homeserver = String( - await prompter.text({ - message: "Matrix homeserver URL", - initialValue: existing.homeserver ?? envHomeserver, - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - if (!/^https?:\/\//i.test(raw)) { - return "Use a full URL (https://...)"; - } - return undefined; - }, - }), - ).trim(); - - let accessToken = existing.accessToken ?? ""; - let password = existing.password ?? ""; - let userId = existing.userId ?? ""; - - if (accessToken || password) { - const keep = await prompter.confirm({ - message: "Matrix credentials already configured. Keep them?", - initialValue: true, - }); - if (!keep) { - accessToken = ""; - password = ""; - userId = ""; - } - } - - if (!accessToken && !password) { - // Ask auth method FIRST before asking for user ID - const authMode = await prompter.select({ - message: "Matrix auth method", - options: [ - { value: "token", label: "Access token (user ID fetched automatically)" }, - { value: "password", label: "Password (requires user ID)" }, - ], - }); - - if (authMode === "token") { - accessToken = String( - await prompter.text({ - message: "Matrix access token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - // With access token, we can fetch the userId automatically - don't prompt for it - // The client.ts will use whoami() to get it - userId = ""; - } else { - // Password auth requires user ID upfront. - userId = String( - await prompter.text({ - message: "Matrix user ID", - initialValue: existing.userId ?? envUserId, - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - if (!raw.startsWith("@")) { - return "Matrix user IDs should start with @"; - } - if (!raw.includes(":")) { - return "Matrix user IDs should include a server (:server)"; - } - return undefined; - }, - }), - ).trim(); - password = String( - await prompter.text({ - message: "Matrix password", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - } - - const deviceName = String( - await prompter.text({ - message: "Matrix device name (optional)", - initialValue: existing.deviceName ?? "OpenClaw Gateway", - }), - ).trim(); - - // Ask about E2EE encryption - const enableEncryption = await prompter.confirm({ - message: "Enable end-to-end encryption (E2EE)?", - initialValue: existing.encryption ?? false, - }); - - next = { - ...next, - channels: { - ...next.channels, - "matrix-js": { - ...next.channels?.["matrix-js"], - enabled: true, - homeserver, - userId: userId || undefined, - accessToken: accessToken || undefined, - password: password || undefined, - deviceName: deviceName || undefined, - encryption: enableEncryption || undefined, - }, - }, - }; - - if (forceAllowFrom) { - next = await promptMatrixAllowFrom({ cfg: next, prompter }); - } - - const existingGroups = - next.channels?.["matrix-js"]?.groups ?? next.channels?.["matrix-js"]?.rooms; - const accessConfig = await promptChannelAccessConfig({ prompter, - label: "Matrix rooms", - currentPolicy: next.channels?.["matrix-js"]?.groupPolicy ?? "allowlist", - currentEntries: Object.keys(existingGroups ?? {}), - placeholder: "!roomId:server, #alias:server, Project Room", - updatePrompt: Boolean(existingGroups), - }); - if (accessConfig) { - if (accessConfig.policy !== "allowlist") { - next = setMatrixGroupPolicy(next, accessConfig.policy); - } else { - let roomKeys = accessConfig.entries; - if (accessConfig.entries.length > 0) { - try { - const resolvedIds: string[] = []; - const unresolved: string[] = []; - for (const entry of accessConfig.entries) { - const trimmed = entry.trim(); - if (!trimmed) { - continue; - } - const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); - if (cleaned.startsWith("!") && cleaned.includes(":")) { - resolvedIds.push(cleaned); - continue; - } - const matches = await listMatrixDirectoryGroupsLive({ - cfg: next, - query: trimmed, - limit: 10, - }); - const exact = matches.find( - (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), - ); - const best = exact ?? matches[0]; - if (best?.id) { - resolvedIds.push(best.id); - } else { - unresolved.push(entry); - } - } - roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - if (resolvedIds.length > 0 || unresolved.length > 0) { - await prompter.note( - [ - resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined, - unresolved.length > 0 - ? `Unresolved (kept as typed): ${unresolved.join(", ")}` - : undefined, - ] - .filter(Boolean) - .join("\n"), - "Matrix rooms", - ); - } - } catch (err) { - await prompter.note( - `Room lookup failed; keeping entries as typed. ${String(err)}`, - "Matrix rooms", - ); - } - } - next = setMatrixGroupPolicy(next, "allowlist"); - next = setMatrixGroupRooms(next, roomKeys); - } + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + intent: "update", + }), + configureInteractive: async ({ + cfg, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + configured, + }) => { + if (!configured) { + return await runMatrixConfigure({ + cfg: cfg as CoreConfig, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + intent: "update", + }); } - - return { cfg: next }; + const action = await prompter.select({ + message: "Matrix-js already configured. What do you want to do?", + options: [ + { value: "update", label: "Modify settings" }, + { value: "add-account", label: "Add account" }, + { value: "skip", label: "Skip (leave as-is)" }, + ], + initialValue: "update", + }); + if (action === "skip") { + return "skip"; + } + return await runMatrixConfigure({ + cfg: cfg as CoreConfig, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + intent: action === "add-account" ? "add-account" : "update", + }); }, dmPolicy, disable: (cfg) => ({ diff --git a/src/channels/plugins/onboarding-types.ts b/src/channels/plugins/onboarding-types.ts index 897487a49c6..fe75aeb0990 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: ChannelOnboardingConfigureContext, + ) => Promise; dmPolicy?: ChannelOnboardingDmPolicy; onAccountRecorded?: (accountId: string, options?: SetupChannelsOptions) => void; disable?: (cfg: OpenClawConfig) => OpenClawConfig; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 3a11c3ee27a..eaa6fc53397 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -8,6 +8,8 @@ import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { resolveTelegramAccount } from "../../telegram/accounts.js"; import { deleteTelegramUpdateOffset } from "../../telegram/update-offset-store.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; +import { applyAgentBindings, describeBinding } from "../agents.bindings.js"; +import { buildAgentSummaries } from "../agents.config.js"; import { setupChannels } from "../onboard-channels.js"; import type { ChannelChoice } from "../onboard-types.js"; import { @@ -111,6 +113,68 @@ export async function channelsAddCommand( } } + const bindTargets = selection + .map((channel) => ({ + channel, + accountId: accountIds[channel]?.trim(), + })) + .filter( + ( + value, + ): value is { + channel: ChannelChoice; + accountId: string; + } => Boolean(value.accountId), + ); + if (bindTargets.length > 0) { + const bindNow = await prompter.confirm({ + message: "Bind configured channel accounts to agents now?", + initialValue: true, + }); + if (bindNow) { + const agentSummaries = buildAgentSummaries(nextConfig); + const defaultAgentId = resolveDefaultAgentId(nextConfig); + for (const target of bindTargets) { + const targetAgentId = await prompter.select({ + message: `Route ${target.channel} account "${target.accountId}" to agent`, + options: agentSummaries.map((agent) => ({ + value: agent.id, + label: agent.isDefault ? `${agent.id} (default)` : agent.id, + })), + initialValue: defaultAgentId, + }); + const bindingResult = applyAgentBindings(nextConfig, [ + { + agentId: targetAgentId, + match: { channel: target.channel, accountId: target.accountId }, + }, + ]); + nextConfig = bindingResult.config; + if (bindingResult.added.length > 0 || bindingResult.updated.length > 0) { + await prompter.note( + [ + ...bindingResult.added.map((binding) => `Added: ${describeBinding(binding)}`), + ...bindingResult.updated.map((binding) => `Updated: ${describeBinding(binding)}`), + ].join("\n"), + "Routing bindings", + ); + } + if (bindingResult.conflicts.length > 0) { + await prompter.note( + [ + "Skipped bindings already claimed by another agent:", + ...bindingResult.conflicts.map( + (conflict) => + `- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`, + ), + ].join("\n"), + "Routing bindings", + ); + } + } + } + } + await writeConfigFile(nextConfig); await prompter.outro("Channels updated."); return; diff --git a/src/commands/onboard-channels.test.ts b/src/commands/onboard-channels.test.ts index d6c0669e4fd..fe7fa304b9f 100644 --- a/src/commands/onboard-channels.test.ts +++ b/src/commands/onboard-channels.test.ts @@ -1,7 +1,11 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { matrixPlugin } from "../../extensions/matrix-js/src/channel.js"; +import { setMatrixRuntime } from "../../extensions/matrix-js/src/runtime.js"; import type { OpenClawConfig } from "../config/config.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js"; import { setupChannels } from "./onboard-channels.js"; @@ -249,4 +253,111 @@ describe("setupChannels", () => { expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" })); expect(multiselect).not.toHaveBeenCalled(); }); + + it("offers add-account action for configured matrix-js channels", async () => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => + (homeDir ?? (() => "/tmp"))(), + }, + config: { + loadConfig: () => ({}), + }, + } as unknown as PluginRuntime); + setActivePluginRegistry( + createTestRegistry([{ pluginId: "matrix-js", plugin: matrixPlugin, source: "test" }]), + ); + const select = vi.fn( + async ({ message, options }: { message: string; options?: Array<{ value?: string }> }) => { + if (message === "Select channel (QuickStart)") { + return "matrix-js"; + } + if (message.includes("already configured")) { + expect(options?.some((option) => option.value === "add-account")).toBe(true); + return "skip"; + } + return "__done__"; + }, + ); + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + text: vi.fn(async () => "") as unknown as WizardPrompter["text"], + multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"], + }); + + const runtime = createExitThrowingRuntime(); + + await setupChannels( + { + channels: { + "matrix-js": { + homeserver: "https://matrix.example.org", + accessToken: "token", + }, + }, + } as OpenClawConfig, + runtime, + prompter, + { + skipConfirm: true, + quickstartDefaults: true, + promptAccountIds: true, + }, + ); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("already configured") }), + ); + }); + + it("uses configureInteractive for first-time plugin onboarding", async () => { + const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg })); + const plugin = { + ...createChannelTestPluginBase({ + id: "customchat", + label: "CustomChat", + docsPath: "/channels/customchat", + }), + onboarding: { + channel: "customchat", + getStatus: async () => ({ + channel: "customchat", + configured: false, + statusLines: ["CustomChat: not configured"], + }), + configure: async () => { + throw new Error("configure should not be called"); + }, + configureInteractive, + }, + }; + setActivePluginRegistry( + createTestRegistry([{ pluginId: "customchat", plugin, source: "test" }]), + ); + + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "customchat"; + } + return "__done__"; + }); + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + text: vi.fn(async () => "") as unknown as WizardPrompter["text"], + multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"], + }); + const runtime = createExitThrowingRuntime(); + + await setupChannels({} as OpenClawConfig, runtime, prompter, { + skipConfirm: true, + quickstartDefaults: true, + }); + + expect(configureInteractive).toHaveBeenCalledWith( + expect.objectContaining({ + configured: false, + label: "CustomChat", + }), + ); + }); }); diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 32510c29f39..71ce0c751d7 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -514,6 +514,27 @@ export async function setupChannels( 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), + }); + if (custom === "skip") { + return; + } + next = custom.cfg; + if (custom.accountId) { + recordAccount(channel, custom.accountId); + } + addSelection(channel); + await refreshStatus(channel); + return; + } const supportsDisable = Boolean( options?.allowDisable && (plugin?.config.setAccountEnabled || adapter?.disable), ); @@ -615,9 +636,33 @@ 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 (custom === "skip") { + return; + } + next = custom.cfg; + if (custom.accountId) { + recordAccount(channel, custom.accountId); + } + addSelection(channel); + await refreshStatus(channel); + return; + } if (configured) { await handleConfiguredChannel(channel, label); return;