fix(channels): show missing external channel config (#82849)

This commit is contained in:
Peter Steinberger
2026-05-17 04:10:26 +01:00
committed by GitHub
parent 5040eb5d84
commit b328f57bc3
3 changed files with 135 additions and 19 deletions
+1
View File
@@ -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.
+94
View File
@@ -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<string, { accounts: string[]; origin: string; installed: boolean }>;
};
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([]);
+40 -19
View File
@@ -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 } : {}),
}),
);
}