diff --git a/src/config/channel-configured-shared.ts b/src/config/channel-configured-shared.ts new file mode 100644 index 00000000000..d18de8c7380 --- /dev/null +++ b/src/config/channel-configured-shared.ts @@ -0,0 +1,44 @@ +import { hasNonEmptyString } from "../infra/outbound/channel-target.js"; +import { isRecord } from "../utils.js"; +import type { OpenClawConfig } from "./config.js"; + +const STATIC_ENV_RULES: Record boolean)> = { + discord: ["DISCORD_BOT_TOKEN"], + slack: ["SLACK_BOT_TOKEN"], + telegram: ["TELEGRAM_BOT_TOKEN"], + irc: (env) => hasNonEmptyString(env.IRC_HOST) && hasNonEmptyString(env.IRC_NICK), +}; + +export function resolveChannelConfigRecord( + cfg: OpenClawConfig, + channelId: string, +): Record | null { + const channels = cfg.channels as Record | undefined; + const entry = channels?.[channelId]; + return isRecord(entry) ? entry : null; +} + +export function hasMeaningfulChannelConfigShallow(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return Object.keys(value).some((key) => key !== "enabled"); +} + +export function isStaticallyChannelConfigured( + cfg: OpenClawConfig, + channelId: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const staticRule = STATIC_ENV_RULES[channelId]; + if (Array.isArray(staticRule)) { + for (const envVar of staticRule) { + if (hasNonEmptyString(env[envVar])) { + return true; + } + } + } else if (staticRule?.(env)) { + return true; + } + return hasMeaningfulChannelConfigShallow(resolveChannelConfigRecord(cfg, channelId)); +} diff --git a/src/config/channel-configured.ts b/src/config/channel-configured.ts index da1bc3d0fda..900f35e7c7c 100644 --- a/src/config/channel-configured.ts +++ b/src/config/channel-configured.ts @@ -1,24 +1,12 @@ -import { hasMeaningfulChannelConfig } from "../channels/config-presence.js"; import { getBootstrapChannelPlugin } from "../channels/plugins/bootstrap-registry.js"; import { hasBundledChannelConfiguredState } from "../channels/plugins/configured-state.js"; import { hasBundledChannelPersistedAuthState } from "../channels/plugins/persisted-auth-state.js"; -import { isRecord } from "../utils.js"; +import { + hasMeaningfulChannelConfigShallow, + resolveChannelConfigRecord, +} from "./channel-configured-shared.js"; import type { OpenClawConfig } from "./config.js"; -function resolveChannelConfig( - cfg: OpenClawConfig, - channelId: string, -): Record | null { - const channels = cfg.channels as Record | undefined; - const entry = channels?.[channelId]; - return isRecord(entry) ? entry : null; -} - -function isGenericChannelConfigured(cfg: OpenClawConfig, channelId: string): boolean { - const entry = resolveChannelConfig(cfg, channelId); - return hasMeaningfulChannelConfig(entry); -} - export function isChannelConfigured( cfg: OpenClawConfig, channelId: string, @@ -31,7 +19,7 @@ export function isChannelConfigured( if (pluginPersistedAuthState) { return true; } - if (isGenericChannelConfigured(cfg, channelId)) { + if (hasMeaningfulChannelConfigShallow(resolveChannelConfigRecord(cfg, channelId))) { return true; } const plugin = getBootstrapChannelPlugin(channelId); diff --git a/src/plugin-activation-boundary.test.ts b/src/plugin-activation-boundary.test.ts index ab849181f71..afd89b6d43d 100644 --- a/src/plugin-activation-boundary.test.ts +++ b/src/plugin-activation-boundary.test.ts @@ -20,7 +20,7 @@ describe("plugin activation boundary", () => { let ambientImportsPromise: Promise | undefined; let configHelpersPromise: | Promise<{ - isChannelConfigured: typeof import("./config/channel-configured.js").isChannelConfigured; + isStaticallyChannelConfigured: typeof import("./config/channel-configured-shared.js").isStaticallyChannelConfigured; resolveEnvApiKey: typeof import("./agents/model-auth-env.js").resolveEnvApiKey; }> | undefined; @@ -58,10 +58,10 @@ describe("plugin activation boundary", () => { function importConfigHelpers() { configHelpersPromise ??= Promise.all([ - import("./config/channel-configured.js"), + import("./config/channel-configured-shared.js"), import("./agents/model-auth-env.js"), ]).then(([channelConfigured, modelAuthEnv]) => ({ - isChannelConfigured: channelConfigured.isChannelConfigured, + isStaticallyChannelConfigured: channelConfigured.isStaticallyChannelConfigured, resolveEnvApiKey: modelAuthEnv.resolveEnvApiKey, })); return configHelpersPromise; @@ -116,15 +116,20 @@ describe("plugin activation boundary", () => { }); it("does not load bundled plugins for config and env detection helpers", async () => { - const { isChannelConfigured, resolveEnvApiKey } = await importConfigHelpers(); + const { isStaticallyChannelConfigured, resolveEnvApiKey } = await importConfigHelpers(); - expect(isChannelConfigured({}, "telegram", { TELEGRAM_BOT_TOKEN: "token" })).toBe(true); - expect(isChannelConfigured({}, "discord", { DISCORD_BOT_TOKEN: "token" })).toBe(true); - expect(isChannelConfigured({}, "slack", { SLACK_BOT_TOKEN: "xoxb-test" })).toBe(true); + expect(isStaticallyChannelConfigured({}, "telegram", { TELEGRAM_BOT_TOKEN: "token" })).toBe( + true, + ); + expect(isStaticallyChannelConfigured({}, "discord", { DISCORD_BOT_TOKEN: "token" })).toBe(true); + expect(isStaticallyChannelConfigured({}, "slack", { SLACK_BOT_TOKEN: "xoxb-test" })).toBe(true); expect( - isChannelConfigured({}, "irc", { IRC_HOST: "irc.example.com", IRC_NICK: "openclaw" }), + isStaticallyChannelConfigured({}, "irc", { + IRC_HOST: "irc.example.com", + IRC_NICK: "openclaw", + }), ).toBe(true); - expect(isChannelConfigured({}, "whatsapp", {})).toBe(false); + expect(isStaticallyChannelConfigured({}, "whatsapp", {})).toBe(false); expect( resolveEnvApiKey("anthropic-vertex", { ANTHROPIC_VERTEX_USE_GCP_METADATA: "true",