fix(channels): list channel catalog status

This commit is contained in:
Peter Steinberger
2026-05-06 20:00:17 +01:00
parent b22c8998ca
commit 288876dda7
3 changed files with 249 additions and 152 deletions

View File

@@ -100,7 +100,7 @@ export async function registerChannelsCli(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["openclaw channels list", "List configured channels and auth profiles."],
["openclaw channels list", "List available and configured channels."],
["openclaw channels status --probe", "Run channel status checks and probes."],
[
"openclaw channels add --channel telegram --token <token>",

View File

@@ -10,10 +10,13 @@ const mocks = vi.hoisted(() => ({
effectiveConfig: config,
diagnostics: [],
})),
loadAuthProfileStoreWithoutExternalProfiles: vi.fn(),
listChatChannels: vi.fn(() => [
{ id: "discord", label: "Discord", order: 10 },
{ id: "telegram", label: "Telegram", order: 20 },
]),
listReadOnlyChannelPluginsForConfig: vi.fn<() => ChannelPlugin[]>(() => []),
listTrustedChannelPluginCatalogEntries: vi.fn<() => any[]>(() => []),
buildChannelAccountSnapshot: vi.fn(),
loadProviderUsageSummary: vi.fn(),
}));
vi.mock("../config/config.js", () => ({
@@ -28,8 +31,8 @@ vi.mock("../cli/command-secret-targets.js", () => ({
getChannelsCommandSecretTargetIds: () => new Set<string>(),
}));
vi.mock("../agents/auth-profiles.js", () => ({
loadAuthProfileStoreWithoutExternalProfiles: mocks.loadAuthProfileStoreWithoutExternalProfiles,
vi.mock("../channels/chat-meta.js", () => ({
listChatChannels: mocks.listChatChannels,
}));
vi.mock("../channels/plugins/read-only.js", () => ({
@@ -40,103 +43,108 @@ vi.mock("../channels/plugins/status.js", () => ({
buildChannelAccountSnapshot: mocks.buildChannelAccountSnapshot,
}));
vi.mock("../infra/provider-usage.js", () => ({
formatUsageReportLines: () => [],
loadProviderUsageSummary: mocks.loadProviderUsageSummary,
vi.mock("./channel-setup/trusted-catalog.js", () => ({
listTrustedChannelPluginCatalogEntries: mocks.listTrustedChannelPluginCatalogEntries,
}));
import { channelsListCommand } from "./channels/list.js";
function createMockChannelPlugin(accountIds: string[]): ChannelPlugin {
function createMockChannelPlugin(params: {
id?: string;
label?: string;
accountIds?: string[];
order?: number;
}): ChannelPlugin {
const id = params.id ?? "telegram";
return {
id: "telegram",
id,
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram",
id,
label: params.label ?? id,
selectionLabel: params.label ?? id,
docsPath: `/channels/${id}`,
blurb: params.label ?? id,
order: params.order,
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => accountIds,
listAccountIds: () => params.accountIds ?? [],
resolveAccount: () => ({}),
},
};
}
describe("channels list auth profiles", () => {
describe("channels list", () => {
beforeEach(() => {
mocks.readConfigFileSnapshot.mockReset();
mocks.resolveCommandConfigWithSecrets.mockClear();
mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReset();
mocks.loadProviderUsageSummary.mockReset();
mocks.listChatChannels.mockReset();
mocks.listChatChannels.mockReturnValue([
{ id: "discord", label: "Discord", order: 10 },
{ id: "telegram", label: "Telegram", order: 20 },
]);
mocks.listTrustedChannelPluginCatalogEntries.mockReset();
mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([]);
mocks.listReadOnlyChannelPluginsForConfig.mockReset();
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]);
mocks.buildChannelAccountSnapshot.mockReset();
});
it("includes local auth profiles in JSON output without loading external profiles", async () => {
it("lists only channels in JSON output", async () => {
const runtime = createTestRuntime();
mocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
config: {},
});
mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReturnValue({
version: 1,
profiles: {
"anthropic:default": {
type: "oauth",
provider: "anthropic",
access: "token",
refresh: "refresh",
expires: 0,
created: 0,
},
"openai-codex:default": {
type: "oauth",
provider: "openai",
access: "token",
refresh: "refresh",
expires: 0,
created: 0,
},
},
});
await channelsListCommand({ json: true, usage: false }, runtime);
expect(mocks.resolveCommandConfigWithSecrets).toHaveBeenCalledTimes(1);
const payload = JSON.parse(runtime.log.mock.calls[0]?.[0] as string) as {
auth?: Array<{ id: string }>;
channels?: Array<{ id: string; configured: boolean; enabled: boolean; installed: boolean }>;
auth?: unknown;
usage?: unknown;
};
const ids = payload.auth?.map((entry) => entry.id) ?? [];
expect(ids).toContain("anthropic:default");
expect(ids).toContain("openai-codex:default");
expect(payload.auth).toBeUndefined();
expect(payload.usage).toBeUndefined();
expect(payload.channels?.map((entry) => entry.id)).toEqual(["discord", "telegram"]);
expect(payload.channels?.[0]).toMatchObject({
id: "discord",
configured: false,
enabled: true,
installed: false,
});
});
it("includes configured chat channel accounts in JSON output", async () => {
it("includes bundled/catalog/configured channels with status flags", async () => {
const runtime = createTestRuntime();
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([
createMockChannelPlugin(["alerts", "default"]),
createMockChannelPlugin({
id: "telegram",
label: "Telegram",
accountIds: ["alerts"],
order: 20,
}),
]);
mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([
{
id: "slack",
meta: { id: "slack", label: "Slack", selectionLabel: "Slack", order: 30 },
install: { npmSpec: "@openclaw/slack" },
},
]);
mocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
config: {
channels: {
telegram: {
accounts: {
default: { botToken: "123:abc" },
alerts: { botToken: "456:def" },
},
accounts: { alerts: { botToken: "456:def" } },
},
slack: { enabled: false },
custom: { webhookUrl: "https://example.invalid/hook" },
},
},
});
mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReturnValue({
version: 1,
profiles: {},
});
await channelsListCommand({ json: true, usage: false }, runtime);
@@ -145,36 +153,50 @@ describe("channels list auth profiles", () => {
expect.objectContaining({ includeSetupFallbackPlugins: true }),
);
const payload = JSON.parse(runtime.log.mock.calls[0]?.[0] as string) as {
chat?: Record<string, string[]>;
channels: Array<{
id: string;
configured: boolean;
enabled: boolean;
installed: boolean;
accounts: string[];
}>;
};
expect(payload.chat?.telegram).toEqual(["alerts", "default"]);
expect(payload.channels).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "telegram",
configured: true,
enabled: true,
installed: true,
accounts: ["alerts"],
}),
expect.objectContaining({
id: "slack",
configured: false,
enabled: false,
installed: false,
accounts: [],
}),
expect.objectContaining({
id: "custom",
configured: true,
enabled: true,
installed: false,
accounts: [],
}),
]),
);
});
it("keeps JSON output valid when usage loading fails", async () => {
const runtime = createTestRuntime();
mocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
config: {},
});
mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReturnValue({
version: 1,
profiles: {},
});
mocks.loadProviderUsageSummary.mockRejectedValue(new Error("fetch failed"));
await channelsListCommand({ json: true }, runtime);
const payload = JSON.parse(runtime.log.mock.calls[0]?.[0] as string) as {
usage?: unknown;
};
expect(payload.usage).toBeUndefined();
expect(runtime.error).not.toHaveBeenCalled();
});
it("prints configured chat channel accounts before auth providers", async () => {
it("prints channel rows and account rows without auth providers", async () => {
const runtime = createTestRuntime();
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([
createMockChannelPlugin(["default"]),
createMockChannelPlugin({
id: "telegram",
label: "Telegram",
accountIds: ["default"],
order: 20,
}),
]);
mocks.buildChannelAccountSnapshot.mockResolvedValue({
accountId: "default",
@@ -194,21 +216,14 @@ describe("channels list auth profiles", () => {
},
},
});
mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReturnValue({
version: 1,
profiles: {},
});
await channelsListCommand({ usage: false }, runtime);
expect(mocks.listReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ includeSetupFallbackPlugins: true }),
);
const output = stripAnsi(runtime.log.mock.calls[0]?.[0] as string);
expect(output).toContain("Chat channels:");
expect(output).toContain("Telegram default:");
expect(output).toContain("configured");
expect(output.indexOf("Telegram default:")).toBeLessThan(output.indexOf("Auth providers"));
expect(output).toContain("Discord: not configured, enabled, not installed");
expect(output).toContain("Telegram: configured, enabled, installed");
expect(output).toContain("Telegram default: configured, token=config, enabled");
expect(output).not.toContain("Auth providers");
});
});

View File

@@ -1,14 +1,15 @@
import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { listChatChannels } from "../../channels/chat-meta.js";
import { isChannelVisibleInConfiguredLists } from "../../channels/plugins/exposure.js";
import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js";
import { withProgress } from "../../cli/progress.js";
import { formatUsageReportLines, loadProviderUsageSummary } from "../../infra/provider-usage.js";
import { isStaticallyChannelConfigured } from "../../config/channel-configured-shared.js";
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { listTrustedChannelPluginCatalogEntries } from "../channel-setup/trusted-catalog.js";
import { formatChannelAccountLabel, requireValidConfig } from "./shared.js";
export type ChannelsListOptions = {
@@ -89,21 +90,51 @@ function formatAccountLine(params: {
}
return `- ${label}: ${bits.join(", ")}`;
}
async function loadUsageWithProgress(
runtime: RuntimeEnv,
progress = true,
): Promise<Awaited<ReturnType<typeof loadProviderUsageSummary>> | null> {
try {
return await withProgress(
{ label: "Fetching usage snapshot…", indeterminate: true, enabled: progress },
async () => await loadProviderUsageSummary({ skipPluginAuthWithoutCredentialSource: true }),
);
} catch (err) {
if (progress) {
runtime.error(String(err));
type ChannelListEntry = {
id: string;
label: string;
order: number;
configured: boolean;
enabled: boolean;
installed: boolean;
accounts: string[];
source: "bundled" | "catalog" | "configured";
};
const NON_CHANNEL_CONFIG_KEYS = new Set(["defaults"]);
function resolveChannelEnabled(cfg: Record<string, unknown>, channelId: string): boolean {
const channels = cfg.channels;
if (channels && typeof channels === "object" && !Array.isArray(channels)) {
const channelConfig = (channels as Record<string, unknown>)[channelId];
if (channelConfig && typeof channelConfig === "object" && !Array.isArray(channelConfig)) {
if ((channelConfig as Record<string, unknown>).enabled === false) {
return false;
}
}
return null;
}
return true;
}
function formatInstalled(value: boolean): string {
return value ? theme.success("installed") : theme.warn("not installed");
}
function formatChannelSummaryLine(entry: ChannelListEntry): string {
const bits = [
formatConfigured(entry.configured),
formatEnabled(entry.enabled),
formatInstalled(entry.installed),
];
return `- ${theme.accent(entry.label)}: ${bits.join(", ")}`;
}
function configuredChannelIdsFromConfig(cfg: Record<string, unknown>): string[] {
const channels = cfg.channels;
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
return [];
}
return Object.keys(channels).filter((id) => !NON_CHANNEL_CONFIG_KEYS.has(id));
}
export async function channelsListCommand(
@@ -114,78 +145,129 @@ export async function channelsListCommand(
if (!cfg) {
return;
}
const includeUsage = opts.usage !== false;
void opts.usage;
const plugins = listReadOnlyChannelPluginsForConfig(cfg, {
includeSetupFallbackPlugins: true,
});
const pluginById = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const catalogEntries = listTrustedChannelPluginCatalogEntries({ cfg, workspaceDir });
const entries = new Map<string, ChannelListEntry>();
const authStore = loadAuthProfileStoreWithoutExternalProfiles();
const authProfiles = Object.entries(authStore.profiles).map(([profileId, profile]) => ({
id: profileId,
provider: profile.provider,
type: profile.type,
isExternal: false,
}));
if (opts.json) {
const usage = includeUsage ? await loadUsageWithProgress(runtime, false) : undefined;
const chat: Record<string, string[]> = {};
for (const plugin of plugins) {
chat[plugin.id] = plugin.config.listAccountIds(cfg);
const upsert = (entry: ChannelListEntry) => {
const existing = entries.get(entry.id);
entries.set(entry.id, {
...entry,
...existing,
configured: Boolean(existing?.configured || entry.configured),
enabled: existing?.enabled === false || entry.enabled === false ? false : true,
installed: Boolean(existing?.installed || entry.installed),
accounts: existing?.accounts.length ? existing.accounts : entry.accounts,
source: existing?.source ?? entry.source,
});
};
for (const meta of listChatChannels()) {
const plugin = pluginById.get(meta.id);
upsert({
id: meta.id,
label: meta.label ?? meta.id,
order: meta.order ?? Number.MAX_SAFE_INTEGER,
configured: isStaticallyChannelConfigured(cfg, meta.id),
enabled: resolveChannelEnabled(cfg, meta.id),
installed: Boolean(plugin),
accounts: plugin?.config.listAccountIds(cfg) ?? [],
source: "bundled",
});
}
for (const plugin of plugins) {
const accounts = plugin.config.listAccountIds(cfg);
upsert({
id: plugin.id,
label: plugin.meta.label ?? plugin.id,
order: plugin.meta.order ?? Number.MAX_SAFE_INTEGER,
configured: accounts.length > 0 || isStaticallyChannelConfigured(cfg, plugin.id),
enabled: resolveChannelEnabled(cfg, plugin.id),
installed: true,
accounts,
source: "bundled",
});
}
for (const entry of catalogEntries) {
const plugin = pluginById.get(entry.id);
const accounts = plugin?.config.listAccountIds(cfg) ?? [];
upsert({
id: entry.id,
label: entry.meta.label ?? entry.id,
order: entry.meta.order ?? Number.MAX_SAFE_INTEGER,
configured: accounts.length > 0 || isStaticallyChannelConfigured(cfg, entry.id),
enabled: resolveChannelEnabled(cfg, entry.id),
installed: Boolean(plugin),
accounts,
source: "catalog",
});
}
for (const channelId of configuredChannelIdsFromConfig(cfg)) {
const plugin = pluginById.get(channelId);
const accounts = plugin?.config.listAccountIds(cfg) ?? [];
upsert({
id: channelId,
label: plugin?.meta.label ?? channelId,
order: plugin?.meta.order ?? Number.MAX_SAFE_INTEGER,
configured: accounts.length > 0 || isStaticallyChannelConfigured(cfg, channelId),
enabled: resolveChannelEnabled(cfg, channelId),
installed: Boolean(plugin),
accounts,
source: "configured",
});
}
const channels = [...entries.values()].toSorted((left, right) => {
if (left.order !== right.order) {
return left.order - right.order;
}
const payload = { chat, auth: authProfiles, ...(usage ? { usage } : {}) };
writeRuntimeJson(runtime, payload);
return left.label.localeCompare(right.label);
});
if (opts.json) {
writeRuntimeJson(runtime, { channels });
return;
}
const lines: string[] = [];
lines.push(theme.heading("Chat channels:"));
for (const plugin of plugins) {
const accounts = plugin.config.listAccountIds(cfg);
if (!accounts || accounts.length === 0) {
if (channels.length === 0) {
lines.push(theme.muted("- none"));
}
for (const entry of channels) {
const plugin = pluginById.get(entry.id);
lines.push(formatChannelSummaryLine(entry));
if (!plugin || entry.accounts.length === 0) {
continue;
}
for (const accountId of accounts) {
for (const accountId of entry.accounts) {
const snapshot = await buildChannelAccountSnapshot({
plugin,
cfg,
accountId,
});
lines.push(
formatAccountLine({
` ${formatAccountLine({
channel: plugin,
snapshot,
}),
}).slice(2)}`,
);
}
}
lines.push("");
lines.push(theme.heading("Auth providers (OAuth + API keys):"));
if (authProfiles.length === 0) {
lines.push(theme.muted("- none"));
} else {
for (const profile of authProfiles) {
const external = profile.isExternal ? theme.muted(" (synced)") : "";
lines.push(`- ${theme.accent(profile.id)} (${theme.success(profile.type)}${external})`);
}
}
runtime.log(lines.join("\n"));
if (includeUsage) {
runtime.log("");
const usage = await loadUsageWithProgress(runtime);
if (usage) {
const usageLines = formatUsageReportLines(usage);
if (usageLines.length > 0) {
usageLines[0] = theme.accent(usageLines[0]);
runtime.log(usageLines.join("\n"));
}
}
}
runtime.log("");
runtime.log(`Docs: ${formatDocsLink("/gateway/configuration", "gateway/configuration")}`);
runtime.log(`Docs: ${formatDocsLink("/channels", "channels")}`);
}