mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(channels): own account config mutations (#115970)
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user