Status: scope JSON plugin preload to configured channels

This commit is contained in:
Vincent Koc 2026-03-15 20:05:19 -07:00
parent d8b927ee6a
commit 986b772a89
5 changed files with 197 additions and 25 deletions

View File

@ -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<string>();
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;

View File

@ -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"] }),
);
});
});

View File

@ -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<typeof loadConfig>;
@ -25,15 +39,30 @@ function resolveChannelPluginIds(params: {
.map((plugin) => plugin.id);
}
function resolveConfiguredChannelPluginIds(params: {
config: ReturnType<typeof loadConfig>;
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;
}

View File

@ -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",
});
});
});

View File

@ -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";