refactor(channels): move bootstrap channel logic behind extension seams

This commit is contained in:
Peter Steinberger
2026-04-04 04:52:53 +01:00
parent fff7e610df
commit bc457fd1b8
25 changed files with 602 additions and 249 deletions

View File

@@ -1,4 +1,5 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import { collectBlueBubblesStatusIssues } from "openclaw/plugin-sdk/bluebubbles";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
@@ -9,7 +10,6 @@ import {
import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
import {
buildProbeChannelStatusSummary,
collectBlueBubblesStatusIssues,
PAIRING_APPROVED_MESSAGE,
} from "openclaw/plugin-sdk/channel-status";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";

View File

@@ -8,3 +8,10 @@ export {
collectUnsupportedSecretRefConfigCandidates,
} from "./src/security-contract.js";
export { deriveLegacySessionChatType } from "./src/session-contract.js";
export function hasConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
return (
typeof params.env?.DISCORD_BOT_TOKEN === "string" &&
params.env.DISCORD_BOT_TOKEN.trim().length > 0
);
}

View File

@@ -0,0 +1,8 @@
export function hasConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
return (
typeof params.env?.IRC_HOST === "string" &&
params.env.IRC_HOST.trim().length > 0 &&
typeof params.env?.IRC_NICK === "string" &&
params.env.IRC_NICK.trim().length > 0
);
}

View File

@@ -3,3 +3,9 @@ export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export function hasConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
return ["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"].some(
(key) => typeof params.env?.[key] === "string" && params.env[key]?.trim().length > 0,
);
}

View File

@@ -3,9 +3,18 @@ export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export {
TELEGRAM_COMMAND_NAME_PATTERN,
normalizeTelegramCommandDescription,
normalizeTelegramCommandName,
resolveTelegramCustomCommands,
} from "./src/command-config.js";
export { parseTelegramTopicConversation } from "./src/topic-conversation.js";
export { singleAccountKeysToMove } from "./src/setup-contract.js";
export { buildTelegramModelsProviderChannelData } from "./src/command-ui.js";
export {
buildCommandsPaginationKeyboard,
buildTelegramModelsProviderChannelData,
} from "./src/command-ui.js";
export type {
TelegramInteractiveHandlerContext,
TelegramInteractiveHandlerRegistration,

View File

@@ -4,3 +4,10 @@ export {
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export { singleAccountKeysToMove } from "./src/setup-contract.js";
export function hasConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
return (
typeof params.env?.TELEGRAM_BOT_TOKEN === "string" &&
params.env.TELEGRAM_BOT_TOKEN.trim().length > 0
);
}

View File

@@ -3,6 +3,8 @@ type UnsupportedSecretRefConfigCandidate = {
value: unknown;
};
import { hasAnyWhatsAppAuth } from "./src/accounts.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -14,6 +16,12 @@ export const unsupportedSecretRefSurfacePatterns = [
export { resolveLegacyGroupSessionKey } from "./src/group-session-contract.js";
export function hasPersistedAuthState(params: {
cfg: import("openclaw/plugin-sdk/config-runtime").OpenClawConfig;
}): boolean {
return hasAnyWhatsAppAuth(params.cfg);
}
export function collectUnsupportedSecretRefConfigCandidates(
raw: unknown,
): UnsupportedSecretRefConfigCandidate[] {

View File

@@ -79,7 +79,7 @@ function buildChatChannelMetaById(): Record<ChatChannelId, ChatChannelMeta> {
if (!rawId || !CHAT_CHANNEL_ID_SET.has(rawId)) {
continue;
}
const id = rawId as ChatChannelId;
const id = rawId;
entries.set(
id,
toChatChannelMeta({
@@ -89,11 +89,6 @@ function buildChatChannelMetaById(): Record<ChatChannelId, ChatChannelMeta> {
);
}
const missingIds = CHAT_CHANNEL_ORDER.filter((id) => !entries.has(id));
if (missingIds.length > 0) {
throw new Error(`Missing bundled chat channel metadata for: ${missingIds.join(", ")}`);
}
return Object.freeze(Object.fromEntries(entries)) as Record<ChatChannelId, ChatChannelMeta>;
}

View File

@@ -1,38 +1,66 @@
// Keep built-in channel IDs in a leaf module so shared config/sandbox code can
// reference them without importing channel registry helpers that may pull in
// plugin runtime state.
export const CHAT_CHANNEL_ORDER = [
"telegram",
"whatsapp",
"discord",
"irc",
"googlechat",
"slack",
"signal",
"imessage",
"line",
] as const;
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number];
export type ChatChannelId = string;
export const CHANNEL_IDS = [...CHAT_CHANNEL_ORDER] as const;
const BUILT_IN_CHAT_CHANNEL_ALIAS_ENTRIES = [
["gchat", "googlechat"],
["google-chat", "googlechat"],
["imsg", "imessage"],
["internet-relay-chat", "irc"],
] as const satisfies ReadonlyArray<readonly [string, ChatChannelId]>;
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = Object.freeze(
Object.fromEntries(BUILT_IN_CHAT_CHANNEL_ALIAS_ENTRIES),
) as Record<string, ChatChannelId>;
type BundledChatChannelEntry = {
id: ChatChannelId;
aliases: readonly string[];
order: number;
};
function normalizeChannelKey(raw?: string | null): string | undefined {
const normalized = raw?.trim().toLowerCase();
return normalized || undefined;
}
function listBundledChatChannelEntries(): BundledChatChannelEntry[] {
return listBundledPluginMetadata({
includeChannelConfigs: false,
includeSyntheticChannelConfigs: false,
})
.flatMap((entry) => {
const channel =
entry.packageManifest && "channel" in entry.packageManifest
? entry.packageManifest.channel
: undefined;
const id = normalizeChannelKey(channel?.id);
if (!channel || !id) {
return [];
}
const aliases = (channel.aliases ?? [])
.map((alias) => normalizeChannelKey(alias))
.filter((alias): alias is string => Boolean(alias));
return [
{
id,
aliases,
order: typeof channel.order === "number" ? channel.order : Number.MAX_SAFE_INTEGER,
},
];
})
.toSorted(
(left, right) =>
left.order - right.order || left.id.localeCompare(right.id, "en", { sensitivity: "base" }),
);
}
const BUNDLED_CHAT_CHANNEL_ENTRIES = Object.freeze(listBundledChatChannelEntries());
const CHAT_CHANNEL_ID_SET = new Set(BUNDLED_CHAT_CHANNEL_ENTRIES.map((entry) => entry.id));
export const CHAT_CHANNEL_ORDER = Object.freeze(
BUNDLED_CHAT_CHANNEL_ENTRIES.map((entry) => entry.id),
);
export const CHANNEL_IDS = CHAT_CHANNEL_ORDER;
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = Object.freeze(
Object.fromEntries(
BUNDLED_CHAT_CHANNEL_ENTRIES.flatMap((entry) =>
entry.aliases.map((alias) => [alias, entry.id] as const),
),
),
) as Record<string, ChatChannelId>;
export function listChatChannelAliases(): string[] {
return Object.keys(CHAT_CHANNEL_ALIASES);
}
@@ -43,5 +71,5 @@ export function normalizeChatChannelId(raw?: string | null): ChatChannelId | nul
return null;
}
const resolved = CHAT_CHANNEL_ALIASES[normalized] ?? normalized;
return CHAT_CHANNEL_ORDER.includes(resolved) ? resolved : null;
return CHAT_CHANNEL_ID_SET.has(resolved) ? resolved : null;
}

View File

@@ -1,5 +1,4 @@
import type { OpenClawConfig } from "../config/config.js";
import { parseFeishuConversationId } from "../plugin-sdk/feishu-conversation.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import {
buildChannelKeyCandidates,
@@ -59,10 +58,6 @@ function buildChannelCandidates(
normalizeMessageChannel(params.channel ?? "") ?? params.channel?.trim().toLowerCase();
const groupId = params.groupId?.trim();
const sessionConversation = resolveSessionConversationRef(params.parentSessionKey);
const bundledParentOverrideFallbacks = resolveBundledParentOverrideFallbacks({
channel: normalizedChannel,
parentConversationId: sessionConversation?.rawId,
});
const parentOverrideFallbacks =
(normalizedChannel
? getChannelPlugin(
@@ -70,7 +65,7 @@ function buildChannelCandidates(
)?.conversationBindings?.buildModelOverrideParentCandidates?.({
parentConversationId: sessionConversation?.rawId,
})
: null) ?? bundledParentOverrideFallbacks;
: null) ?? [];
const groupConversationKind =
normalizeChatType(params.groupChatType ?? undefined) === "channel"
? "channel"
@@ -108,34 +103,6 @@ function buildChannelCandidates(
};
}
function resolveBundledParentOverrideFallbacks(params: {
channel?: string | null;
parentConversationId?: string | null;
}): string[] {
if (params.channel !== "feishu") {
return [];
}
const parsed = parseFeishuConversationId({
conversationId: params.parentConversationId ?? "",
});
if (!parsed) {
return [];
}
switch (parsed.scope) {
case "group_topic_sender":
return buildChannelKeyCandidates(
parsed.topicId ? `${parsed.chatId}:topic:${parsed.topicId}` : undefined,
parsed.chatId,
);
case "group_topic":
case "group_sender":
return buildChannelKeyCandidates(parsed.chatId);
case "group":
default:
return [];
}
}
export function resolveChannelModelOverride(
params: ChannelModelOverrideParams,
): ChannelModelOverride | null {

View File

@@ -1,11 +1,13 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import { discoverOpenClawPlugins } from "../../plugins/discovery.js";
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
import {
buildPluginLoaderAliasMap,
buildPluginLoaderJitiOptions,
resolveLoaderPackageRoot,
shouldPreferNativeJiti,
} from "../../plugins/sdk-alias.js";
@@ -15,12 +17,25 @@ const CONTRACT_SURFACE_BASENAMES = [
"contract-api.ts",
"contract-api.js",
] as const;
const PUBLIC_SURFACE_SOURCE_EXTENSIONS = [".ts", ".mts", ".js", ".mjs", ".cts", ".cjs"] as const;
const OPENCLAW_PACKAGE_ROOT =
resolveLoaderPackageRoot({
modulePath: fileURLToPath(import.meta.url),
moduleUrl: import.meta.url,
}) ?? fileURLToPath(new URL("../../..", import.meta.url));
const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url);
const RUNNING_FROM_BUILT_ARTIFACT =
CURRENT_MODULE_PATH.includes(`${path.sep}dist${path.sep}`) ||
CURRENT_MODULE_PATH.includes(`${path.sep}dist-runtime${path.sep}`);
type ContractSurfaceBasename = (typeof CONTRACT_SURFACE_BASENAMES)[number];
let cachedSurfaces: unknown[] | null = null;
let cachedSurfaceEntries: Array<{
pluginId: string;
surface: unknown;
}> | null = null;
const cachedPreferredSurfaceModules = new Map<string, unknown>();
function createModuleLoader() {
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
@@ -46,28 +61,87 @@ function createModuleLoader() {
const loadModule = createModuleLoader();
function resolveContractSurfaceModulePaths(rootDir: string | undefined): string[] {
function matchesPreferredBasename(
basename: ContractSurfaceBasename,
preferredBasename: ContractSurfaceBasename | undefined,
): boolean {
if (!preferredBasename) {
return true;
}
return basename.replace(/\.[^.]+$/u, "") === preferredBasename.replace(/\.[^.]+$/u, "");
}
function resolveDistPreferredModulePath(modulePath: string): string {
const compiledDistModulePath = modulePath.replace(
`${path.sep}dist-runtime${path.sep}`,
`${path.sep}dist${path.sep}`,
);
return compiledDistModulePath !== modulePath && fs.existsSync(compiledDistModulePath)
? compiledDistModulePath
: modulePath;
}
function resolveContractSurfaceModulePaths(
rootDir: string | undefined,
preferredBasename?: ContractSurfaceBasename,
): string[] {
if (typeof rootDir !== "string" || rootDir.length === 0) {
return [];
}
const modulePaths: string[] = [];
for (const basename of CONTRACT_SURFACE_BASENAMES) {
if (!matchesPreferredBasename(basename, preferredBasename)) {
continue;
}
const modulePath = path.join(rootDir, basename);
if (!fs.existsSync(modulePath)) {
continue;
}
const compiledDistModulePath = modulePath.replace(
`${path.sep}dist-runtime${path.sep}`,
`${path.sep}dist${path.sep}`,
);
// Prefer the compiled dist module over the dist-runtime shim so Jiti sees
// the full named export surface instead of only local wrapper exports.
if (compiledDistModulePath !== modulePath && fs.existsSync(compiledDistModulePath)) {
modulePaths.push(compiledDistModulePath);
modulePaths.push(resolveDistPreferredModulePath(modulePath));
}
return modulePaths;
}
function resolveSourceFirstContractSurfaceModulePaths(params: {
rootDir: string | undefined;
preferredBasename?: ContractSurfaceBasename;
}): string[] {
if (typeof params.rootDir !== "string" || params.rootDir.length === 0) {
return [];
}
if (RUNNING_FROM_BUILT_ARTIFACT) {
return resolveContractSurfaceModulePaths(params.rootDir, params.preferredBasename);
}
const dirName = path.basename(path.resolve(params.rootDir));
const sourceRoot = path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions", dirName);
const modulePaths: string[] = [];
for (const basename of CONTRACT_SURFACE_BASENAMES) {
if (!matchesPreferredBasename(basename, params.preferredBasename)) {
continue;
}
modulePaths.push(modulePath);
const sourceBaseName = basename.replace(/\.[^.]+$/u, "");
let sourceCandidatePath: string | null = null;
for (const ext of PUBLIC_SURFACE_SOURCE_EXTENSIONS) {
const candidate = path.join(sourceRoot, `${sourceBaseName}${ext}`);
if (fs.existsSync(candidate)) {
sourceCandidatePath = candidate;
break;
}
}
if (sourceCandidatePath) {
modulePaths.push(sourceCandidatePath);
continue;
}
const builtCandidates = resolveContractSurfaceModulePaths(params.rootDir, basename);
if (builtCandidates[0]) {
modulePaths.push(builtCandidates[0]);
}
}
return modulePaths;
}
@@ -91,7 +165,9 @@ function loadBundledChannelContractSurfaceEntries(): Array<{
if (manifest.origin !== "bundled" || manifest.channels.length === 0) {
continue;
}
const modulePaths = resolveContractSurfaceModulePaths(manifest.rootDir);
const modulePaths = resolveSourceFirstContractSurfaceModulePaths({
rootDir: manifest.rootDir,
});
if (modulePaths.length === 0) {
continue;
}
@@ -123,3 +199,44 @@ export function getBundledChannelContractSurfaceEntries(): Array<{
cachedSurfaceEntries ??= loadBundledChannelContractSurfaceEntries();
return cachedSurfaceEntries;
}
export function getBundledChannelContractSurfaceModule<T = unknown>(params: {
pluginId: string;
preferredBasename?: ContractSurfaceBasename;
}): T | null {
const cacheKey = `${params.pluginId}:${params.preferredBasename ?? "*"}`;
if (cachedPreferredSurfaceModules.has(cacheKey)) {
return (cachedPreferredSurfaceModules.get(cacheKey) ?? null) as T | null;
}
const discovery = discoverOpenClawPlugins({ cache: false });
const manifestRegistry = loadPluginManifestRegistry({
cache: false,
config: {},
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
});
const manifest = manifestRegistry.plugins.find(
(entry) =>
entry.origin === "bundled" && entry.channels.length > 0 && entry.id === params.pluginId,
);
if (!manifest) {
cachedPreferredSurfaceModules.set(cacheKey, null);
return null;
}
const modulePath = resolveSourceFirstContractSurfaceModulePaths({
rootDir: manifest.rootDir,
preferredBasename: params.preferredBasename,
})[0];
if (!modulePath) {
cachedPreferredSurfaceModules.set(cacheKey, null);
return null;
}
try {
const module = loadModule(modulePath)(modulePath) as T;
cachedPreferredSurfaceModules.set(cacheKey, module);
return module;
} catch {
cachedPreferredSurfaceModules.set(cacheKey, null);
return null;
}
}

View File

@@ -474,6 +474,26 @@ function expectNoCrossPluginSdkFacadeImports(file: string, imports: string[]): v
}
}
function expectCoreSourceStaysOffPluginSpecificSdkFacades(file: string, imports: string[]): void {
for (const specifier of imports) {
if (!specifier.includes("/plugin-sdk/")) {
continue;
}
const targetSubpath = specifier.split("/plugin-sdk/")[1]?.replace(/\.[cm]?[jt]sx?$/u, "") ?? "";
const targetExtensionId =
BUNDLED_EXTENSION_IDS.find(
(extensionId) =>
targetSubpath === extensionId || targetSubpath.startsWith(`${extensionId}-`),
) ?? null;
if (!targetExtensionId) {
continue;
}
expect.fail(
`${file} should not import plugin-specific SDK facades (${specifier}) from core production code. Use a neutral contract surface or plugin hook instead.`,
);
}
}
describe("channel import guardrails", () => {
it("keeps channel helper modules off their own SDK barrels", () => {
for (const source of SAME_CHANNEL_SDK_GUARDS) {
@@ -553,6 +573,15 @@ describe("channel import guardrails", () => {
}
});
it("keeps core production files off plugin-specific sdk facades", () => {
for (const file of collectCoreSourceFiles()) {
expectCoreSourceStaysOffPluginSpecificSdkFacades(
file,
getSourceAnalysis(file).importSpecifiers,
);
}
});
it("keeps extension-to-extension imports limited to approved public surfaces", () => {
for (const file of collectExtensionSourceFiles()) {
expectOnlyApprovedExtensionSeams(file, getSourceAnalysis(file).extensionImports);

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { isChannelConfigured } from "./channel-configured.js";
describe("isChannelConfigured", () => {
it("detects Telegram env configuration through the channel plugin seam", () => {
expect(isChannelConfigured({}, "telegram", { TELEGRAM_BOT_TOKEN: "token" })).toBe(true);
});
it("detects Discord env configuration through the channel plugin seam", () => {
expect(isChannelConfigured({}, "discord", { DISCORD_BOT_TOKEN: "token" })).toBe(true);
});
it("detects Slack env configuration through the channel plugin seam", () => {
expect(isChannelConfigured({}, "slack", { SLACK_BOT_TOKEN: "xoxb-test" })).toBe(true);
});
it("requires both IRC host and nick env vars through the channel plugin seam", () => {
expect(isChannelConfigured({}, "irc", { IRC_HOST: "irc.example.com" })).toBe(false);
expect(
isChannelConfigured({}, "irc", {
IRC_HOST: "irc.example.com",
IRC_NICK: "openclaw",
}),
).toBe(true);
});
it("still falls back to generic config presence for channels without a custom hook", () => {
expect(
isChannelConfigured(
{
channels: {
signal: {
httpPort: 8080,
},
},
},
"signal",
{},
),
).toBe(true);
});
});

View File

@@ -1,28 +1,12 @@
import { hasMeaningfulChannelConfig } from "../channels/config-presence.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
import { getBundledChannelContractSurfaceModule } from "../channels/plugins/contract-surfaces.js";
import { isRecord } from "../utils.js";
import type { OpenClawConfig } from "./config.js";
function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function accountsHaveKeys(value: unknown, keys: readonly string[]): boolean {
if (!isRecord(value)) {
return false;
}
for (const account of Object.values(value)) {
if (!isRecord(account)) {
continue;
}
for (const key of keys) {
if (hasNonEmptyString(account[key])) {
return true;
}
}
}
return false;
}
type ChannelConfiguredSurface = {
hasConfiguredState?: (params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv }) => boolean;
hasPersistedAuthState?: (params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv }) => boolean;
};
function resolveChannelConfig(
cfg: OpenClawConfig,
@@ -33,120 +17,31 @@ function resolveChannelConfig(
return isRecord(entry) ? entry : null;
}
type StructuredChannelConfigSpec = {
envAny?: readonly string[];
envAll?: readonly string[];
stringKeys?: readonly string[];
numberKeys?: readonly string[];
accountStringKeys?: readonly string[];
};
const STRUCTURED_CHANNEL_CONFIG_SPECS: Record<string, StructuredChannelConfigSpec> = {
telegram: {
envAny: ["TELEGRAM_BOT_TOKEN"],
stringKeys: ["botToken", "tokenFile"],
accountStringKeys: ["botToken", "tokenFile"],
},
discord: {
envAny: ["DISCORD_BOT_TOKEN"],
stringKeys: ["token"],
accountStringKeys: ["token"],
},
irc: {
envAll: ["IRC_HOST", "IRC_NICK"],
stringKeys: ["host", "nick"],
accountStringKeys: ["host", "nick"],
},
slack: {
envAny: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"],
stringKeys: ["botToken", "appToken", "userToken"],
accountStringKeys: ["botToken", "appToken", "userToken"],
},
signal: {
stringKeys: ["account", "httpUrl", "httpHost", "cliPath"],
numberKeys: ["httpPort"],
accountStringKeys: ["account", "httpUrl", "httpHost", "cliPath"],
},
imessage: {
stringKeys: ["cliPath"],
},
};
function envHasAnyKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean {
for (const key of keys) {
if (hasNonEmptyString(env[key])) {
return true;
}
}
return false;
}
function envHasAllKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean {
for (const key of keys) {
if (!hasNonEmptyString(env[key])) {
return false;
}
}
return keys.length > 0;
}
function hasAnyNumberKeys(entry: Record<string, unknown>, keys: readonly string[]): boolean {
for (const key of keys) {
if (typeof entry[key] === "number") {
return true;
}
}
return false;
}
function isStructuredChannelConfigured(
cfg: OpenClawConfig,
channelId: string,
env: NodeJS.ProcessEnv,
spec: StructuredChannelConfigSpec,
): boolean {
if (spec.envAny && envHasAnyKeys(env, spec.envAny)) {
return true;
}
if (spec.envAll && envHasAllKeys(env, spec.envAll)) {
return true;
}
const entry = resolveChannelConfig(cfg, channelId);
if (!entry) {
return false;
}
if (spec.stringKeys && spec.stringKeys.some((key) => hasNonEmptyString(entry[key]))) {
return true;
}
if (spec.numberKeys && hasAnyNumberKeys(entry, spec.numberKeys)) {
return true;
}
if (spec.accountStringKeys && accountsHaveKeys(entry.accounts, spec.accountStringKeys)) {
return true;
}
return hasMeaningfulChannelConfig(entry);
}
function isGenericChannelConfigured(cfg: OpenClawConfig, channelId: string): boolean {
const entry = resolveChannelConfig(cfg, channelId);
return hasMeaningfulChannelConfig(entry);
}
function getChannelConfiguredSurface(channelId: string): ChannelConfiguredSurface | null {
return getBundledChannelContractSurfaceModule<ChannelConfiguredSurface>({
pluginId: channelId,
preferredBasename: "contract-surfaces.ts",
});
}
export function isChannelConfigured(
cfg: OpenClawConfig,
channelId: string,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const pluginConfigured = getChannelPlugin(channelId)?.config.hasPersistedAuthState?.({
cfg,
env,
});
const surface = getChannelConfiguredSurface(channelId);
const pluginConfigured = surface?.hasConfiguredState?.({ cfg, env });
if (pluginConfigured) {
return true;
}
const spec = STRUCTURED_CHANNEL_CONFIG_SPECS[channelId];
if (spec) {
return isStructuredChannelConfigured(cfg, channelId, env, spec);
const pluginPersistedAuthState = surface?.hasPersistedAuthState?.({ cfg, env });
if (pluginPersistedAuthState) {
return true;
}
return isGenericChannelConfigured(cfg, channelId);
}

View File

@@ -0,0 +1,61 @@
import { getBundledChannelContractSurfaceModule } from "../channels/plugins/contract-surfaces.js";
export type TelegramCustomCommandInput = {
command?: string | null;
description?: string | null;
};
export type TelegramCustomCommandIssue = {
index: number;
field: "command" | "description";
message: string;
};
type TelegramCommandConfigContract = {
TELEGRAM_COMMAND_NAME_PATTERN: RegExp;
normalizeTelegramCommandName: (value: string) => string;
normalizeTelegramCommandDescription: (value: string) => string;
resolveTelegramCustomCommands: (params: {
commands?: TelegramCustomCommandInput[] | null;
reservedCommands?: Set<string>;
checkReserved?: boolean;
checkDuplicates?: boolean;
}) => {
commands: Array<{ command: string; description: string }>;
issues: TelegramCustomCommandIssue[];
};
};
function loadTelegramCommandConfigContract(): TelegramCommandConfigContract {
const contract = getBundledChannelContractSurfaceModule<TelegramCommandConfigContract>({
pluginId: "telegram",
preferredBasename: "contract-api.ts",
});
if (!contract) {
throw new Error("telegram command config contract surface is unavailable");
}
return contract;
}
export const TELEGRAM_COMMAND_NAME_PATTERN =
loadTelegramCommandConfigContract().TELEGRAM_COMMAND_NAME_PATTERN;
export function normalizeTelegramCommandName(value: string): string {
return loadTelegramCommandConfigContract().normalizeTelegramCommandName(value);
}
export function normalizeTelegramCommandDescription(value: string): string {
return loadTelegramCommandConfigContract().normalizeTelegramCommandDescription(value);
}
export function resolveTelegramCustomCommands(params: {
commands?: TelegramCustomCommandInput[] | null;
reservedCommands?: Set<string>;
checkReserved?: boolean;
checkDuplicates?: boolean;
}): {
commands: Array<{ command: string; description: string }>;
issues: TelegramCustomCommandIssue[];
} {
return loadTelegramCommandConfigContract().resolveTelegramCustomCommands(params);
}

View File

@@ -5,7 +5,7 @@ import {
normalizeTelegramCommandDescription,
normalizeTelegramCommandName,
resolveTelegramCustomCommands,
} from "../plugin-sdk/telegram-command-config.js";
} from "./telegram-command-config.js";
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
import {
ChannelHealthMonitorSchema,

View File

@@ -1,19 +1,8 @@
import { z } from "zod";
import { getBundledChannelRuntimeMap } from "./bundled-channel-config-runtime.js";
import type { ChannelsConfig } from "./types.channels.js";
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
import { ContextVisibilityModeSchema, GroupPolicySchema } from "./zod-schema.core.js";
import {
BlueBubblesConfigSchema,
DiscordConfigSchema,
GoogleChatConfigSchema,
IMessageConfigSchema,
IrcConfigSchema,
MSTeamsConfigSchema,
SignalConfigSchema,
SlackConfigSchema,
TelegramConfigSchema,
} from "./zod-schema.providers-core.js";
import { WhatsAppConfigSchema } from "./zod-schema.providers-whatsapp.js";
export * from "./zod-schema.providers-core.js";
export * from "./zod-schema.providers-whatsapp.js";
@@ -23,21 +12,7 @@ const ChannelModelByChannelSchema = z
.record(z.string(), z.record(z.string(), z.string()))
.optional();
const directChannelRuntimeSchemas = new Map<
string,
{ safeParse: (value: unknown) => ReturnType<z.ZodTypeAny["safeParse"]> }
>([
["bluebubbles", { safeParse: (value) => BlueBubblesConfigSchema.safeParse(value) }],
["discord", { safeParse: (value) => DiscordConfigSchema.safeParse(value) }],
["googlechat", { safeParse: (value) => GoogleChatConfigSchema.safeParse(value) }],
["imessage", { safeParse: (value) => IMessageConfigSchema.safeParse(value) }],
["irc", { safeParse: (value) => IrcConfigSchema.safeParse(value) }],
["msteams", { safeParse: (value) => MSTeamsConfigSchema.safeParse(value) }],
["signal", { safeParse: (value) => SignalConfigSchema.safeParse(value) }],
["slack", { safeParse: (value) => SlackConfigSchema.safeParse(value) }],
["telegram", { safeParse: (value) => TelegramConfigSchema.safeParse(value) }],
["whatsapp", { safeParse: (value) => WhatsAppConfigSchema.safeParse(value) }],
]);
const directChannelRuntimeSchemas = getBundledChannelRuntimeMap();
function addLegacyChannelAcpBindingIssues(
value: unknown,
@@ -86,7 +61,7 @@ function normalizeBundledChannelConfigs(
}
const parsed = runtimeSchema.safeParse(value[channelId]);
if (!parsed.success) {
for (const issue of parsed.error.issues) {
for (const issue of parsed.issues) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: issue.message ?? `Invalid channels.${channelId} config.`,

View File

@@ -311,12 +311,11 @@ export async function hydrateAttachmentParamsForAction(params: {
dryRun?: boolean;
mediaPolicy: AttachmentMediaPolicy;
}): Promise<void> {
const shouldHydrateBlueBubblesUploadFile =
params.action === "upload-file" && params.channel === "bluebubbles";
const shouldHydrateUploadFile = params.action === "upload-file";
if (
params.action !== "sendAttachment" &&
params.action !== "setGroupIcon" &&
!shouldHydrateBlueBubblesUploadFile
!shouldHydrateUploadFile
) {
return;
}
@@ -327,8 +326,7 @@ export async function hydrateAttachmentParamsForAction(params: {
args: params.args,
dryRun: params.dryRun,
mediaPolicy: params.mediaPolicy,
allowMessageCaptionFallback:
params.action === "sendAttachment" || shouldHydrateBlueBubblesUploadFile,
allowMessageCaptionFallback: params.action === "sendAttachment" || shouldHydrateUploadFile,
});
}

View File

@@ -1,3 +1,5 @@
import type { ChannelAccountSnapshot } from "../channels/plugins/types.core.js";
import type { ChannelStatusIssue } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import {
parseChatTargetPrefixesOrThrow,
@@ -5,6 +7,7 @@ import {
type ParsedChatTarget,
} from "./channel-targets.js";
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
import { asString, collectIssuesForEnabledAccounts, isRecord } from "./status-helpers.js";
// Narrow plugin-sdk surface for the bundled BlueBubbles plugin.
// Keep this list additive and scoped to the conversation-binding seam only.
@@ -263,6 +266,101 @@ export function resolveBlueBubblesConversationIdFromTarget(target: string): stri
return normalizeBlueBubblesAcpConversationId(target)?.conversationId;
}
type BlueBubblesAccountStatus = {
accountId?: unknown;
enabled?: unknown;
configured?: unknown;
running?: unknown;
baseUrl?: unknown;
lastError?: unknown;
probe?: unknown;
};
type BlueBubblesProbeResult = {
ok?: boolean;
status?: number | null;
error?: string | null;
};
function readBlueBubblesAccountStatus(
value: ChannelAccountSnapshot,
): BlueBubblesAccountStatus | null {
if (!isRecord(value)) {
return null;
}
return {
accountId: value.accountId,
enabled: value.enabled,
configured: value.configured,
running: value.running,
baseUrl: value.baseUrl,
lastError: value.lastError,
probe: value.probe,
};
}
function readBlueBubblesProbeResult(value: unknown): BlueBubblesProbeResult | null {
if (!isRecord(value)) {
return null;
}
return {
ok: typeof value.ok === "boolean" ? value.ok : undefined,
status: typeof value.status === "number" ? value.status : null,
error: asString(value.error) ?? null,
};
}
export function collectBlueBubblesStatusIssues(
accounts: ChannelAccountSnapshot[],
): ChannelStatusIssue[] {
return collectIssuesForEnabledAccounts({
accounts,
readAccount: readBlueBubblesAccountStatus,
collectIssues: ({ account, accountId, issues }) => {
const configured = account.configured === true;
const running = account.running === true;
const lastError = asString(account.lastError);
const probe = readBlueBubblesProbeResult(account.probe);
if (!configured) {
issues.push({
channel: "bluebubbles",
accountId,
kind: "config",
message: "Not configured (missing serverUrl or password).",
fix: "Run: openclaw channels add bluebubbles --http-url <server-url> --password <password>",
});
return;
}
if (probe && probe.ok === false) {
const errorDetail = probe.error
? `: ${probe.error}`
: probe.status
? ` (HTTP ${probe.status})`
: "";
issues.push({
channel: "bluebubbles",
accountId,
kind: "runtime",
message: `BlueBubbles server unreachable${errorDetail}`,
fix: "Check that the BlueBubbles server is running and accessible. Verify serverUrl and password in your config.",
});
}
if (running && lastError) {
issues.push({
channel: "bluebubbles",
accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
fix: "Check gateway logs for details. If the webhook is failing, verify the webhook URL is configured in BlueBubbles server settings.",
});
}
},
});
}
export { resolveAckReaction } from "../agents/identity.js";
export {
createActionGate,
@@ -305,7 +403,6 @@ export {
patchScopedAccountConfig,
} from "../channels/plugins/setup-helpers.js";
export { createAccountListHelpers } from "../channels/plugins/account-helpers.js";
export { collectBlueBubblesStatusIssues } from "../channels/plugins/status-issues/bluebubbles.js";
export type {
BaseProbeResult,
ChannelAccountSnapshot,

View File

@@ -1,5 +1,4 @@
export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js";
export { collectBlueBubblesStatusIssues } from "../channels/plugins/status-issues/bluebubbles.js";
export {
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,

View File

@@ -1,6 +1,6 @@
import { getBundledChannelContractSurfaceModule } from "../channels/plugins/contract-surfaces.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js";
export { buildCommandsPaginationKeyboard } from "../../extensions/telegram/api.js";
export {
createPreCryptoDirectDmAuthorizer,
resolveInboundDirectDmAccessWithRuntime,
@@ -86,6 +86,37 @@ export {
buildHelpMessage,
} from "../auto-reply/status.js";
type TelegramCommandUiContract = {
buildCommandsPaginationKeyboard: (
currentPage: number,
totalPages: number,
agentId?: string,
) => Array<Array<{ text: string; callback_data: string }>>;
};
function loadTelegramCommandUiContract(): TelegramCommandUiContract {
const contract = getBundledChannelContractSurfaceModule<TelegramCommandUiContract>({
pluginId: "telegram",
preferredBasename: "contract-api.ts",
});
if (!contract) {
throw new Error("telegram command ui contract surface is unavailable");
}
return contract;
}
export function buildCommandsPaginationKeyboard(
currentPage: number,
totalPages: number,
agentId?: string,
): Array<Array<{ text: string; callback_data: string }>> {
return loadTelegramCommandUiContract().buildCommandsPaginationKeyboard(
currentPage,
totalPages,
agentId,
);
}
export type ResolveSenderCommandAuthorizationParams = {
cfg: OpenClawConfig;
rawBody: string;

View File

@@ -49,4 +49,4 @@ export {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./bluebubbles-policy.js";
export { collectBlueBubblesStatusIssues } from "../channels/plugins/status-issues/bluebubbles.js";
export { collectBlueBubblesStatusIssues } from "./bluebubbles.js";

View File

@@ -1,13 +1,83 @@
import type { OpenClawConfig } from "./config-runtime.js";
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
type MatrixLegacyLog = {
info?: (message: string) => void;
warn?: (message: string) => void;
};
type MatrixLegacyCryptoPlan = {
accountId: string;
rootDir: string;
recoveryKeyPath: string;
statePath: string;
legacyCryptoPath: string;
homeserver: string;
userId: string;
accessToken: string;
deviceId: string | null;
};
type MatrixLegacyCryptoDetection = {
plans: MatrixLegacyCryptoPlan[];
warnings: string[];
};
type MatrixLegacyMigrationResult = {
migrated: boolean;
changes: string[];
warnings: string[];
};
type MatrixLegacyStatePlan = {
accountId: string;
legacyStoragePath: string;
legacyCryptoPath: string;
targetRootDir: string;
targetStoragePath: string;
targetCryptoPath: string;
selectionNote?: string;
};
type MatrixLegacyStateDetection = MatrixLegacyStatePlan | { warning: string } | null;
type MatrixMigrationSnapshotResult = {
created: boolean;
archivePath: string;
markerPath: string;
};
type MatrixRuntimeHeavyModule = {
autoPrepareLegacyMatrixCrypto: (typeof import("../../extensions/matrix/src/runtime-heavy-api.js"))["autoPrepareLegacyMatrixCrypto"];
detectLegacyMatrixCrypto: (typeof import("../../extensions/matrix/src/runtime-heavy-api.js"))["detectLegacyMatrixCrypto"];
autoMigrateLegacyMatrixState: (typeof import("../../extensions/matrix/src/runtime-heavy-api.js"))["autoMigrateLegacyMatrixState"];
detectLegacyMatrixState: (typeof import("../../extensions/matrix/src/runtime-heavy-api.js"))["detectLegacyMatrixState"];
hasActionableMatrixMigration: (typeof import("../../extensions/matrix/src/runtime-heavy-api.js"))["hasActionableMatrixMigration"];
hasPendingMatrixMigration: (typeof import("../../extensions/matrix/src/runtime-heavy-api.js"))["hasPendingMatrixMigration"];
maybeCreateMatrixMigrationSnapshot: (typeof import("../../extensions/matrix/src/runtime-heavy-api.js"))["maybeCreateMatrixMigrationSnapshot"];
autoPrepareLegacyMatrixCrypto: (params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log?: MatrixLegacyLog;
deps?: Partial<Record<string, unknown>>;
}) => Promise<MatrixLegacyMigrationResult>;
detectLegacyMatrixCrypto: (params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}) => MatrixLegacyCryptoDetection;
autoMigrateLegacyMatrixState: (params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log?: MatrixLegacyLog;
}) => Promise<MatrixLegacyMigrationResult>;
detectLegacyMatrixState: (params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}) => MatrixLegacyStateDetection;
hasActionableMatrixMigration: (params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}) => boolean;
hasPendingMatrixMigration: (params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv }) => boolean;
maybeCreateMatrixMigrationSnapshot: (params: {
trigger: string;
env?: NodeJS.ProcessEnv;
outputDir?: string;
log?: MatrixLegacyLog;
}) => Promise<MatrixMigrationSnapshotResult>;
};
function loadFacadeModule(): MatrixRuntimeHeavyModule {

View File

@@ -3,4 +3,6 @@ export {
normalizeTelegramCommandDescription,
normalizeTelegramCommandName,
resolveTelegramCustomCommands,
} from "../../extensions/telegram/src/command-config.js";
type TelegramCustomCommandInput,
type TelegramCustomCommandIssue,
} from "../config/telegram-command-config.js";

View File

@@ -607,6 +607,8 @@ describe("plugin-sdk subpath exports", () => {
"shouldComputeCommandAuthorized",
"shouldHandleTextCommands",
]);
expectSourceOmitsSnippet("command-auth", "../../extensions/");
expectSourceOmitsSnippet("matrix-runtime-heavy", "../../extensions/");
expectSourceMentions("channel-send-result", [
"attachChannelToResult",
"buildChannelSendResult",