refactor: install optional channel capabilities on demand

This commit is contained in:
Peter Steinberger 2026-03-19 03:39:06 +00:00
parent 19126033dd
commit 25015161fe
2 changed files with 90 additions and 10 deletions

View File

@ -7,6 +7,10 @@ import { channelsCapabilitiesCommand } from "./capabilities.js";
const logs: string[] = [];
const errors: string[] = [];
const mocks = vi.hoisted(() => ({
writeConfigFile: vi.fn(),
resolveInstallableChannelPlugin: vi.fn(),
}));
vi.mock("./shared.js", () => ({
requireValidConfig: vi.fn(async () => ({ channels: {} })),
@ -20,6 +24,18 @@ vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: vi.fn(),
}));
vi.mock("../../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../config/config.js")>();
return {
...actual,
writeConfigFile: mocks.writeConfigFile,
};
});
vi.mock("../channel-setup/channel-plugin-resolution.js", () => ({
resolveInstallableChannelPlugin: mocks.resolveInstallableChannelPlugin,
}));
const runtime = {
log: (...args: unknown[]) => {
logs.push(args.map(String).join(" "));
@ -77,6 +93,11 @@ describe("channelsCapabilitiesCommand", () => {
beforeEach(() => {
resetOutput();
vi.clearAllMocks();
mocks.writeConfigFile.mockResolvedValue(undefined);
mocks.resolveInstallableChannelPlugin.mockResolvedValue({
cfg: { channels: {} },
configChanged: false,
});
});
it("prints Slack bot + user scopes when user token is configured", async () => {
@ -106,6 +127,12 @@ describe("channelsCapabilitiesCommand", () => {
};
vi.mocked(listChannelPlugins).mockReturnValue([plugin]);
vi.mocked(getChannelPlugin).mockReturnValue(plugin);
mocks.resolveInstallableChannelPlugin.mockResolvedValue({
cfg: { channels: {} },
channelId: "slack",
plugin,
configChanged: false,
});
await channelsCapabilitiesCommand({ channel: "slack" }, runtime);
@ -139,6 +166,12 @@ describe("channelsCapabilitiesCommand", () => {
};
vi.mocked(listChannelPlugins).mockReturnValue([plugin]);
vi.mocked(getChannelPlugin).mockReturnValue(plugin);
mocks.resolveInstallableChannelPlugin.mockResolvedValue({
cfg: { channels: {} },
channelId: "msteams",
plugin,
configChanged: false,
});
await channelsCapabilitiesCommand({ channel: "msteams" }, runtime);
@ -146,4 +179,41 @@ describe("channelsCapabilitiesCommand", () => {
expect(output).toContain("ChannelMessage.Read.All (channel history)");
expect(output).toContain("Files.Read.All (files (OneDrive))");
});
it("installs an explicit optional channel before rendering capabilities", async () => {
const plugin = buildPlugin({
id: "whatsapp",
probe: { ok: true },
});
plugin.status = {
...plugin.status,
formatCapabilitiesProbe: () => [{ text: "Probe: linked" }],
};
mocks.resolveInstallableChannelPlugin.mockResolvedValue({
cfg: {
channels: {},
plugins: { entries: { whatsapp: { enabled: true } } },
},
channelId: "whatsapp",
plugin,
configChanged: true,
});
vi.mocked(listChannelPlugins).mockReturnValue([]);
vi.mocked(getChannelPlugin).mockReturnValue(undefined);
await channelsCapabilitiesCommand({ channel: "whatsapp" }, runtime);
expect(mocks.resolveInstallableChannelPlugin).toHaveBeenCalledWith(
expect.objectContaining({
rawChannel: "whatsapp",
allowInstall: true,
}),
);
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
plugins: { entries: { whatsapp: { enabled: true } } },
}),
);
expect(logs.join("\n")).toContain("Probe: linked");
});
});

View File

@ -1,5 +1,5 @@
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import {
createMessageActionDiscoveryContext,
resolveMessageActionDiscoveryForPlugin,
@ -10,10 +10,11 @@ import type {
ChannelCapabilitiesDisplayLine,
ChannelPlugin,
} from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { writeConfigFile, type OpenClawConfig } from "../../config/config.js";
import { danger } from "../../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { theme } from "../../terminal/theme.js";
import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js";
import { formatChannelAccountLabel, requireValidConfig } from "./shared.js";
export type ChannelsCapabilitiesOptions = {
@ -25,6 +26,7 @@ export type ChannelsCapabilitiesOptions = {
};
type ChannelCapabilitiesReport = {
plugin: ChannelPlugin;
channel: string;
accountId: string;
accountName?: string;
@ -183,6 +185,7 @@ async function resolveChannelReports(params: {
);
reports.push({
plugin,
channel: plugin.id,
accountId,
accountName:
@ -204,10 +207,11 @@ export async function channelsCapabilitiesCommand(
opts: ChannelsCapabilitiesOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) {
const loadedCfg = await requireValidConfig(runtime);
if (!loadedCfg) {
return;
}
let cfg = loadedCfg;
const timeoutMs = normalizeTimeout(opts.timeout, 10_000);
const rawChannel = typeof opts.channel === "string" ? opts.channel.trim().toLowerCase() : "";
const rawTarget = typeof opts.target === "string" ? opts.target.trim() : "";
@ -227,12 +231,18 @@ export async function channelsCapabilitiesCommand(
const selected =
!rawChannel || rawChannel === "all"
? plugins
: (() => {
const plugin = getChannelPlugin(rawChannel);
if (!plugin) {
return null;
: await (async () => {
const resolved = await resolveInstallableChannelPlugin({
cfg,
runtime,
rawChannel,
allowInstall: true,
});
if (resolved.configChanged) {
cfg = resolved.cfg;
await writeConfigFile(cfg);
}
return [plugin];
return resolved.plugin ? [resolved.plugin] : null;
})();
if (!selected || selected.length === 0) {
@ -280,7 +290,7 @@ export async function channelsCapabilitiesCommand(
lines.push(`Status: ${configuredLabel}, ${enabledLabel}`);
}
const probeLines =
getChannelPlugin(report.channel)?.status?.formatCapabilitiesProbe?.({
report.plugin.status?.formatCapabilitiesProbe?.({
probe: report.probe,
}) ?? formatGenericProbeLines(report.probe);
if (probeLines.length > 0) {