From 986b772a89d0fedb4e296545ec7224d19767c740 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:05:19 -0700 Subject: [PATCH] Status: scope JSON plugin preload to configured channels --- src/channels/config-presence.ts | 64 ++++++++++++++++----- src/cli/plugin-registry.test.ts | 95 ++++++++++++++++++++++++++++++++ src/cli/plugin-registry.ts | 49 ++++++++++++++-- src/commands/status.scan.test.ts | 12 ++-- src/commands/status.scan.ts | 2 +- 5 files changed, 197 insertions(+), 25 deletions(-) create mode 100644 src/cli/plugin-registry.test.ts diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index 792aa545a54..d9add345eeb 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -7,19 +7,19 @@ import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); const CHANNEL_ENV_PREFIXES = [ - "BLUEBUBBLES_", - "DISCORD_", - "GOOGLECHAT_", - "IRC_", - "LINE_", - "MATRIX_", - "MSTEAMS_", - "SIGNAL_", - "SLACK_", - "TELEGRAM_", - "WHATSAPP_", - "ZALOUSER_", - "ZALO_", + ["BLUEBUBBLES_", "bluebubbles"], + ["DISCORD_", "discord"], + ["GOOGLECHAT_", "googlechat"], + ["IRC_", "irc"], + ["LINE_", "line"], + ["MATRIX_", "matrix"], + ["MSTEAMS_", "msteams"], + ["SIGNAL_", "signal"], + ["SLACK_", "slack"], + ["TELEGRAM_", "telegram"], + ["WHATSAPP_", "whatsapp"], + ["ZALOUSER_", "zalouser"], + ["ZALO_", "zalo"], ] as const; function hasNonEmptyString(value: unknown): boolean { @@ -60,13 +60,49 @@ function hasWhatsAppAuthState(env: NodeJS.ProcessEnv): boolean { } } +export function listPotentialConfiguredChannelIds( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const configuredChannelIds = new Set(); + const channels = isRecord(cfg.channels) ? cfg.channels : null; + if (channels) { + for (const [key, value] of Object.entries(channels)) { + if (IGNORED_CHANNEL_CONFIG_KEYS.has(key)) { + continue; + } + if (recordHasKeys(value)) { + configuredChannelIds.add(key); + } + } + } + + for (const [key, value] of Object.entries(env)) { + if (!hasNonEmptyString(value)) { + continue; + } + for (const [prefix, channelId] of CHANNEL_ENV_PREFIXES) { + if (key.startsWith(prefix)) { + configuredChannelIds.add(channelId); + } + } + if (key === "TELEGRAM_BOT_TOKEN") { + configuredChannelIds.add("telegram"); + } + } + if (hasWhatsAppAuthState(env)) { + configuredChannelIds.add("whatsapp"); + } + return [...configuredChannelIds]; +} + function hasEnvConfiguredChannel(env: NodeJS.ProcessEnv): boolean { for (const [key, value] of Object.entries(env)) { if (!hasNonEmptyString(value)) { continue; } if ( - CHANNEL_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)) || + CHANNEL_ENV_PREFIXES.some(([prefix]) => key.startsWith(prefix)) || key === "TELEGRAM_BOT_TOKEN" ) { return true; diff --git a/src/cli/plugin-registry.test.ts b/src/cli/plugin-registry.test.ts new file mode 100644 index 00000000000..f9751d5fed8 --- /dev/null +++ b/src/cli/plugin-registry.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), + resolveDefaultAgentId: vi.fn(() => "main"), + loadConfig: vi.fn(), + loadOpenClawPlugins: vi.fn(), + loadPluginManifestRegistry: vi.fn(), + getActivePluginRegistry: vi.fn(), +})); + +vi.mock("../agents/agent-scope.js", () => ({ + resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, + resolveDefaultAgentId: mocks.resolveDefaultAgentId, +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: mocks.loadConfig, +})); + +vi.mock("../plugins/loader.js", () => ({ + loadOpenClawPlugins: mocks.loadOpenClawPlugins, +})); + +vi.mock("../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry: mocks.loadPluginManifestRegistry, +})); + +vi.mock("../plugins/runtime.js", () => ({ + getActivePluginRegistry: mocks.getActivePluginRegistry, +})); + +describe("ensurePluginRegistryLoaded", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.loadConfig.mockReturnValue({ + plugins: { enabled: true }, + channels: { telegram: { enabled: false } }, + }); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { id: "telegram", channels: ["telegram"] }, + { id: "slack", channels: ["slack"] }, + { id: "openai", channels: [] }, + ], + }); + mocks.getActivePluginRegistry.mockReturnValue({ + plugins: [], + channels: [], + tools: [], + }); + }); + + it("loads only configured channel plugins for configured-channels scope", async () => { + const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); + + ensurePluginRegistryLoaded({ scope: "configured-channels" }); + + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["telegram"], + }), + ); + }); + + it("reloads when escalating from configured-channels to channels", async () => { + mocks.getActivePluginRegistry + .mockReturnValueOnce({ + plugins: [], + channels: [], + tools: [], + }) + .mockReturnValue({ + plugins: [{ id: "telegram" }], + channels: [{ plugin: { id: "telegram" } }], + tools: [], + }); + + const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); + + ensurePluginRegistryLoaded({ scope: "configured-channels" }); + ensurePluginRegistryLoaded({ scope: "channels" }); + + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2); + expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ onlyPluginIds: ["telegram"] }), + ); + expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ onlyPluginIds: ["telegram", "slack"] }), + ); + }); +}); diff --git a/src/cli/plugin-registry.ts b/src/cli/plugin-registry.ts index aad181eff7f..f51a57d7fda 100644 --- a/src/cli/plugin-registry.ts +++ b/src/cli/plugin-registry.ts @@ -1,4 +1,5 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; @@ -7,9 +8,22 @@ import { getActivePluginRegistry } from "../plugins/runtime.js"; import type { PluginLogger } from "../plugins/types.js"; const log = createSubsystemLogger("plugins"); -let pluginRegistryLoaded: "none" | "channels" | "all" = "none"; +let pluginRegistryLoaded: "none" | "configured-channels" | "channels" | "all" = "none"; -export type PluginRegistryScope = "channels" | "all"; +export type PluginRegistryScope = "configured-channels" | "channels" | "all"; + +function scopeRank(scope: typeof pluginRegistryLoaded): number { + switch (scope) { + case "none": + return 0; + case "configured-channels": + return 1; + case "channels": + return 2; + case "all": + return 3; + } +} function resolveChannelPluginIds(params: { config: ReturnType; @@ -25,15 +39,30 @@ function resolveChannelPluginIds(params: { .map((plugin) => plugin.id); } +function resolveConfiguredChannelPluginIds(params: { + config: ReturnType; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + const configuredChannelIds = new Set( + listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), + ); + if (configuredChannelIds.size === 0) { + return []; + } + return resolveChannelPluginIds(params).filter((pluginId) => configuredChannelIds.has(pluginId)); +} + export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistryScope }): void { const scope = options?.scope ?? "all"; - if (pluginRegistryLoaded === "all" || pluginRegistryLoaded === scope) { + if (scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) { return; } const active = getActivePluginRegistry(); // Tests (and callers) can pre-seed a registry (e.g. `test/setup.ts`); avoid // doing an expensive load when we already have plugins/channels/tools. if ( + pluginRegistryLoaded === "none" && active && (active.plugins.length > 0 || active.channels.length > 0 || active.tools.length > 0) ) { @@ -52,15 +81,23 @@ export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistrySco config, workspaceDir, logger, - ...(scope === "channels" + ...(scope === "configured-channels" ? { - onlyPluginIds: resolveChannelPluginIds({ + onlyPluginIds: resolveConfiguredChannelPluginIds({ config, workspaceDir, env: process.env, }), } - : {}), + : scope === "channels" + ? { + onlyPluginIds: resolveChannelPluginIds({ + config, + workspaceDir, + env: process.env, + }), + } + : {}), }); pluginRegistryLoaded = scope; } diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 55f323f0b4a..122e10076bf 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -194,7 +194,7 @@ describe("scanStatus", () => { expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); }); - it("preloads channel plugins for status --json when channel config exists", async () => { + it("preloads configured channel plugins for status --json when channel config exists", async () => { mocks.readBestEffortConfig.mockResolvedValue({ session: {}, plugins: { enabled: false }, @@ -245,7 +245,9 @@ describe("scanStatus", () => { await scanStatus({ json: true }, {} as never); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ + scope: "configured-channels", + }); expect(mocks.probeGateway).toHaveBeenCalledWith( expect.objectContaining({ detailLevel: "presence" }), ); @@ -254,7 +256,7 @@ describe("scanStatus", () => { ); }); - it("preloads channel plugins for status --json when channel auth is env-only", async () => { + it("preloads configured channel plugins for status --json when channel auth is env-only", async () => { const prevMatrixToken = process.env.MATRIX_ACCESS_TOKEN; process.env.MATRIX_ACCESS_TOKEN = "token"; mocks.readBestEffortConfig.mockResolvedValue({ @@ -313,6 +315,8 @@ describe("scanStatus", () => { } } - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ + scope: "configured-channels", + }); }); }); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 88dd21e7177..7f1380964d5 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -202,7 +202,7 @@ async function scanStatusJsonFast(opts: { }); if (hasPotentialConfiguredChannels(cfg)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); - ensurePluginRegistryLoaded({ scope: "channels" }); + ensurePluginRegistryLoaded({ scope: "configured-channels" }); } const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";