Configure: defer channel status until selection

This commit is contained in:
Gustavo Madeira Santana
2026-04-17 02:14:53 -04:00
parent 474b08bfbd
commit 13b182f34b
9 changed files with 409 additions and 58 deletions

View File

@@ -293,6 +293,7 @@ export type SetupChannelsOptions = {
onResolvedPlugin?: (channel: ChannelId, plugin: ChannelSetupPlugin) => void;
promptAccountIds?: boolean;
forceAllowFromChannels?: ChannelId[];
deferStatusUntilSelection?: boolean;
skipStatusNote?: boolean;
skipDmPolicyPrompt?: boolean;
skipConfirm?: boolean;

View File

@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const select = vi.hoisted(() => vi.fn());
const confirm = vi.hoisted(() => vi.fn());
const note = vi.hoisted(() => vi.fn());
vi.mock("../channels/chat-meta.js", () => ({
listChatChannels: () => [
{ id: "telegram", label: "Telegram" },
{ id: "twitch", label: "Twitch" },
],
}));
vi.mock("../terminal/note.js", () => ({
note: (...args: unknown[]) => note(...args),
}));
vi.mock("./configure.shared.js", () => ({
select: (params: unknown) => select(params),
confirm: (params: unknown) => confirm(params),
}));
import { removeChannelConfigWizard } from "./configure.channels.js";
describe("removeChannelConfigWizard", () => {
beforeEach(() => {
vi.clearAllMocks();
confirm.mockResolvedValue(true);
});
it("lists configured channels from openclaw.json even when no plugins are loaded", async () => {
select.mockResolvedValue("done");
await removeChannelConfigWizard(
{
channels: {
twitch: {},
unknown: {},
telegram: {},
},
} as never,
{} as never,
);
expect(select).toHaveBeenCalledWith(
expect.objectContaining({
message: "Remove which channel config?",
options: [
expect.objectContaining({ value: "telegram", label: "Telegram" }),
expect.objectContaining({ value: "twitch", label: "Twitch" }),
expect.objectContaining({ value: "unknown", label: "unknown" }),
{ value: "done", label: "Done" },
],
}),
);
});
it("deletes the selected channel block from openclaw.json", async () => {
select.mockResolvedValueOnce("telegram").mockResolvedValueOnce("done");
const next = await removeChannelConfigWizard(
{
channels: {
telegram: { token: "secret" },
twitch: { token: "secret" },
},
} as never,
{} as never,
);
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: "Delete Telegram configuration from ~/.openclaw/openclaw.json?",
}),
);
expect(next.channels).toEqual({ twitch: { token: "secret" } });
expect(note).toHaveBeenCalledWith(
"Telegram removed from config.\nNote: credentials/sessions on disk are unchanged.",
"Channel removed",
);
});
});

View File

@@ -1,28 +1,52 @@
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import { listChatChannels } from "../channels/chat-meta.js";
import { formatCliCommand } from "../cli/command-format.js";
import { CONFIG_PATH } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
import { shouldShowChannelInSetup } from "./channel-setup/discovery.js";
import { confirm, select } from "./configure.shared.js";
import { guardCancel } from "./onboard-helpers.js";
type ConfiguredChannelRemovalChoice = {
id: string;
label: string;
};
function listConfiguredChannelRemovalChoices(
cfg: OpenClawConfig,
): ConfiguredChannelRemovalChoice[] {
const channels = cfg.channels;
if (!channels) {
return [];
}
const labelsById = new Map(listChatChannels().map((meta) => [meta.id, meta.label]));
return Object.keys(channels)
.map((id) => ({
id,
label: labelsById.get(id) ?? id,
}))
.toSorted(compareChannelRemovalChoices);
}
function compareChannelRemovalChoices(
left: ConfiguredChannelRemovalChoice,
right: ConfiguredChannelRemovalChoice,
): number {
return (
left.label.localeCompare(right.label, undefined, { numeric: true, sensitivity: "base" }) ||
left.id.localeCompare(right.id, undefined, { numeric: true, sensitivity: "base" })
);
}
export async function removeChannelConfigWizard(
cfg: OpenClawConfig,
runtime: RuntimeEnv,
): Promise<OpenClawConfig> {
let next = { ...cfg };
const listConfiguredChannels = () =>
listChannelPlugins()
.map((plugin) => plugin.meta)
.filter((meta) => shouldShowChannelInSetup(meta))
.filter((meta) => next.channels?.[meta.id] !== undefined);
while (true) {
const configured = listConfiguredChannels();
const configured = listConfiguredChannelRemovalChoices(next);
if (configured.length === 0) {
note(
[
@@ -53,7 +77,7 @@ export async function removeChannelConfigWizard(
return next;
}
const label = getChannelPlugin(channel)?.meta.label ?? channel;
const label = configured.find((entry) => entry.id === channel)?.label ?? channel;
const confirmed = guardCancel(
await confirm({
message: `Delete ${label} configuration from ${shortenHomePath(CONFIG_PATH)}?`,

View File

@@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => {
isCodexNativeWebSearchRelevant: vi.fn(({ config }: { config: OpenClawConfig }) =>
Boolean(config.auth?.profiles?.["openai-codex:default"]),
),
setupChannels: vi.fn(async (cfg: OpenClawConfig) => cfg),
};
});
@@ -104,7 +105,7 @@ vi.mock("./onboard-skills.js", () => ({
}));
vi.mock("./onboard-channels.js", () => ({
setupChannels: vi.fn(),
setupChannels: mocks.setupChannels,
}));
vi.mock("./onboard-search.js", () => ({
@@ -327,6 +328,28 @@ describe("runConfigureWizard", () => {
);
});
it("defers channel status checks until a channel is selected", async () => {
setupBaseWizardState();
queueWizardPrompts({
select: ["local", "configure"],
confirm: [],
});
await runConfigureWizard({ command: "configure", sections: ["channels"] }, createRuntime());
expect(mocks.setupChannels).toHaveBeenCalledWith(
expect.objectContaining({
gateway: expect.objectContaining({ mode: "local" }),
}),
expect.anything(),
expect.anything(),
expect.objectContaining({
deferStatusUntilSelection: true,
skipStatusNote: true,
}),
);
});
it("still supports keyless web search providers through the shared setup flow", async () => {
setupBaseWizardState();
mocks.resolveSearchProviderOptions.mockReturnValue([

View File

@@ -35,7 +35,7 @@ import {
} from "./configure.shared.js";
import { formatHealthCheckFailure } from "./health-format.js";
import { healthCommand } from "./health.js";
import { noteChannelStatus, setupChannels } from "./onboard-channels.js";
import { setupChannels } from "./onboard-channels.js";
import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
@@ -561,12 +561,12 @@ export async function runConfigureWizard(
};
const configureChannelsSection = async () => {
await noteChannelStatus({ cfg: nextConfig, prompter });
const channelMode = await promptChannelMode(runtime);
if (channelMode === "configure") {
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
allowDisable: true,
allowSignalInstall: true,
deferStatusUntilSelection: true,
skipConfirm: true,
skipStatusNote: true,
});

View File

@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from "vitest";
const listChatChannels = vi.hoisted(() => vi.fn(() => [{ id: "discord" }, { id: "bluebubbles" }]));
vi.mock("../channels/chat-meta.js", () => ({
listChatChannels: () => listChatChannels(),
}));
vi.mock("../channels/registry.js", () => ({
formatChannelPrimerLine: vi.fn(() => ""),
formatChannelSelectionLine: vi.fn(() => ""),
}));
vi.mock("../commands/channel-setup/discovery.js", () => ({
resolveChannelSetupEntries: vi.fn(() => ({
entries: [],
installedCatalogEntries: [],
installableCatalogEntries: [],
installedCatalogById: new Map(),
installableCatalogById: new Map(),
})),
shouldShowChannelInSetup: (meta: { exposure?: { setup?: boolean }; showInSetup?: boolean }) =>
meta.showInSetup !== false && meta.exposure?.setup !== false,
}));
import { resolveChannelSetupSelectionContributions } from "./channel-setup.status.js";
describe("resolveChannelSetupSelectionContributions", () => {
it("sorts channels alphabetically by picker label", () => {
const contributions = resolveChannelSetupSelectionContributions({
entries: [
{
id: "zalo",
meta: {
id: "zalo",
label: "Zalo",
selectionLabel: "Zalo (Bot API)",
},
},
{
id: "discord",
meta: {
id: "discord",
label: "Discord",
selectionLabel: "Discord (Bot API)",
},
},
{
id: "bluebubbles",
meta: {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles (macOS app)",
},
},
] as never,
statusByChannel: new Map(),
resolveDisabledHint: () => undefined,
});
expect(contributions.map((contribution) => contribution.option.label)).toEqual([
"BlueBubbles (macOS app)",
"Discord (Bot API)",
"Zalo (Bot API)",
]);
});
it("does not invent hints before status has been collected", () => {
const contributions = resolveChannelSetupSelectionContributions({
entries: [
{
id: "zalo",
meta: {
id: "zalo",
label: "Zalo",
selectionLabel: "Zalo (Bot API)",
quickstartAllowFrom: true,
},
},
] as never,
statusByChannel: new Map(),
resolveDisabledHint: () => undefined,
});
expect(contributions.map((contribution) => contribution.option)).toEqual([
{
value: "zalo",
label: "Zalo (Bot API)",
},
]);
});
it("combines real status and disabled hints when available", () => {
const contributions = resolveChannelSetupSelectionContributions({
entries: [
{
id: "zalo",
meta: {
id: "zalo",
label: "Zalo",
selectionLabel: "Zalo (Bot API)",
quickstartAllowFrom: true,
},
},
] as never,
statusByChannel: new Map([["zalo", { selectionHint: "configured" }]]),
resolveDisabledHint: () => "disabled",
});
expect(contributions[0]?.option).toEqual({
value: "zalo",
label: "Zalo (Bot API)",
hint: "configured · disabled",
});
});
});

View File

@@ -35,6 +35,18 @@ export type ChannelSetupSelectionContribution = FlowContribution & {
source: "catalog" | "core" | "plugin";
};
type ChannelSetupSelectionEntry = {
id: ChannelChoice;
meta: {
id: string;
label: string;
selectionLabel?: string;
exposure?: { setup?: boolean };
showConfigured?: boolean;
showInSetup?: boolean;
};
};
function buildChannelSetupSelectionContribution(params: {
channel: ChannelChoice;
label: string;
@@ -235,33 +247,35 @@ export function resolveChannelSelectionNoteLines(params: {
}
export function resolveChannelSetupSelectionContributions(params: {
entries: Array<{
id: ChannelChoice;
meta: {
id: string;
label: string;
selectionLabel?: string;
exposure?: { setup?: boolean };
showConfigured?: boolean;
showInSetup?: boolean;
};
}>;
entries: ChannelSetupSelectionEntry[];
statusByChannel: Map<ChannelChoice, { selectionHint?: string }>;
resolveDisabledHint: (channel: ChannelChoice) => string | undefined;
}): ChannelSetupSelectionContribution[] {
const bundledChannelIds = new Set(listChatChannels().map((channel) => channel.id));
return params.entries
.filter((entry) => shouldShowChannelInSetup(entry.meta))
.toSorted((left, right) => compareChannelSetupSelectionEntries(left, right))
.map((entry) => {
const disabledHint = params.resolveDisabledHint(entry.id);
const hint =
[params.statusByChannel.get(entry.id)?.selectionHint, disabledHint]
.filter(Boolean)
.join(" · ") || undefined;
const statusHint = params.statusByChannel.get(entry.id)?.selectionHint;
const hint = [statusHint, disabledHint].filter(Boolean).join(" · ") || undefined;
return buildChannelSetupSelectionContribution({
channel: entry.id,
label: entry.meta.selectionLabel ?? entry.meta.label,
hint,
source: listChatChannels().some((channel) => channel.id === entry.id) ? "core" : "plugin",
source: bundledChannelIds.has(entry.id) ? "core" : "plugin",
});
});
}
function compareChannelSetupSelectionEntries(
left: ChannelSetupSelectionEntry,
right: ChannelSetupSelectionEntry,
): number {
const leftLabel = left.meta.selectionLabel ?? left.meta.label;
const rightLabel = right.meta.selectionLabel ?? right.meta.label;
return (
leftLabel.localeCompare(rightLabel, undefined, { numeric: true, sensitivity: "base" }) ||
left.id.localeCompare(right.id, undefined, { numeric: true, sensitivity: "base" })
);
}

View File

@@ -12,6 +12,25 @@ const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => []));
const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() =>
vi.fn((_params?: unknown) => ({ channels: [], channelSetups: [] })),
);
const resolveChannelSetupEntries = vi.hoisted(() =>
vi.fn(
(
_params?: unknown,
): {
entries: unknown[];
installedCatalogEntries: unknown[];
installableCatalogEntries: unknown[];
installedCatalogById: Map<unknown, unknown>;
installableCatalogById: Map<unknown, unknown>;
} => ({
entries: [],
installedCatalogEntries: [],
installableCatalogEntries: [],
installedCatalogById: new Map(),
installableCatalogById: new Map(),
}),
),
);
const collectChannelStatus = vi.hoisted(() =>
vi.fn(async (_params?: unknown) => ({
installedPlugins: [],
@@ -42,7 +61,7 @@ vi.mock("../channels/registry.js", () => ({
}));
vi.mock("../commands/channel-setup/discovery.js", () => ({
resolveChannelSetupEntries: vi.fn(),
resolveChannelSetupEntries: (params?: unknown) => resolveChannelSetupEntries(params),
shouldShowChannelInSetup: () => true,
}));
@@ -101,6 +120,13 @@ describe("setupChannels workspace shadow exclusion", () => {
channels: [],
channelSetups: [],
});
resolveChannelSetupEntries.mockReturnValue({
entries: [],
installedCatalogEntries: [],
installableCatalogEntries: [],
installedCatalogById: new Map(),
installableCatalogById: new Map(),
});
collectChannelStatus.mockResolvedValue({
installedPlugins: [],
catalogEntries: [],
@@ -163,4 +189,41 @@ describe("setupChannels workspace shadow exclusion", () => {
}),
);
});
it("defers status and setup-plugin loads until a channel is selected", async () => {
resolveChannelSetupEntries.mockReturnValue({
entries: [
{
id: "telegram",
meta: { id: "telegram", label: "Telegram", blurb: "" },
},
],
installedCatalogEntries: [],
installableCatalogEntries: [],
installedCatalogById: new Map(),
installableCatalogById: new Map(),
});
const select = vi.fn(async () => "__done__");
await setupChannels(
{} as never,
{} as never,
{
confirm: vi.fn(async () => true),
note: vi.fn(async () => undefined),
select,
} as never,
{
deferStatusUntilSelection: true,
skipConfirm: true,
},
);
expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" }));
expect(collectChannelStatus).not.toHaveBeenCalled();
expect(listTrustedChannelPluginCatalogEntries).not.toHaveBeenCalled();
expect(listChannelSetupPlugins).not.toHaveBeenCalled();
expect(getChannelSetupPlugin).not.toHaveBeenCalled();
expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled();
});
});

View File

@@ -22,6 +22,7 @@ import { listTrustedChannelPluginCatalogEntries } from "../commands/channel-setu
import type {
ChannelSetupConfiguredResult,
ChannelSetupResult,
ChannelSetupStatus,
ChannelOnboardingPostWriteHook,
SetupChannelsOptions,
} from "../commands/channel-setup/types.js";
@@ -110,6 +111,7 @@ export async function setupChannels(
options?: SetupChannelsOptions,
): Promise<OpenClawConfig> {
let next = cfg;
const deferStatusUntilSelection = options?.deferStatusUntilSelection === true;
const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []);
const accountOverrides: Partial<Record<ChannelChoice, string>> = {
...options?.accountIds,
@@ -122,12 +124,18 @@ export async function setupChannels(
options?.onResolvedPlugin?.(channel, plugin);
};
const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelSetupPlugin | undefined =>
scopedPluginsById.get(channel) ?? getChannelSetupPlugin(channel);
const listVisibleInstalledPlugins = (): ChannelSetupPlugin[] => {
scopedPluginsById.get(channel) ??
(deferStatusUntilSelection ? undefined : getChannelSetupPlugin(channel));
const listVisibleInstalledPlugins = (params?: {
includeRegistry?: boolean;
}): ChannelSetupPlugin[] => {
const includeRegistry = params?.includeRegistry ?? !deferStatusUntilSelection;
const merged = new Map<string, ChannelSetupPlugin>();
for (const plugin of listChannelSetupPlugins()) {
if (shouldShowChannelInSetup(plugin.meta)) {
merged.set(plugin.id, plugin);
if (includeRegistry) {
for (const plugin of listChannelSetupPlugins()) {
if (shouldShowChannelInSetup(plugin.meta)) {
merged.set(plugin.id, plugin);
}
}
}
for (const plugin of scopedPluginsById.values()) {
@@ -137,10 +145,10 @@ export async function setupChannels(
}
return Array.from(merged.values());
};
const resolveVisibleChannelEntries = () =>
const resolveVisibleChannelEntries = (params?: { includeRegistry?: boolean }) =>
resolveChannelSetupEntries({
cfg: next,
installedPlugins: listVisibleInstalledPlugins(),
installedPlugins: listVisibleInstalledPlugins(params),
workspaceDir: resolveWorkspaceDir(),
});
const loadScopedChannelPlugin = async (
@@ -172,7 +180,7 @@ export async function setupChannels(
if (scopedPlugin) {
return resolveChannelSetupWizardAdapterForPlugin(scopedPlugin);
}
return resolveChannelSetupWizardAdapterForPlugin(getChannelSetupPlugin(channel));
return resolveChannelSetupWizardAdapterForPlugin(getVisibleChannelPlugin(channel));
};
const preloadConfiguredExternalPlugins = async () => {
// Keep setup memory bounded by snapshot-loading only configured external plugins.
@@ -194,15 +202,20 @@ export async function setupChannels(
}
await Promise.all(preloadTasks);
};
await preloadConfiguredExternalPlugins();
if (!deferStatusUntilSelection) {
await preloadConfiguredExternalPlugins();
}
const { statusByChannel, statusLines } = await collectChannelStatus({
cfg: next,
options,
accountOverrides,
installedPlugins: listVisibleInstalledPlugins(),
resolveAdapter: getVisibleSetupFlowAdapter,
});
const statusSummary = deferStatusUntilSelection
? { statusByChannel: new Map<ChannelChoice, ChannelSetupStatus>(), statusLines: [] }
: await collectChannelStatus({
cfg: next,
options,
accountOverrides,
installedPlugins: listVisibleInstalledPlugins(),
resolveAdapter: getVisibleSetupFlowAdapter,
});
const { statusByChannel, statusLines } = statusSummary;
if (!options?.skipStatusNote && statusLines.length > 0) {
await prompter.note(statusLines.join("\n"), "Channel status");
}
@@ -217,7 +230,9 @@ export async function setupChannels(
return cfg;
}
const primerChannels = resolveVisibleChannelEntries().entries.map((entry) => ({
const primerChannels = resolveVisibleChannelEntries({
includeRegistry: !deferStatusUntilSelection,
}).entries.map((entry) => ({
id: entry.id,
label: entry.meta.label,
blurb: entry.meta.blurb,
@@ -225,7 +240,8 @@ export async function setupChannels(
await noteChannelPrimer(prompter, primerChannels);
const quickstartDefault =
options?.initialSelection?.[0] ?? resolveQuickstartDefault(statusByChannel);
options?.initialSelection?.[0] ??
(deferStatusUntilSelection ? undefined : resolveQuickstartDefault(statusByChannel));
const shouldPromptAccountIds = options?.promptAccountIds === true;
const accountIdsByChannel = new Map<ChannelChoice, string>();
@@ -243,7 +259,7 @@ export async function setupChannels(
}
};
const resolveDisabledHint = (channel: ChannelChoice): string | undefined => {
const resolveConfigDisabledHint = (channel: ChannelChoice): string | undefined => {
if (
typeof (next.channels as Record<string, { enabled?: boolean }> | undefined)?.[channel]
?.enabled === "boolean"
@@ -252,14 +268,22 @@ export async function setupChannels(
? "disabled"
: undefined;
}
if (next.plugins?.entries?.[channel]?.enabled === false) {
return "plugin disabled";
}
if (next.plugins?.enabled === false) {
return "plugins disabled";
}
return undefined;
};
const resolveDisabledHint = (channel: ChannelChoice): string | undefined => {
const configDisabledHint = resolveConfigDisabledHint(channel);
if (configDisabledHint || deferStatusUntilSelection) {
return configDisabledHint;
}
const plugin = getVisibleChannelPlugin(channel);
if (!plugin) {
if (next.plugins?.entries?.[channel]?.enabled === false) {
return "plugin disabled";
}
if (next.plugins?.enabled === false) {
return "plugins disabled";
}
return undefined;
}
const accountId = resolveChannelDefaultAccountId({ plugin, cfg: next });
@@ -274,7 +298,9 @@ export async function setupChannels(
};
const getChannelEntries = () => {
const resolved = resolveVisibleChannelEntries();
const resolved = resolveVisibleChannelEntries({
includeRegistry: !deferStatusUntilSelection,
});
return {
entries: resolved.entries,
catalogById: resolved.installableCatalogById,
@@ -485,7 +511,7 @@ export async function setupChannels(
}
await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId);
await refreshStatus(channel);
} else if (installedCatalogEntry) {
} else if (installedCatalogEntry && installedCatalogEntry.origin !== "bundled") {
const plugin = await loadScopedChannelPlugin(channel, installedCatalogEntry.pluginId);
if (!plugin) {
await prompter.note(`${channel} plugin not available.`, "Channel setup");
@@ -581,7 +607,9 @@ export async function setupChannels(
const selectedLines = resolveChannelSelectionNoteLines({
cfg: next,
installedPlugins: listVisibleInstalledPlugins(),
installedPlugins: listVisibleInstalledPlugins({
includeRegistry: !deferStatusUntilSelection,
}),
selection,
});
if (selectedLines.length > 0) {