diff --git a/src/commands/configure.channels.test.ts b/src/commands/configure.channels.test.ts index db41eda7ff2..4245cc79f5e 100644 --- a/src/commands/configure.channels.test.ts +++ b/src/commands/configure.channels.test.ts @@ -24,7 +24,7 @@ import { removeChannelConfigWizard } from "./configure.channels.js"; describe("removeChannelConfigWizard", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); confirm.mockResolvedValue(true); }); @@ -34,6 +34,8 @@ describe("removeChannelConfigWizard", () => { await removeChannelConfigWizard( { channels: { + defaults: { groupPolicy: "open" }, + modelByChannel: { openai: { telegram: "gpt-5.4" } }, twitch: {}, unknown: {}, telegram: {}, @@ -80,6 +82,26 @@ describe("removeChannelConfigWizard", () => { ); }); + it("preserves channel-wide defaults when deleting the last channel block", async () => { + select.mockResolvedValueOnce("telegram").mockResolvedValueOnce("done"); + + const next = await removeChannelConfigWizard( + { + channels: { + defaults: { groupPolicy: "open" }, + modelByChannel: { openai: { telegram: "gpt-5.4" } }, + telegram: { token: "secret" }, + }, + } as never, + {} as never, + ); + + expect(next.channels).toEqual({ + defaults: { groupPolicy: "open" }, + modelByChannel: { openai: { telegram: "gpt-5.4" } }, + }); + }); + it("sanitizes unknown channel keys before rendering prompts", async () => { const unsafeChannel = "bad\u001B[31m\nkey\u0007"; select.mockResolvedValueOnce(unsafeChannel).mockResolvedValueOnce("done"); diff --git a/src/commands/configure.channels.ts b/src/commands/configure.channels.ts index 20c6d05ca36..15d36362b85 100644 --- a/src/commands/configure.channels.ts +++ b/src/commands/configure.channels.ts @@ -14,6 +14,8 @@ type ConfiguredChannelRemovalChoice = { label: string; }; +const RESERVED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); + function listConfiguredChannelRemovalChoices( cfg: OpenClawConfig, ): ConfiguredChannelRemovalChoice[] { @@ -23,6 +25,7 @@ function listConfiguredChannelRemovalChoices( } const labelsById = new Map(listChatChannels().map((meta) => [meta.id, meta.label])); return Object.keys(channels) + .filter((id) => !RESERVED_CHANNEL_CONFIG_KEYS.has(id)) .map((id) => ({ id, label: labelsById.get(id) ?? formatUnknownChannelRemovalLabel(id), diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 2675394af26..b0b00393c9b 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -72,7 +72,8 @@ vi.mock("../commands/channel-setup/plugin-install.js", () => ({ })); vi.mock("../commands/channel-setup/registry.js", () => ({ - resolveChannelSetupWizardAdapterForPlugin: () => undefined, + resolveChannelSetupWizardAdapterForPlugin: (plugin?: { setupWizard?: unknown }) => + plugin?.setupWizard, })); vi.mock("../commands/channel-setup/trusted-catalog.js", () => ({ @@ -226,4 +227,91 @@ describe("setupChannels workspace shadow exclusion", () => { expect(getChannelSetupPlugin).not.toHaveBeenCalled(); expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); }); + + it("loads the selected bundled catalog plugin without writing explicit plugin enablement", async () => { + const setupWizard = { + channel: "telegram", + getStatus: vi.fn(async () => ({ + channel: "telegram", + configured: false, + statusLines: [], + })), + configure: vi.fn(async ({ cfg }: { cfg: Record }) => ({ + cfg: { + ...cfg, + channels: { + telegram: { token: "secret" }, + }, + }, + })), + }; + const telegramPlugin = { + id: "telegram", + meta: { id: "telegram", label: "Telegram", blurb: "" }, + capabilities: {}, + config: { + resolveAccount: vi.fn(() => ({})), + }, + setupWizard, + }; + const installedCatalogEntry = { + id: "telegram", + pluginId: "telegram", + origin: "bundled", + meta: { id: "telegram", label: "Telegram", blurb: "" }, + }; + resolveChannelSetupEntries.mockReturnValue({ + entries: [ + { + id: "telegram", + meta: { id: "telegram", label: "Telegram", blurb: "" }, + }, + ], + installedCatalogEntries: [installedCatalogEntry], + installableCatalogEntries: [], + installedCatalogById: new Map([["telegram", installedCatalogEntry]]), + installableCatalogById: new Map(), + }); + loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ + channels: [{ plugin: telegramPlugin }], + channelSetups: [], + }); + const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__"); + + const next = await setupChannels( + {} as never, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select, + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(1); + expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + pluginId: "telegram", + workspaceDir: "/tmp/openclaw-workspace", + }), + ); + expect(getChannelSetupPlugin).not.toHaveBeenCalled(); + expect(collectChannelStatus).not.toHaveBeenCalled(); + expect(setupWizard.configure).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: {}, + }), + ); + expect(next).toEqual({ + channels: { + telegram: { token: "secret" }, + }, + }); + }); }); diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index ac378c21684..65481dc2ed3 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -512,7 +512,7 @@ export async function setupChannels( } await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId); await refreshStatus(channel); - } else if (installedCatalogEntry && installedCatalogEntry.origin !== "bundled") { + } else if (installedCatalogEntry) { const plugin = await loadScopedChannelPlugin(channel, installedCatalogEntry.pluginId); if (!plugin) { await prompter.note(`${channel} plugin not available.`, "Channel setup");