diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 81c4f178dfd4..3c801056e0df 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -106,7 +106,7 @@ If a channel plugin needs to be installed during a flag-driven add command, Open Both guided setup and flag-driven setup pass through the selected channel's parser, validation, account resolution, config writer, and post-write hooks. Unsupported flags fail with the owning channel's setup error instead of being accepted through a global input bag. -When you run `openclaw channels add` with no direct account, credential, or channel-config flags, the interactive wizard can prompt. A positional channel id and `--channel ` both preselect that channel without bypassing guidance: +When you run `openclaw channels add` with no direct account, credential, or channel-config flags, the interactive wizard can prompt. A positional channel id and `--channel ` both open that channel's guided setup immediately. Back returns to the full channel picker: ```bash openclaw channels add telegram diff --git a/src/channels/registry-lookup.ts b/src/channels/registry-lookup.ts index d09afc8e0ff1..8ef81b6bea75 100644 --- a/src/channels/registry-lookup.ts +++ b/src/channels/registry-lookup.ts @@ -33,7 +33,6 @@ function setLookupEntry( key: string | undefined, entry: RegisteredChannelPluginEntry, ): void { - // First writer wins so canonical ids keep priority over later aliases. if (key && !map.has(key)) { map.set(key, entry); } @@ -60,6 +59,9 @@ function buildRegisteredChannelPluginLookup(): RegisteredChannelPluginLookup { const id = normalizeOptionalLowercaseString(entry.plugin.id ?? ""); setLookupEntry(byKey, id, entry); setLookupEntry(byId, id, entry); + } + // Canonical ids are registered first so aliases can never shadow them. + for (const entry of entries) { for (const alias of entry.plugin.meta?.aliases ?? []) { setLookupEntry(byKey, normalizeOptionalLowercaseString(alias), entry); } diff --git a/src/channels/registry.helpers.test.ts b/src/channels/registry.helpers.test.ts index df6505000d32..84c9207fbd2a 100644 --- a/src/channels/registry.helpers.test.ts +++ b/src/channels/registry.helpers.test.ts @@ -94,6 +94,20 @@ describe("channel registry helpers", () => { expect(normalizeAnyChannelId("qq")).toBe("qqbot"); }); + it("prefers an exact channel id over an earlier plugin alias", () => { + const aliasOwner = createRegistryWithRegisteredChannel("alias-owner", ["exact-id"]).channels[0]; + const exactOwner = createRegistryWithRegisteredChannel("exact-id").channels[0]; + setActivePluginRegistry( + createTestRegistry([ + expectDefined(aliasOwner, "alias owner test channel"), + expectDefined(exactOwner, "exact owner test channel"), + ]), + ); + + expect(normalizeAnyChannelId("exact-id")).toBe("exact-id"); + expect(normalizeAnyChannelIdLight("exact-id")).toBe("exact-id"); + }); + it("rebuilds registered channel lookups when pinned-empty fallback active registry changes", () => { const startupRegistry = createEmptyPluginRegistry(); setActivePluginRegistry(startupRegistry); diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index bb8cfc4b092f..01493481d306 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -560,6 +560,48 @@ describe("channelsAddCommand", () => { expect(setupOptions().finishAfterInitialSelection).toBe(true); }); + it("opens an exact channel id instead of an earlier plugin alias", async () => { + const config: OpenClawConfig = { channels: {} }; + const aliasOwner = createChannelTestPluginBase({ + id: "alias-owner", + label: "Alias Owner", + }); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "alias-owner", + plugin: { + ...aliasOwner, + meta: { ...aliasOwner.meta, aliases: ["exact-id"] }, + }, + source: "test", + }, + ]), + ); + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + sourceConfig: config, + config, + }); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + ...createExternalChatCatalogEntry(), + id: "exact-id", + meta: { + ...createExternalChatCatalogEntry().meta, + id: "exact-id", + label: "Exact ID", + selectionLabel: "Exact ID", + }, + }, + ]); + + await channelsAddCommand({ channel: "exact-id" }, runtime, { hasFlags: false }); + + expect(setupOptions().initialSelection).toEqual(["exact-id"]); + expect(setupOptions().finishAfterInitialSelection).toBe(true); + }); + it("exits quietly when guided channel setup is cancelled", async () => { const { WizardCancelledError } = await import("../wizard/prompts.js"); configMocks.readConfigFileSnapshot.mockResolvedValue({ diff --git a/src/commands/channels/add-wizard.ts b/src/commands/channels/add-wizard.ts index 2dee63576429..f78d12e8ef6d 100644 --- a/src/commands/channels/add-wizard.ts +++ b/src/commands/channels/add-wizard.ts @@ -39,12 +39,13 @@ export async function resolveInitialWizardChannel( installedPlugins: listActiveChannelSetupPlugins(), workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), }); - return resolved.entries.find( - (entry) => - normalizeOptionalLowercaseString(entry.id) === normalized || + return ( + resolved.entries.find((entry) => normalizeOptionalLowercaseString(entry.id) === normalized) ?? + resolved.entries.find((entry) => (entry.meta.aliases ?? []).some( (alias) => normalizeOptionalLowercaseString(alias) === normalized, ), + ) )?.id; } diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 833af1217d69..e9eeda4f76e5 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -830,6 +830,130 @@ describe("setupChannels workspace shadow exclusion", () => { }, ); + it("enters an explicitly targeted channel before the generic setup prompts", async () => { + const promptOrder: string[] = []; + const configureInteractive = vi.fn(async ({ cfg }) => { + promptOrder.push("channel setup"); + return { + cfg: { + ...cfg, + channels: { ...cfg.channels, "external-chat": { token: "configured" } }, + }, + accountId: "external-account", + }; + }); + const externalChatPlugin = makeSetupPlugin({ + id: "external-chat", + label: "External Chat", + setupWizard: { + channel: "external-chat", + getStatus: vi.fn(async () => ({ + channel: "external-chat", + configured: false, + statusLines: [], + })), + configure: vi.fn(), + configureInteractive, + } as ChannelSetupPlugin["setupWizard"], + }); + resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); + listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); + const confirm = vi.fn(async () => { + promptOrder.push("setup confirmation"); + return true; + }); + const select = vi.fn(async () => { + promptOrder.push("channel picker"); + return "__done__"; + }); + + const result = await setupChannels( + {} as OpenClawConfig, + {} as never, + { + confirm, + note: vi.fn(async () => undefined), + select, + } as never, + { + initialSelection: ["external-chat"], + finishAfterInitialSelection: true, + deferStatusUntilSelection: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(promptOrder).toEqual(["channel setup"]); + expect(confirm).not.toHaveBeenCalled(); + expect(configureInteractive).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + channels: { "external-chat": { token: "configured" } }, + plugins: { entries: { "external-chat": { enabled: true } } }, + }); + }); + + it("returns targeted channel setup Back navigation to the channel picker", async () => { + const promptOrder: string[] = []; + const configureInteractive = vi.fn(async ({ prompter }) => { + promptOrder.push("channel setup"); + await prompter.text({ message: "External Chat token" }); + return { + cfg: { + channels: { "external-chat": { token: "should-not-apply" } }, + } as OpenClawConfig, + accountId: "external-account", + }; + }); + const externalChatPlugin = makeSetupPlugin({ + id: "external-chat", + label: "External Chat", + setupWizard: { + channel: "external-chat", + getStatus: vi.fn(async () => ({ + channel: "external-chat", + configured: false, + statusLines: [], + })), + configure: vi.fn(), + configureInteractive, + } as ChannelSetupPlugin["setupWizard"], + }); + resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); + listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); + const select = vi.fn(async () => { + promptOrder.push("channel picker"); + return "__done__"; + }); + const cfg = { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig; + + const result = await setupChannels( + cfg, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select, + text: vi.fn(async () => { + throw new WizardNavigationError("back"); + }), + } as never, + { + initialSelection: ["external-chat"], + finishAfterInitialSelection: true, + deferStatusUntilSelection: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(promptOrder).toEqual(["channel setup", "channel picker"]); + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Select a channel", + }), + ); + expect(result).toEqual(cfg); + }); + it("returns custom channel setup to channel selection when its first prompt goes back", async () => { const configureInteractive = vi.fn(async ({ prompter }) => { await prompter.text({ message: "Custom channel token" }); diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index c1f579e01a5b..a4f353774ecb 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -236,12 +236,17 @@ export async function setupChannels( await prompter.note(statusLines.join("\n"), t("wizard.channels.statusTitle")); } - const shouldConfigure = options?.skipConfirm - ? true - : await prompter.confirm({ - message: t("wizard.channels.setupConfirm"), - initialValue: true, - }); + const targetedChannel = + options?.finishAfterInitialSelection && options.initialSelection?.length === 1 + ? options.initialSelection[0] + : undefined; + const shouldConfigure = + options?.skipConfirm || targetedChannel + ? true + : await prompter.confirm({ + message: t("wizard.channels.setupConfirm"), + initialValue: true, + }); if (!shouldConfigure) { return cfg; } @@ -268,10 +273,6 @@ export async function setupChannels( const selection: ChannelChoice[] = []; let finishSetupRequested = false; - const targetedChannel = - options?.finishAfterInitialSelection && options.initialSelection?.length === 1 - ? options.initialSelection[0] - : undefined; const addSelection = (channel: ChannelChoice) => { if (!selection.includes(channel)) { selection.push(channel); @@ -898,9 +899,13 @@ export async function setupChannels( return "done"; }; - if (targetedChannel) { - await handleChannelChoice(targetedChannel); - } else if (options?.quickstartDefaults) { + // Targeted setup finishes after success, but Back must re-enter the shared + // picker instead of ending the wizard. + const targetedSetupReturnedToPicker = targetedChannel + ? (await handleChannelChoice(targetedChannel)) === "retry_selection" + : false; + + if (!targetedChannel && options?.quickstartDefaults) { const skipValue = "__skip__" as const; const quickstartInitialValue = options?.initialSelection?.[0] ?? skipValue; while (true) { @@ -931,7 +936,7 @@ export async function setupChannels( break; } } - } else { + } else if (!targetedChannel || targetedSetupReturnedToPicker) { const doneValue = "__done__" as const; const initialValue = options?.initialSelection?.[0] ?? quickstartDefault; while (true) {