refactor(channels): own account config mutations (#115970)

This commit is contained in:
Vincent Koc
2026-07-30 08:20:41 +08:00
committed by GitHub
parent 9991f49ebb
commit 156d2e623e
6 changed files with 729 additions and 142 deletions
@@ -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<string, unknown> }) => {
callOrder.push("prepare");
return { ...input, token: "prepared-token" };
},
validateInput: ({ input }: { input: Record<string, unknown> }) => {
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<string, Record<string, unknown>>;
}
| 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 <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<string, Record<string, unknown>>;
};
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();
});
});
@@ -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<typeof resolveChannelSetupExecutionAdapter>
>;
type ChannelAccountConfigurationError =
| { kind: "unsupported" }
| { kind: "invalid-input"; message: string };
type PreparedChannelAccountConfiguration = {
plugin: ChannelPlugin;
setup: ChannelSetupExecutionAdapter;
applyAccountConfig: NonNullable<ChannelSetupExecutionAdapter["applyAccountConfig"]>;
accountId: string;
input: unknown;
};
export async function prepareChannelAccountConfiguration(params: {
cfg: OpenClawConfig;
plugin: ChannelPlugin;
requestedAccountId?: string;
resolveInput: () => unknown;
runtime: RuntimeEnv;
beforePersistentEffect?: () => Promise<void>;
}): Promise<Result<PreparedChannelAccountConfiguration, ChannelAccountConfigurationError>> {
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<void>;
}): 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<Result<{ nextConfig: OpenClawConfig }, ChannelAccountRemovalError>> {
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 });
}
+143 -1
View File
@@ -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);
});
});
-19
View File
@@ -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 });
}
+39 -70
View File
@@ -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<ChannelPlugin | undefined> => {
): Promise<ChannelAccountMutationPlugin | undefined> => {
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,
}),
},
+33 -52
View File
@@ -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}".`,
);
}
}