From 156d2e623eb360b3a057c6933028cc2d872287fb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 08:20:41 +0800 Subject: [PATCH] refactor(channels): own account config mutations (#115970) --- .../plugins/account-config-mutation.test.ts | 302 ++++++++++++++++++ .../plugins/account-config-mutation.ts | 212 ++++++++++++ src/commands/channels.remove.test.ts | 144 ++++++++- src/commands/channels/add-mutators.ts | 19 -- src/commands/channels/add.ts | 109 +++---- src/commands/channels/remove.ts | 85 ++--- 6 files changed, 729 insertions(+), 142 deletions(-) create mode 100644 src/channels/plugins/account-config-mutation.test.ts create mode 100644 src/channels/plugins/account-config-mutation.ts diff --git a/src/channels/plugins/account-config-mutation.test.ts b/src/channels/plugins/account-config-mutation.test.ts new file mode 100644 index 000000000000..cc584270f57d --- /dev/null +++ b/src/channels/plugins/account-config-mutation.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { createChannelTestPluginBase } from "../../test-utils/channel-plugins.js"; +import { + applyPreparedChannelAccountConfiguration, + applyPreparedChannelAccountRemoval, + prepareChannelAccountConfiguration, + prepareChannelAccountRemoval, +} from "./account-config-mutation.js"; +import { defineChannelSetupContract } from "./setup-contract.js"; +import type { ChannelPlugin } from "./types.plugin.js"; + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +} as never; + +describe("channel account config mutations", () => { + it("prepares, validates, applies, and reports lifecycle changes in order", async () => { + const callOrder: string[] = []; + const beforePersistentEffect = vi.fn(async () => { + callOrder.push("authority"); + }); + const cfg = { + channels: { + "test-chat": { + enabled: true, + token: "old-token", + }, + }, + } satisfies OpenClawConfig; + const plugin = { + ...createChannelTestPluginBase({ id: "test-chat" }), + setup: { + singleAccountKeysToMove: ["token"], + prepareAccountConfigInput: ({ input }: { input: Record }) => { + callOrder.push("prepare"); + return { ...input, token: "prepared-token" }; + }, + validateInput: ({ input }: { input: Record }) => { + callOrder.push("validate"); + return input.token === "prepared-token" ? null : "input was not prepared"; + }, + applyAccountConfig: ({ cfg: inputCfg, accountId, input }) => { + callOrder.push("apply"); + const channel = inputCfg.channels?.["test-chat"] as + | { + enabled?: boolean; + accounts?: Record>; + } + | undefined; + return { + ...inputCfg, + channels: { + ...inputCfg.channels, + "test-chat": { + ...channel, + accounts: { + ...channel?.accounts, + [accountId]: { token: (input as { token: string }).token }, + }, + }, + }, + }; + }, + }, + lifecycle: { + onAccountConfigChanged: ({ prevCfg, nextCfg, accountId }) => { + callOrder.push("lifecycle"); + expect(prevCfg).toBe(cfg); + expect(accountId).toBe("work"); + expect(nextCfg.channels?.["test-chat"]).toMatchObject({ + accounts: { + default: { token: "old-token" }, + work: { token: "prepared-token" }, + }, + }); + }, + }, + } as ChannelPlugin; + + const prepared = await prepareChannelAccountConfiguration({ + cfg, + plugin, + requestedAccountId: "Work", + resolveInput: () => ({ token: "raw-token" }), + runtime, + beforePersistentEffect, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + + const applied = await applyPreparedChannelAccountConfiguration({ + cfg, + channel: "test-chat", + prepared: prepared.value, + runtime, + beforePersistentEffect, + }); + + expect(callOrder).toEqual([ + "authority", + "prepare", + "validate", + "apply", + "authority", + "lifecycle", + ]); + expect(applied.accountId).toBe("work"); + expect(applied.input).toEqual({ token: "prepared-token" }); + }); + + it("returns channel-owned setup parse errors before config mutation", async () => { + const applyAccountConfig = vi.fn(({ cfg }) => cfg); + const plugin = { + ...createChannelTestPluginBase({ id: "typed-chat" }), + setupContract: defineChannelSetupContract({ + fields: { + token: { + kind: "string", + cli: { flags: "--token ", description: "Bot token" }, + }, + }, + adapter: { applyAccountConfig }, + }), + } as ChannelPlugin; + + const prepared = await prepareChannelAccountConfiguration({ + cfg: {}, + plugin, + resolveInput: () => ({ unknownOption: true }), + runtime, + }); + + expect(prepared).toEqual({ + ok: false, + error: { + kind: "invalid-input", + message: "Unsupported setup option: unknownOption", + }, + }); + expect(applyAccountConfig).not.toHaveBeenCalled(); + }); + + it("normalizes plugin-resolved account IDs only at the config mutation boundary", async () => { + const applyAccountConfig = vi.fn(({ cfg }) => cfg); + const onAccountConfigChanged = vi.fn(); + const plugin = { + ...createChannelTestPluginBase({ id: "test-chat" }), + setup: { + resolveAccountId: () => "Work", + applyAccountConfig, + }, + lifecycle: { onAccountConfigChanged }, + } as ChannelPlugin; + + const prepared = await prepareChannelAccountConfiguration({ + cfg: {}, + plugin, + requestedAccountId: "ignored", + resolveInput: () => ({ token: "token-1" }), + runtime, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + + const applied = await applyPreparedChannelAccountConfiguration({ + cfg: {}, + channel: "test-chat", + prepared: prepared.value, + runtime, + }); + + expect(applyAccountConfig).toHaveBeenCalledWith({ + cfg: {}, + accountId: "work", + input: { token: "token-1" }, + }); + expect(onAccountConfigChanged).toHaveBeenCalledWith({ + prevCfg: {}, + nextCfg: {}, + accountId: "Work", + runtime, + }); + expect(applied.accountId).toBe("Work"); + }); + + it("does not resolve input when the channel has no account setup capability", async () => { + const resolveInput = vi.fn(() => { + throw new Error("input should stay lazy"); + }); + const plugin = createChannelTestPluginBase({ id: "read-only-chat" }) as ChannelPlugin; + + const prepared = await prepareChannelAccountConfiguration({ + cfg: {}, + plugin, + resolveInput, + runtime, + }); + + expect(prepared).toEqual({ + ok: false, + error: { kind: "unsupported" }, + }); + expect(resolveInput).not.toHaveBeenCalled(); + }); + + it("deletes an account and runs its owner lifecycle hook", async () => { + const onAccountRemoved = vi.fn(); + const cfg = { + channels: { + "test-chat": { + accounts: { + default: { token: "default-token" }, + work: { token: "work-token" }, + }, + }, + }, + } satisfies OpenClawConfig; + const plugin = { + ...createChannelTestPluginBase({ + id: "test-chat", + config: { + deleteAccount: ({ cfg: inputCfg, accountId }) => { + const channel = inputCfg.channels?.["test-chat"] as { + accounts?: Record>; + }; + const accounts = { ...channel.accounts }; + delete accounts[accountId]; + return { + ...inputCfg, + channels: { + ...inputCfg.channels, + "test-chat": { ...channel, accounts }, + }, + }; + }, + }, + }), + gateway: { startAccount: vi.fn() }, + lifecycle: { onAccountRemoved }, + } as ChannelPlugin; + const prepared = prepareChannelAccountRemoval({ + plugin, + accountId: "Work", + action: "delete", + }); + + expect(prepared).toMatchObject({ + accountId: "work", + accountKey: "work", + shouldStopRuntime: true, + }); + const result = await applyPreparedChannelAccountRemoval({ + cfg, + prepared, + runtime, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.nextConfig.channels?.["test-chat"]).toMatchObject({ + accounts: { default: { token: "default-token" } }, + }); + } + expect(onAccountRemoved).toHaveBeenCalledWith({ + prevCfg: cfg, + accountId: "work", + runtime, + }); + }); + + it("reports unsupported removal actions without running lifecycle hooks", async () => { + const onAccountConfigChanged = vi.fn(); + const plugin = { + ...createChannelTestPluginBase({ id: "test-chat" }), + lifecycle: { onAccountConfigChanged }, + } as ChannelPlugin; + const prepared = prepareChannelAccountRemoval({ + plugin, + accountId: "default", + action: "disable", + }); + + const result = await applyPreparedChannelAccountRemoval({ + cfg: {}, + prepared, + runtime, + }); + + expect(result).toEqual({ + ok: false, + error: { kind: "unsupported-action", action: "disable" }, + }); + expect(onAccountConfigChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/src/channels/plugins/account-config-mutation.ts b/src/channels/plugins/account-config-mutation.ts new file mode 100644 index 000000000000..be59c93360e9 --- /dev/null +++ b/src/channels/plugins/account-config-mutation.ts @@ -0,0 +1,212 @@ +import { err as resultError, ok, type Result } from "@openclaw/normalization-core/result"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import { resolveChannelSetupExecutionAdapter } from "./setup-contract.js"; +import { moveSingleAccountChannelSectionToDefaultAccount } from "./setup-helpers.js"; +import type { ChannelPlugin } from "./types.plugin.js"; +import type { ChannelId } from "./types.public.js"; + +export type ChannelAccountMutationPlugin = ChannelPlugin; + +type ChannelSetupExecutionAdapter = NonNullable< + ReturnType +>; + +type ChannelAccountConfigurationError = + | { kind: "unsupported" } + | { kind: "invalid-input"; message: string }; + +type PreparedChannelAccountConfiguration = { + plugin: ChannelPlugin; + setup: ChannelSetupExecutionAdapter; + applyAccountConfig: NonNullable; + accountId: string; + input: unknown; +}; + +export async function prepareChannelAccountConfiguration(params: { + cfg: OpenClawConfig; + plugin: ChannelPlugin; + requestedAccountId?: string; + resolveInput: () => unknown; + runtime: RuntimeEnv; + beforePersistentEffect?: () => Promise; +}): Promise> { + const setup = resolveChannelSetupExecutionAdapter(params.plugin); + if (!setup?.applyAccountConfig) { + return resultError({ kind: "unsupported" }); + } + + // Input resolution can perform plugin-owned reads. Keep it behind setup + // capability discovery so unsupported channels retain their existing failure path. + const rawInput = params.resolveInput(); + let input: unknown; + if (params.plugin.setupContract) { + const parsed = params.plugin.setupContract.parseInput(rawInput); + if (!parsed.ok) { + return resultError({ kind: "invalid-input", message: parsed.error }); + } + input = parsed.value; + } else { + input = rawInput; + } + + const accountId = + setup.resolveAccountId?.({ + cfg: params.cfg, + accountId: params.requestedAccountId, + input, + }) ?? normalizeAccountId(params.requestedAccountId); + if (setup.prepareAccountConfigInput) { + await params.beforePersistentEffect?.(); + input = await setup.prepareAccountConfigInput({ + cfg: params.cfg, + accountId, + input, + runtime: params.runtime, + }); + } + + const validationError = setup.validateInput?.({ + cfg: params.cfg, + accountId, + input, + }); + if (validationError) { + return resultError({ kind: "invalid-input", message: validationError }); + } + + return ok({ + plugin: params.plugin, + setup, + applyAccountConfig: setup.applyAccountConfig, + accountId, + input, + }); +} + +export async function applyPreparedChannelAccountConfiguration(params: { + cfg: OpenClawConfig; + channel: ChannelId; + prepared: PreparedChannelAccountConfiguration; + runtime: RuntimeEnv; + beforePersistentEffect?: () => Promise; +}): Promise<{ + nextConfig: OpenClawConfig; + accountId: string; + input: unknown; + afterAccountConfigWritten?: ChannelSetupExecutionAdapter["afterAccountConfigWritten"]; +}> { + const { accountId, applyAccountConfig, input, plugin, setup } = params.prepared; + const configAccountId = normalizeAccountId(accountId); + let nextConfig = params.cfg; + if (accountId !== DEFAULT_ACCOUNT_ID) { + nextConfig = moveSingleAccountChannelSectionToDefaultAccount({ + cfg: nextConfig, + channelKey: params.channel, + setupSurface: plugin.setup, + }); + } + nextConfig = applyAccountConfig({ + cfg: nextConfig, + accountId: configAccountId, + input, + }); + + // Lifecycle hooks can mutate owner state. The command supplies an authority + // check while retaining responsibility for the later config commit. + if (plugin.lifecycle?.onAccountConfigChanged) { + await params.beforePersistentEffect?.(); + await plugin.lifecycle.onAccountConfigChanged({ + prevCfg: params.cfg, + nextCfg: nextConfig, + accountId, + runtime: params.runtime, + }); + } + + return { + nextConfig, + accountId, + input, + ...(setup.afterAccountConfigWritten + ? { afterAccountConfigWritten: setup.afterAccountConfigWritten } + : {}), + }; +} + +type ChannelAccountRemovalAction = "delete" | "disable"; + +type PreparedChannelAccountRemoval = { + plugin: ChannelPlugin; + action: ChannelAccountRemovalAction; + accountId: string; + accountKey: string; + shouldStopRuntime: boolean; +}; + +type ChannelAccountRemovalError = { + kind: "unsupported-action"; + action: ChannelAccountRemovalAction; +}; + +export function prepareChannelAccountRemoval(params: { + plugin: ChannelPlugin; + accountId?: string; + action: ChannelAccountRemovalAction; +}): PreparedChannelAccountRemoval { + // normalizeAccountId maps omitted values to the literal default account, so + // the command's former nullish plugin-default fallback was unreachable. + const accountId = normalizeAccountId(params.accountId); + return { + plugin: params.plugin, + action: params.action, + accountId, + accountKey: accountId || DEFAULT_ACCOUNT_ID, + shouldStopRuntime: Boolean( + params.plugin.gateway?.startAccount || params.plugin.gateway?.logoutAccount, + ), + }; +} + +export async function applyPreparedChannelAccountRemoval(params: { + cfg: OpenClawConfig; + prepared: PreparedChannelAccountRemoval; + runtime: RuntimeEnv; +}): Promise> { + const { accountId, action, plugin } = params.prepared; + // Capability validation stays in apply: callers must preserve the historical + // runtime-stop ordering before reporting an unsupported mutation. + if (action === "delete") { + if (!plugin.config.deleteAccount) { + return resultError({ kind: "unsupported-action", action }); + } + const nextConfig = plugin.config.deleteAccount({ + cfg: { ...params.cfg }, + accountId, + }); + await plugin.lifecycle?.onAccountRemoved?.({ + prevCfg: params.cfg, + accountId, + runtime: params.runtime, + }); + return ok({ nextConfig }); + } + + if (!plugin.config.setAccountEnabled) { + return resultError({ kind: "unsupported-action", action }); + } + const nextConfig = plugin.config.setAccountEnabled({ + cfg: { ...params.cfg }, + accountId, + enabled: false, + }); + await plugin.lifecycle?.onAccountConfigChanged?.({ + prevCfg: params.cfg, + nextCfg: nextConfig, + accountId, + runtime: params.runtime, + }); + return ok({ nextConfig }); +} diff --git a/src/commands/channels.remove.test.ts b/src/commands/channels.remove.test.ts index ffe2b819340d..4243c0912752 100644 --- a/src/commands/channels.remove.test.ts +++ b/src/commands/channels.remove.test.ts @@ -184,7 +184,66 @@ describe("channelsRemoveCommand", () => { expect(runtime.exit).not.toHaveBeenCalled(); }); + it("keeps omitted removal on literal default when the plugin selects another default", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + "external-chat": { + enabled: true, + token: "token-1", + }, + }, + }, + }); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([ + createExternalChatCatalogEntry(), + ]); + const deletePlugin = createExternalChatDeletePlugin(); + const defaultAccountId = vi.fn(() => "work"); + const scopedPlugin = { + ...deletePlugin, + config: { + ...deletePlugin.config, + defaultAccountId, + }, + } as ChannelPlugin; + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( + createTestRegistry([ + { + pluginId: "@vendor/external-chat-plugin", + plugin: scopedPlugin, + source: "test", + }, + ]), + ); + + await channelsRemoveCommand( + { + channel: "external-chat", + delete: true, + }, + runtime, + { hasFlags: true }, + ); + + expect(scopedPlugin.config.deleteAccount).toHaveBeenCalledWith({ + cfg: { + channels: { + "external-chat": { + enabled: true, + token: "token-1", + }, + }, + }, + accountId: "default", + }); + expect(defaultAccountId).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith('Deleted external-chat account "default".'); + }); + it("stops an active gateway channel runtime before deleting a runtime-backed account", async () => { + const callOrder: string[] = []; configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config: { @@ -198,11 +257,24 @@ describe("channelsRemoveCommand", () => { }); const catalogEntry: ChannelPluginCatalogEntry = createExternalChatCatalogEntry(); catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); + const deletePlugin = createExternalChatDeletePlugin(); const scopedPlugin = { - ...createExternalChatDeletePlugin(), + ...deletePlugin, + config: { + ...deletePlugin.config, + deleteAccount: vi.fn((params) => { + callOrder.push("delete"); + return deletePlugin.config.deleteAccount!(params); + }), + }, gateway: { startAccount: vi.fn(), }, + lifecycle: { + onAccountRemoved: vi.fn(() => { + callOrder.push("lifecycle"); + }), + }, } as ChannelPlugin; vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( createTestRegistry([ @@ -213,6 +285,16 @@ describe("channelsRemoveCommand", () => { }, ]), ); + gatewayMocks.callGateway.mockImplementationOnce(async () => { + callOrder.push("stop"); + return { stopped: true }; + }); + configMocks.writeConfigFile.mockImplementationOnce(async () => { + callOrder.push("persist"); + }); + runtime.log.mockImplementationOnce(() => { + callOrder.push("output"); + }); await channelsRemoveCommand( { @@ -244,5 +326,65 @@ describe("channelsRemoveCommand", () => { }); const writtenConfig = firstWrittenChannelsConfig(); expect(writtenConfig?.channels?.["external-chat"]).toBeUndefined(); + expect(callOrder).toEqual(["stop", "delete", "lifecycle", "persist", "output"]); + }); + + it("stops a runtime-backed account before reporting an unsupported delete", async () => { + const callOrder: string[] = []; + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + "external-chat": { + enabled: true, + token: "token-1", + }, + }, + }, + }); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([ + createExternalChatCatalogEntry(), + ]); + const deletePlugin = createExternalChatDeletePlugin(); + const scopedPlugin = { + ...deletePlugin, + config: { + ...deletePlugin.config, + deleteAccount: undefined, + }, + gateway: { + startAccount: vi.fn(), + }, + } as ChannelPlugin; + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( + createTestRegistry([ + { + pluginId: "@vendor/external-chat-plugin", + plugin: scopedPlugin, + source: "test", + }, + ]), + ); + gatewayMocks.callGateway.mockImplementationOnce(async () => { + callOrder.push("stop"); + return { stopped: true }; + }); + runtime.error.mockImplementationOnce(() => { + callOrder.push("error"); + }); + + await channelsRemoveCommand( + { + channel: "external-chat", + account: "default", + delete: true, + }, + runtime, + { hasFlags: true }, + ); + + expect(callOrder).toEqual(["stop", "error"]); + expect(configMocks.writeConfigFile).not.toHaveBeenCalled(); + expect(runtime.exit).toHaveBeenCalledWith(1); }); }); diff --git a/src/commands/channels/add-mutators.ts b/src/commands/channels/add-mutators.ts index 8da8423f901e..df5ad7d6698c 100644 --- a/src/commands/channels/add-mutators.ts +++ b/src/commands/channels/add-mutators.ts @@ -21,22 +21,3 @@ export function applyAccountName(params: { const apply = plugin ? resolveChannelSetupExecutionAdapter(plugin)?.applyAccountName : undefined; return apply ? apply({ cfg: params.cfg, accountId, name: params.name }) : params.cfg; } - -/** Delegate account config mutation to the channel plugin setup contract. */ -export function applyChannelAccountConfig(params: { - cfg: OpenClawConfig; - channel: ChatChannel; - accountId: string; - input: unknown; - plugin?: ChannelPlugin; -}): OpenClawConfig { - const accountId = normalizeAccountId(params.accountId); - const plugin = params.plugin ?? getChannelPlugin(params.channel); - const apply = plugin - ? resolveChannelSetupExecutionAdapter(plugin)?.applyAccountConfig - : undefined; - if (!apply) { - return params.cfg; - } - return apply({ cfg: params.cfg, accountId, input: params.input }); -} diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index f0fd2cfb3184..e1c8e46aee79 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -1,13 +1,15 @@ // Implements guided and non-interactive `openclaw channels add` account setup. import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + applyPreparedChannelAccountConfiguration, + type ChannelAccountMutationPlugin, + prepareChannelAccountConfiguration, +} from "../../channels/plugins/account-config-mutation.js"; import { getBundledChannelSetupPlugin } from "../../channels/plugins/bundled.js"; import { resolveChannelSetupCliOptionMetadata } from "../../channels/plugins/cli-add-options.js"; import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js"; import { getLoadedChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; -import { resolveChannelSetupExecutionAdapter } from "../../channels/plugins/setup-contract.js"; -import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; -import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.public.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { @@ -18,12 +20,10 @@ import type { OpenClawConfig } from "../../config/config.js"; import { parseStrictNonNegativeInteger } from "../../infra/parse-finite-number.js"; import { commitConfigWithPendingPluginInstalls } from "../../plugins/install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { WizardCancelledError } from "../../wizard/prompts.js"; -import { applyChannelAccountConfig } from "./add-mutators.js"; import { channelLabel } from "./runtime-label.js"; import { requireValidConfigFileSnapshot, shouldUseWizard } from "./shared.js"; @@ -178,7 +178,7 @@ async function channelsAddCommandImpl( const loadScopedPlugin = async ( channelId: ChannelId, pluginId?: string, - ): Promise => { + ): Promise => { const existing = getLoadedChannelPlugin(channelId); if (existing?.setupContract?.applyAccountConfig || existing?.setup?.applyAccountConfig) { return existing; @@ -251,8 +251,7 @@ async function channelsAddCommandImpl( } const plugin = await loadScopedPlugin(channel, catalogEntry?.pluginId); - const setup = plugin ? resolveChannelSetupExecutionAdapter(plugin) : undefined; - if (!plugin || !setup?.applyAccountConfig) { + if (!plugin) { runtime.error( `${formatUnsupportedChannelActionMessage({ channel, @@ -262,71 +261,39 @@ async function channelsAddCommandImpl( runtime.exit(1); return; } - let input: unknown; - if (plugin.setupContract) { - const parsed = plugin.setupContract.parseInput(buildChannelOwnedSetupInput(opts)); - if (!parsed.ok) { - runtime.error(parsed.error); - runtime.exit(1); - return; - } - input = parsed.value; - } else { - input = buildChannelSetupInput(opts); - } - const accountId = - setup.resolveAccountId?.({ - cfg: nextConfig, - accountId: opts.account, - input, - }) ?? normalizeAccountId(opts.account); - if (setup.prepareAccountConfigInput) { - await params?.beforePersistentEffect?.(); - input = await setup.prepareAccountConfigInput({ - cfg: nextConfig, - accountId, - input, - runtime, - }); - } - - const validationError = setup.validateInput?.({ + const prepared = await prepareChannelAccountConfiguration({ cfg: nextConfig, - accountId, - input, + plugin, + requestedAccountId: opts.account, + resolveInput: () => + plugin.setupContract ? buildChannelOwnedSetupInput(opts) : buildChannelSetupInput(opts), + runtime, + ...(params?.beforePersistentEffect + ? { beforePersistentEffect: params.beforePersistentEffect } + : {}), }); - if (validationError) { - runtime.error(validationError); + if (!prepared.ok) { + runtime.error( + prepared.error.kind === "unsupported" + ? `${formatUnsupportedChannelActionMessage({ + channel, + action: "non-interactive add", + })} Run ${formatCliCommand("openclaw channels add")} with no flags for guided setup.` + : prepared.error.message, + ); runtime.exit(1); return; } - - const prevConfig = nextConfig; - - if (accountId !== DEFAULT_ACCOUNT_ID) { - nextConfig = moveSingleAccountChannelSectionToDefaultAccount({ - cfg: nextConfig, - channelKey: channel, - setupSurface: plugin.setup, - }); - } - - nextConfig = applyChannelAccountConfig({ + const applied = await applyPreparedChannelAccountConfiguration({ cfg: nextConfig, channel, - accountId, - input, - plugin, + prepared: prepared.value, + runtime, + ...(params?.beforePersistentEffect + ? { beforePersistentEffect: params.beforePersistentEffect } + : {}), }); - if (plugin.lifecycle?.onAccountConfigChanged) { - await params?.beforePersistentEffect?.(); - await plugin.lifecycle.onAccountConfigChanged({ - prevCfg: prevConfig, - nextCfg: nextConfig, - accountId, - runtime, - }); - } + nextConfig = applied.nextConfig; await params?.beforePersistentEffect?.(); const committed = await commitConfigWithPendingPluginInstalls({ @@ -342,21 +309,23 @@ async function channelsAddCommandImpl( logger: { warn: (message) => runtime.log(message) }, }); } - runtime.log(`Added ${plugin.meta.label ?? channelLabel(channel)} account "${accountId}".`); - const afterAccountConfigWritten = setup.afterAccountConfigWritten; + runtime.log( + `Added ${plugin.meta.label ?? channelLabel(channel)} account "${applied.accountId}".`, + ); + const afterAccountConfigWritten = applied.afterAccountConfigWritten; if (afterAccountConfigWritten) { const { runCollectedChannelOnboardingPostWriteHooks } = await loadOnboardChannels(); await runCollectedChannelOnboardingPostWriteHooks({ hooks: [ { channel, - accountId, + accountId: applied.accountId, run: async ({ cfg: writtenCfg, runtime: hookRuntime }) => await afterAccountConfigWritten({ previousCfg: cfg, cfg: writtenCfg, - accountId, - input, + accountId: applied.accountId, + input: applied.input, runtime: hookRuntime, }), }, diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index 170ba6a5159b..1d1dcb7490d4 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -1,9 +1,12 @@ // Implements guided and non-interactive disable/delete for channel accounts. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; +import { + applyPreparedChannelAccountRemoval, + type ChannelAccountMutationPlugin, + prepareChannelAccountRemoval, +} from "../../channels/plugins/account-config-mutation.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js"; -import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { formatUnknownChannelMessage, @@ -30,7 +33,7 @@ export type ChannelsRemoveOptions = { function listAccountIds( cfg: OpenClawConfig, channel: ChatChannel, - pluginInput?: ChannelPlugin, + pluginInput?: ChannelAccountMutationPlugin, ): string[] { let plugin = pluginInput; plugin ??= getChannelPlugin(channel); @@ -44,10 +47,10 @@ async function stopGatewayRuntimeBeforeRemove(params: { cfg: OpenClawConfig; channel: ChatChannel; accountId: string; - plugin: ChannelPlugin; + shouldStopRuntime: boolean; runtime: RuntimeEnv; }) { - if (!params.plugin.gateway?.startAccount && !params.plugin.gateway?.logoutAccount) { + if (!params.shouldStopRuntime) { return; } try { @@ -185,57 +188,35 @@ export async function channelsRemoveCommand( return; } const resolvedChannelId: ChatChannel = resolvedChannel; - const resolvedAccountId = - normalizeAccountId(accountId) ?? resolveChannelDefaultAccountId({ plugin, cfg }); - const accountKey = resolvedAccountId || DEFAULT_ACCOUNT_ID; + const preparedRemoval = prepareChannelAccountRemoval({ + plugin, + accountId, + action: deleteConfig ? "delete" : "disable", + }); await stopGatewayRuntimeBeforeRemove({ cfg, channel: resolvedChannelId, - accountId: accountKey, - plugin, + accountId: preparedRemoval.accountKey, + shouldStopRuntime: preparedRemoval.shouldStopRuntime, runtime, }); - let next = { ...cfg }; - const prevCfg = cfg; - if (deleteConfig) { - if (!plugin.config.deleteAccount) { - runtime.error( - `${formatUnsupportedChannelActionMessage({ channel, action: "delete" })} Use ${formatCliCommand("openclaw channels remove --channel " + channel)} to disable it without deleting config.`, - ); - runtime.exit(1); - return; - } - next = plugin.config.deleteAccount({ - cfg: next, - accountId: resolvedAccountId, - }); - await plugin.lifecycle?.onAccountRemoved?.({ - prevCfg, - accountId: resolvedAccountId, - runtime, - }); - } else { - if (!plugin.config.setAccountEnabled) { - runtime.error( - `${formatUnsupportedChannelActionMessage({ channel, action: "disable" })} Use ${formatCliCommand("openclaw channels remove --channel " + channel + " --delete")} only if you want to remove config.`, - ); - runtime.exit(1); - return; - } - next = plugin.config.setAccountEnabled({ - cfg: next, - accountId: resolvedAccountId, - enabled: false, - }); - await plugin.lifecycle?.onAccountConfigChanged?.({ - prevCfg, - nextCfg: next, - accountId: resolvedAccountId, - runtime, - }); + const removal = await applyPreparedChannelAccountRemoval({ + cfg, + prepared: preparedRemoval, + runtime, + }); + if (!removal.ok) { + runtime.error( + removal.error.action === "delete" + ? `${formatUnsupportedChannelActionMessage({ channel, action: "delete" })} Use ${formatCliCommand("openclaw channels remove --channel " + channel)} to disable it without deleting config.` + : `${formatUnsupportedChannelActionMessage({ channel, action: "disable" })} Use ${formatCliCommand("openclaw channels remove --channel " + channel + " --delete")} only if you want to remove config.`, + ); + runtime.exit(1); + return; } + let next = removal.value.nextConfig; const shouldMovePluginInstalls = Boolean( next.plugins?.installs && Object.keys(next.plugins.installs).length > 0, @@ -268,14 +249,14 @@ export async function channelsRemoveCommand( if (useWizard && prompter) { await prompter.outro( deleteConfig - ? `Deleted ${channelLabel(resolvedChannelId)} account "${accountKey}".` - : `Disabled ${channelLabel(resolvedChannelId)} account "${accountKey}".`, + ? `Deleted ${channelLabel(resolvedChannelId)} account "${preparedRemoval.accountKey}".` + : `Disabled ${channelLabel(resolvedChannelId)} account "${preparedRemoval.accountKey}".`, ); } else { runtime.log( deleteConfig - ? `Deleted ${channelLabel(resolvedChannelId)} account "${accountKey}".` - : `Disabled ${channelLabel(resolvedChannelId)} account "${accountKey}".`, + ? `Deleted ${channelLabel(resolvedChannelId)} account "${preparedRemoval.accountKey}".` + : `Disabled ${channelLabel(resolvedChannelId)} account "${preparedRemoval.accountKey}".`, ); } }