From b328f57bc3f7069b56ea90657c53ae6eab9ad7d9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 04:10:26 +0100 Subject: [PATCH] fix(channels): show missing external channel config (#82849) --- CHANGELOG.md | 1 + src/commands/channels.list.test.ts | 94 ++++++++++++++++++++++++++++++ src/commands/channels/list.ts | 59 +++++++++++++------ 3 files changed, 135 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 712b295a85ee..816861aeb2d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - Agents/subagents: route group/channel subagent completions through message-tool-only handoffs when required and keep active-requester wake failures from dropping completion delivery. Fixes #82803. Thanks @galiniliev, @yozakura-ava, and @moeedahmed. - Memory-core: scan persisted memory source sessions on startup, comparing on-disk transcripts against the index and marking only missing/newer/resized files dirty for incremental sync. Fixes #82341. (#82341) Thanks @giodl73-repo. - Telegram: keep the top-level default account in the account list when named accounts or bindings are added alongside top-level credentials, preserving default polling while still letting named-only configs resolve to a single account. Fixes #82794. (#82794) Thanks @giodl73-repo. +- CLI/channels: show configured official external channels such as Discord in `openclaw channels list` when their plugin package is missing, including the install and doctor repair command instead of reporting no configured channels. Fixes #82813. - WhatsApp: honor forced document delivery for outbound image, GIF, and video media so `forceDocument`/`asDocument` sends preserve original media bytes instead of using compressed media payloads. (#79272) Thanks @itsuzef. - WhatsApp: name outbound document attachments from their MIME type when no filename is provided, so PDF and CSV sends arrive as `file.pdf` and `file.csv` instead of an extensionless `file`. Thanks @mcaxtr. diff --git a/src/commands/channels.list.test.ts b/src/commands/channels.list.test.ts index 6ae81d12e00c..9b58a8ec04e0 100644 --- a/src/commands/channels.list.test.ts +++ b/src/commands/channels.list.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ isCatalogChannelInstalled: vi.fn<(params: { entry: ChannelPluginCatalogEntry }) => boolean>( () => true, ), + resolveMissingOfficialExternalChannelPluginRepairHint: vi.fn(), callGateway: vi.fn(), resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), resolveDefaultAgentId: vi.fn(() => "main"), @@ -54,6 +55,11 @@ vi.mock("./channel-setup/discovery.js", () => ({ isCatalogChannelInstalled: mocks.isCatalogChannelInstalled, })); +vi.mock("../plugins/official-external-plugin-repair-hints.js", () => ({ + resolveMissingOfficialExternalChannelPluginRepairHint: + mocks.resolveMissingOfficialExternalChannelPluginRepairHint, +})); + vi.mock("../agents/agent-scope.js", () => ({ resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, resolveDefaultAgentId: mocks.resolveDefaultAgentId, @@ -120,6 +126,8 @@ describe("channels list", () => { mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([]); mocks.isCatalogChannelInstalled.mockReset(); mocks.isCatalogChannelInstalled.mockReturnValue(true); + mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReset(); + mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue(null); mocks.callGateway.mockReset(); mocks.callGateway.mockRejectedValue(new Error("gateway unavailable")); }); @@ -355,6 +363,92 @@ describe("channels list", () => { expect(output).toContain("--all"); }); + it("default output shows configured official external channels when the plugin is missing", async () => { + const runtime = createTestRuntime(); + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); + mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([ + createCatalogEntry("discord", "Discord"), + ]); + mocks.isCatalogChannelInstalled.mockReturnValue(false); + mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue({ + pluginId: "discord", + channelId: "discord", + label: "Discord", + installSpec: "@openclaw/discord", + installCommand: "openclaw plugins install @openclaw/discord", + doctorFixCommand: "openclaw doctor --fix", + repairHint: + "Install the official external plugin with: openclaw plugins install @openclaw/discord, or run: openclaw doctor --fix.", + }); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + discord: { enabled: true, token: "secret" }, + }, + }, + }); + + await channelsListCommand({}, runtime); + + expect(mocks.resolveMissingOfficialExternalChannelPluginRepairHint).toHaveBeenCalledWith({ + config: { + channels: { + discord: { enabled: true, token: "secret" }, + }, + }, + channelId: "discord", + workspaceDir: "/tmp/workspace", + }); + const output = stripAnsi(loggedText(runtime)); + expect(output).toContain("Discord"); + expect(output).toContain("not installed"); + expect(output).toContain("configured"); + expect(output).toContain("disabled"); + expect(output).toContain( + "run openclaw plugins install @openclaw/discord or openclaw doctor --fix", + ); + expect(output).not.toContain("no configured chat channels"); + }); + + it("JSON output includes configured official external channels when the plugin is missing", async () => { + const runtime = createTestRuntime(); + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); + mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([ + createCatalogEntry("discord", "Discord"), + ]); + mocks.isCatalogChannelInstalled.mockReturnValue(false); + mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue({ + pluginId: "discord", + channelId: "discord", + label: "Discord", + installSpec: "@openclaw/discord", + installCommand: "openclaw plugins install @openclaw/discord", + doctorFixCommand: "openclaw doctor --fix", + repairHint: + "Install the official external plugin with: openclaw plugins install @openclaw/discord, or run: openclaw doctor --fix.", + }); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + discord: { enabled: true, token: "secret" }, + }, + }, + }); + + await channelsListCommand({ json: true }, runtime); + + const payload = JSON.parse(loggedText(runtime)) as { + chat: Record; + }; + expect(payload.chat.discord).toEqual({ + accounts: [], + installed: false, + origin: "configured", + }); + }); + it("--all surfaces uninstalled catalog channels with installed=false / not configured / not enabled", async () => { const runtime = createTestRuntime(); mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index 0fbba903618e..4b870f4c3216 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -11,6 +11,7 @@ import { type RuntimeChannelStatusPayload, } from "../../channels/status/read-model.js"; import { callGateway } from "../../gateway/call.js"; +import { resolveMissingOfficialExternalChannelPluginRepairHint } from "../../plugins/official-external-plugin-repair-hints.js"; import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; @@ -124,14 +125,19 @@ function formatAccountLine(params: { function formatCatalogOnlyLine(params: { entry: ChannelPluginCatalogEntry; installed: boolean; + configured: boolean; + repairHint?: string; }): string { - const { entry, installed } = params; + const { entry, installed, configured, repairHint } = params; const channelText = theme.accent(entry.meta.label ?? entry.id); const bits: string[] = [ formatInstalled(installed), - formatConfigured(false), + formatConfigured(configured), formatEnabled(false), ]; + if (repairHint) { + bits.push(repairHint); + } return `- ${channelText}: ${bits.join(", ")}`; } @@ -229,8 +235,8 @@ export async function channelsListCommand( }); } - // --all also surfaces catalog entries that are not already represented - // by a plugin row above. Two shapes land here: + // Catalog entries that are not already represented by a plugin row above can + // still be useful in two shapes: // 1. Catalog plugin package is not yet installed on disk — rendered as // `not installed, not configured, disabled` so the channel still // appears in the listing as installable. @@ -240,9 +246,25 @@ export async function channelsListCommand( // configured channels). These would otherwise silently disappear // from the listing — render them as `installed, not configured, // disabled` so operators can tell the plugin is ready to configure. - const catalogOnlyLines: ChannelPluginCatalogEntry[] = showAll - ? catalogEntries.filter((entry) => !renderedChannelIds.has(entry.id)) - : []; + // Without --all, keep this limited to configured channels whose official + // external plugin owner is missing, otherwise `channels list` can claim + // there are no configured channels even though openclaw.json has one. + const catalogOnlyLines = catalogEntries + .filter((entry) => !renderedChannelIds.has(entry.id)) + .map((entry) => { + const hint = resolveMissingOfficialExternalChannelPluginRepairHint({ + config: cfg, + channelId: entry.id, + ...(workspaceDir ? { workspaceDir } : {}), + }); + return { + entry, + installed: isInstalled(entry.id), + configured: Boolean(hint), + repairHint: hint ? `run ${hint.installCommand} or ${hint.doctorFixCommand}` : undefined, + }; + }) + .filter((line) => showAll || line.configured); if (opts.json) { type JsonChannelEntry = { @@ -268,15 +290,12 @@ export async function channelsListCommand( }; } } - if (showAll) { - for (const entry of catalogOnlyLines) { - const installed = isInstalled(entry.id); - chat[entry.id] = { - accounts: [], - installed, - origin: installed ? "available" : "installable", - }; - } + for (const line of catalogOnlyLines) { + chat[line.entry.id] = { + accounts: [], + installed: line.installed, + origin: line.configured ? "configured" : line.installed ? "available" : "installable", + }; } writeRuntimeJson(runtime, { chat }); return; @@ -302,11 +321,13 @@ export async function channelsListCommand( }), ); } - for (const entry of catalogOnlyLines) { + for (const line of catalogOnlyLines) { lines.push( formatCatalogOnlyLine({ - entry, - installed: isInstalled(entry.id), + entry: line.entry, + installed: line.installed, + configured: line.configured, + ...(line.repairHint ? { repairHint: line.repairHint } : {}), }), ); }