From fa521154ff978f4503b5db5b908072802ea5393b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 01:29:25 +0000 Subject: [PATCH] fix: show disabled channels in onboarding picker --- CHANGELOG.md | 2 +- src/channels/plugins/onboarding-types.ts | 1 + src/commands/configure.wizard.ts | 8 +- src/commands/onboard-channels.test.ts | 57 ++++++++++++ src/commands/onboard-channels.ts | 112 ++++++++++++++++++----- 5 files changed, 156 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 250db0734..a03039e26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf. - Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows. -- Onboarding: switch channels setup to a single-select loop with modify/disable/delete actions per channel. +- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker. - TUI: show provider/model labels for the active session and default model. - Heartbeat: add per-agent heartbeat configuration and multi-agent docs example. - Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. diff --git a/src/channels/plugins/onboarding-types.ts b/src/channels/plugins/onboarding-types.ts index bf73fa11b..19840536b 100644 --- a/src/channels/plugins/onboarding-types.ts +++ b/src/channels/plugins/onboarding-types.ts @@ -15,6 +15,7 @@ export type SetupChannelsOptions = { promptWhatsAppAccountId?: boolean; onWhatsAppAccountId?: (accountId: string) => void; forceAllowFromChannels?: ChannelId[]; + skipStatusNote?: boolean; skipDmPolicyPrompt?: boolean; skipConfirm?: boolean; quickstartDefaults?: boolean; diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 77c9fadf2..4c5f5c874 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -31,7 +31,7 @@ import { } from "./configure.shared.js"; import { healthCommand } from "./health.js"; import { formatHealthCheckFailure } from "./health-format.js"; -import { setupChannels } from "./onboard-channels.js"; +import { noteChannelStatus, setupChannels } from "./onboard-channels.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, @@ -331,11 +331,14 @@ export async function runConfigureWizard( } if (selected.includes("channels")) { + await noteChannelStatus({ cfg: nextConfig, prompter }); const channelMode = await promptChannelMode(runtime); if (channelMode === "configure") { nextConfig = await setupChannels(nextConfig, runtime, prompter, { allowDisable: true, allowSignalInstall: true, + skipConfirm: true, + skipStatusNote: true, }); } else { nextConfig = await removeChannelConfigWizard(nextConfig, runtime); @@ -450,11 +453,14 @@ export async function runConfigureWizard( } if (choice === "channels") { + await noteChannelStatus({ cfg: nextConfig, prompter }); const channelMode = await promptChannelMode(runtime); if (channelMode === "configure") { nextConfig = await setupChannels(nextConfig, runtime, prompter, { allowDisable: true, allowSignalInstall: true, + skipConfirm: true, + skipStatusNote: true, }); } else { nextConfig = await removeChannelConfigWizard(nextConfig, runtime); diff --git a/src/commands/onboard-channels.test.ts b/src/commands/onboard-channels.test.ts index ea8de8f0a..e4e68fb56 100644 --- a/src/commands/onboard-channels.test.ts +++ b/src/commands/onboard-channels.test.ts @@ -125,4 +125,61 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); expect(text).not.toHaveBeenCalled(); }); + + it("adds disabled hint to channel selection when a channel is disabled", async () => { + let selectionCount = 0; + const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => { + if (message === "Select a channel") { + selectionCount += 1; + const opts = options as Array<{ value: string; hint?: string }>; + const telegram = opts.find((opt) => opt.value === "telegram"); + expect(telegram?.hint).toContain("disabled"); + return selectionCount === 1 ? "telegram" : "__done__"; + } + if (message.includes("already configured")) return "skip"; + return "__done__"; + }); + const multiselect = vi.fn(async () => { + throw new Error("unexpected multiselect"); + }); + const prompter: WizardPrompter = { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select, + multiselect, + text: vi.fn(async () => ""), + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + }; + + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + await setupChannels( + { + channels: { + telegram: { + botToken: "token", + enabled: false, + }, + }, + } as ClawdbotConfig, + runtime, + prompter, + { + skipConfirm: true, + }, + ); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ message: "Select a channel" }), + ); + expect(multiselect).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 4db0dd208..0c9307b44 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -18,10 +18,21 @@ import { ensureOnboardingPluginInstalled, reloadOnboardingPluginRegistry, } from "./onboarding/plugin-install.js"; -import type { ChannelOnboardingDmPolicy, SetupChannelsOptions } from "./onboarding/types.js"; +import type { + ChannelOnboardingDmPolicy, + ChannelOnboardingStatus, + SetupChannelsOptions, +} from "./onboarding/types.js"; type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip"; +type ChannelStatusSummary = { + installedPlugins: ReturnType; + catalogEntries: ReturnType; + statusByChannel: Map; + statusLines: string[]; +}; + function formatAccountLabel(accountId: string): string { return accountId === DEFAULT_ACCOUNT_ID ? "default (primary)" : accountId; } @@ -69,6 +80,59 @@ async function promptRemovalAccountId(params: { return normalizeAccountId(selected) ?? defaultAccountId; } +async function collectChannelStatus(params: { + cfg: ClawdbotConfig; + options?: SetupChannelsOptions; + accountOverrides: Partial>; +}): Promise { + const installedPlugins = listChannelPlugins(); + const installedIds = new Set(installedPlugins.map((plugin) => plugin.id)); + const catalogEntries = listChannelPluginCatalogEntries().filter( + (entry) => !installedIds.has(entry.id), + ); + const statusEntries = await Promise.all( + listChannelOnboardingAdapters().map((adapter) => + adapter.getStatus({ + cfg: params.cfg, + options: params.options, + accountOverrides: params.accountOverrides, + }), + ), + ); + const catalogStatuses = catalogEntries.map((entry) => ({ + channel: entry.id, + configured: false, + statusLines: [`${entry.meta.label}: install plugin to enable`], + selectionHint: "plugin · install", + quickstartScore: 0, + })); + const combinedStatuses = [...statusEntries, ...catalogStatuses]; + const statusByChannel = new Map(combinedStatuses.map((entry) => [entry.channel, entry])); + const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines); + return { + installedPlugins, + catalogEntries, + statusByChannel, + statusLines, + }; +} + +export async function noteChannelStatus(params: { + cfg: ClawdbotConfig; + prompter: WizardPrompter; + options?: SetupChannelsOptions; + accountOverrides?: Partial>; +}): Promise { + const { statusLines } = await collectChannelStatus({ + cfg: params.cfg, + options: params.options, + accountOverrides: params.accountOverrides ?? {}, + }); + if (statusLines.length > 0) { + await params.prompter.note(statusLines.join("\n"), "Channel status"); + } +} + async function noteChannelPrimer( prompter: WizardPrompter, channels: Array<{ id: ChannelChoice; blurb: string; label: string }>, @@ -174,26 +238,9 @@ export async function setupChannels( accountOverrides.whatsapp = options.whatsappAccountId.trim(); } - const installedPlugins = listChannelPlugins(); - const catalogEntries = listChannelPluginCatalogEntries().filter( - (entry) => !installedPlugins.some((plugin) => plugin.id === entry.id), - ); - const statusEntries = await Promise.all( - listChannelOnboardingAdapters().map((adapter) => - adapter.getStatus({ cfg, options, accountOverrides }), - ), - ); - const catalogStatuses = catalogEntries.map((entry) => ({ - channel: entry.id, - configured: false, - statusLines: [`${entry.meta.label}: install plugin to enable`], - selectionHint: "plugin · install", - quickstartScore: 0, - })); - const combinedStatuses = [...statusEntries, ...catalogStatuses]; - const statusByChannel = new Map(combinedStatuses.map((entry) => [entry.channel, entry])); - const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines); - if (statusLines.length > 0) { + const { installedPlugins, catalogEntries, statusByChannel, statusLines } = + await collectChannelStatus({ cfg: next, options, accountOverrides }); + if (!options?.skipStatusNote && statusLines.length > 0) { await prompter.note(statusLines.join("\n"), "Channel status"); } @@ -234,15 +281,36 @@ export async function setupChannels( if (!selection.includes(channel)) selection.push(channel); }; + const resolveDisabledHint = (channel: ChannelChoice): string | undefined => { + const plugin = getChannelPlugin(channel); + if (!plugin) return undefined; + const accountId = resolveChannelDefaultAccountId({ plugin, cfg: next }); + const account = plugin.config.resolveAccount(next, accountId); + let enabled: boolean | undefined; + if (plugin.config.isEnabled) { + enabled = plugin.config.isEnabled(account, next); + } else if (typeof (account as { enabled?: boolean })?.enabled === "boolean") { + enabled = (account as { enabled?: boolean }).enabled; + } else if ( + typeof (next.channels as Record | undefined)?.[channel] + ?.enabled === "boolean" + ) { + enabled = (next.channels as Record)[channel]?.enabled; + } + return enabled === false ? "disabled" : undefined; + }; + const buildSelectionOptions = ( entries: Array<{ id: ChannelChoice; meta: { id: string; label: string; selectionLabel?: string } }>, ) => entries.map((entry) => { const status = statusByChannel.get(entry.id); + const disabledHint = resolveDisabledHint(entry.id); + const hint = [status?.selectionHint, disabledHint].filter(Boolean).join(" · ") || undefined; return { value: entry.meta.id, label: entry.meta.selectionLabel ?? entry.meta.label, - ...(status?.selectionHint ? { hint: status.selectionHint } : {}), + ...(hint ? { hint } : {}), }; });