refactor: collapse bundled channel metadata into plugin manifests

This commit is contained in:
Peter Steinberger 2026-03-27 02:29:15 +00:00
parent ea60bc01b9
commit dd098596cf
No known key found for this signature in database
23 changed files with 15042 additions and 15094 deletions

View File

@ -646,17 +646,17 @@
"canon:check:json": "node scripts/canon.mjs check --json",
"canon:enforce": "node scripts/canon.mjs enforce --json",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm check:no-conflict-markers && pnpm check:host-env-policy:swift && pnpm check:bundled-channel-config-metadata && pnpm check:base-config-schema && pnpm check:bundled-plugin-metadata && pnpm check:bundled-provider-auth-env-vars && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:plugins:plugin-sdk-subpaths-exported && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:extensions:no-relative-outside-package && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
"check": "pnpm check:no-conflict-markers && pnpm check:host-env-policy:swift && pnpm check:base-config-schema && pnpm check:bundled-plugin-metadata && pnpm check:bundled-provider-auth-env-vars && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:plugins:plugin-sdk-subpaths-exported && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:extensions:no-relative-outside-package && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
"check:base-config-schema": "node --import tsx scripts/generate-base-config-schema.ts --check",
"check:bundled-channel-config-metadata": "node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check",
"check:bundled-channel-config-metadata": "node scripts/generate-bundled-plugin-metadata.mjs --check",
"check:bundled-plugin-metadata": "node scripts/generate-bundled-plugin-metadata.mjs --check",
"check:bundled-provider-auth-env-vars": "node scripts/generate-bundled-provider-auth-env-vars.mjs --check",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links",
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
"check:no-conflict-markers": "node scripts/check-no-conflict-markers.mjs",
"config:channels:check": "node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check",
"config:channels:gen": "node --import tsx scripts/generate-bundled-channel-config-metadata.ts --write",
"config:channels:check": "node scripts/generate-bundled-plugin-metadata.mjs --check",
"config:channels:gen": "node scripts/generate-bundled-plugin-metadata.mjs",
"config:docs:check": "node --import tsx scripts/generate-config-doc-baseline.ts --check",
"config:docs:gen": "node --import tsx scripts/generate-config-doc-baseline.ts --write",
"config:schema:check": "node --import tsx scripts/generate-base-config-schema.ts --check",

View File

@ -1,212 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { loadChannelConfigSurfaceModule } from "./load-channel-config-surface.ts";
const GENERATED_BY = "scripts/generate-bundled-channel-config-metadata.ts";
const DEFAULT_OUTPUT_PATH = "src/config/bundled-channel-config-metadata.generated.ts";
type BundledPluginSource = {
dirName: string;
pluginDir: string;
manifestPath: string;
manifest: {
id: string;
channels?: unknown;
name?: string;
description?: string;
} & Record<string, unknown>;
packageJson?: Record<string, unknown>;
};
const { collectBundledPluginSources } = (await import(
new URL("./lib/bundled-plugin-source-utils.mjs", import.meta.url).href
)) as {
collectBundledPluginSources: (params?: {
repoRoot?: string;
requirePackageJson?: boolean;
}) => BundledPluginSource[];
};
const { formatGeneratedModule } = (await import(
new URL("./lib/format-generated-module.mjs", import.meta.url).href
)) as {
formatGeneratedModule: (
source: string,
options: {
repoRoot: string;
outputPath: string;
errorLabel: string;
},
) => string;
};
const { writeGeneratedOutput } = (await import(
new URL("./lib/generated-output-utils.mjs", import.meta.url).href
)) as {
writeGeneratedOutput: (params: {
repoRoot: string;
outputPath: string;
next: string;
check?: boolean;
}) => {
changed: boolean;
wrote: boolean;
outputPath: string;
};
};
type BundledChannelConfigMetadata = {
pluginId: string;
channelId: string;
label?: string;
description?: string;
schema: Record<string, unknown>;
uiHints?: Record<string, unknown>;
};
function resolveChannelConfigSchemaModulePath(rootDir: string): string | null {
const candidates = [
path.join(rootDir, "src", "config-schema.ts"),
path.join(rootDir, "src", "config-schema.js"),
path.join(rootDir, "src", "config-schema.mts"),
path.join(rootDir, "src", "config-schema.mjs"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function resolvePackageChannelMeta(source: BundledPluginSource) {
const openclawMeta =
source.packageJson &&
typeof source.packageJson === "object" &&
!Array.isArray(source.packageJson) &&
"openclaw" in source.packageJson
? (source.packageJson.openclaw as Record<string, unknown> | undefined)
: undefined;
const channelMeta =
openclawMeta &&
typeof openclawMeta.channel === "object" &&
openclawMeta.channel &&
!Array.isArray(openclawMeta.channel)
? (openclawMeta.channel as Record<string, unknown>)
: undefined;
return channelMeta;
}
function resolveRootLabel(source: BundledPluginSource, channelId: string): string | undefined {
const channelMeta = resolvePackageChannelMeta(source);
if (channelMeta?.id === channelId && typeof channelMeta.label === "string") {
return channelMeta.label.trim();
}
if (typeof source.manifest?.name === "string" && source.manifest.name.trim()) {
return source.manifest.name.trim();
}
return undefined;
}
function resolveRootDescription(
source: BundledPluginSource,
channelId: string,
): string | undefined {
const channelMeta = resolvePackageChannelMeta(source);
if (channelMeta?.id === channelId && typeof channelMeta.blurb === "string") {
return channelMeta.blurb.trim();
}
if (typeof source.manifest?.description === "string" && source.manifest.description.trim()) {
return source.manifest.description.trim();
}
return undefined;
}
function formatTypeScriptModule(source: string, outputPath: string, repoRoot: string): string {
return formatGeneratedModule(source, {
repoRoot,
outputPath,
errorLabel: "bundled channel config metadata",
});
}
export async function collectBundledChannelConfigMetadata(params?: { repoRoot?: string }) {
const repoRoot = path.resolve(params?.repoRoot ?? process.cwd());
const sources = collectBundledPluginSources({ repoRoot, requirePackageJson: true });
const entries: BundledChannelConfigMetadata[] = [];
for (const source of sources) {
const channelIds = Array.isArray(source.manifest?.channels)
? source.manifest.channels.filter(
(entry: unknown): entry is string => typeof entry === "string" && entry.trim().length > 0,
)
: [];
if (channelIds.length === 0) {
continue;
}
const modulePath = resolveChannelConfigSchemaModulePath(source.pluginDir);
if (!modulePath) {
continue;
}
const surface = await loadChannelConfigSurfaceModule(modulePath, { repoRoot });
if (!surface?.schema) {
continue;
}
for (const channelId of channelIds) {
const label = resolveRootLabel(source, channelId);
const description = resolveRootDescription(source, channelId);
entries.push({
pluginId: String(source.manifest.id),
channelId,
...(label ? { label } : {}),
...(description ? { description } : {}),
schema: surface.schema,
...(Object.keys(surface.uiHints ?? {}).length > 0 ? { uiHints: surface.uiHints } : {}),
});
}
}
return entries.toSorted((left, right) => left.channelId.localeCompare(right.channelId));
}
export async function writeBundledChannelConfigMetadataModule(params?: {
repoRoot?: string;
outputPath?: string;
check?: boolean;
}) {
const repoRoot = path.resolve(params?.repoRoot ?? process.cwd());
const outputPath = params?.outputPath ?? DEFAULT_OUTPUT_PATH;
const entries = await collectBundledChannelConfigMetadata({ repoRoot });
const next = formatTypeScriptModule(
`// Auto-generated by ${GENERATED_BY}. Do not edit directly.
export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = ${JSON.stringify(entries, null, 2)} as const;
`,
outputPath,
repoRoot,
);
return writeGeneratedOutput({
repoRoot,
outputPath,
next,
check: params?.check,
});
}
if (import.meta.url === new URL(process.argv[1] ?? "", "file://").href) {
const check = process.argv.includes("--check");
const result = await writeBundledChannelConfigMetadataModule({ check });
if (!result.changed) {
process.exitCode = 0;
} else if (check) {
console.error(
`[bundled-channel-config-metadata] stale generated output at ${path.relative(process.cwd(), result.outputPath)}`,
);
process.exitCode = 1;
} else {
console.log(
`[bundled-channel-config-metadata] wrote ${path.relative(process.cwd(), result.outputPath)}`,
);
}
}

View File

@ -17,16 +17,17 @@ export type BundledPluginMetadataEntry = {
export function collectBundledPluginMetadata(params?: {
repoRoot?: string;
}): BundledPluginMetadataEntry[];
}): Promise<BundledPluginMetadataEntry[]>;
export function renderBundledPluginMetadataModule(entries: BundledPluginMetadataEntry[]): string;
export function writeBundledPluginMetadataModule(params?: {
repoRoot?: string;
outputPath?: string;
entriesOutputPath?: string;
check?: boolean;
}): {
}): Promise<{
changed: boolean;
wrote: boolean;
outputPath: string;
};
outputPaths: string[];
}>;

View File

@ -1,3 +1,5 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { collectBundledPluginSources } from "./lib/bundled-plugin-source-utils.mjs";
import { formatGeneratedModule } from "./lib/format-generated-module.mjs";
@ -134,12 +136,131 @@ function normalizePluginManifest(raw) {
...(typeof raw.description === "string" ? { description: raw.description.trim() } : {}),
...(typeof raw.version === "string" ? { version: raw.version.trim() } : {}),
...(normalizeObject(raw.uiHints) ? { uiHints: raw.uiHints } : {}),
...(normalizeObject(raw.channelConfigs) ? { channelConfigs: raw.channelConfigs } : {}),
...(normalizeManifestContracts(raw.contracts)
? { contracts: normalizeManifestContracts(raw.contracts) }
: {}),
};
}
function resolvePackageChannelMeta(packageJson) {
const openclawMeta =
packageJson &&
typeof packageJson === "object" &&
!Array.isArray(packageJson) &&
"openclaw" in packageJson
? packageJson.openclaw
: undefined;
if (!openclawMeta || typeof openclawMeta !== "object" || Array.isArray(openclawMeta)) {
return undefined;
}
const channelMeta = openclawMeta.channel;
if (!channelMeta || typeof channelMeta !== "object" || Array.isArray(channelMeta)) {
return undefined;
}
return channelMeta;
}
function resolveChannelConfigSchemaModulePath(rootDir) {
const candidates = [
path.join(rootDir, "src", "config-schema.ts"),
path.join(rootDir, "src", "config-schema.js"),
path.join(rootDir, "src", "config-schema.mts"),
path.join(rootDir, "src", "config-schema.mjs"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function resolveRootLabel(source, channelId) {
const channelMeta = resolvePackageChannelMeta(source.packageJson);
if (channelMeta?.id === channelId && typeof channelMeta.label === "string") {
return channelMeta.label.trim();
}
if (typeof source.manifest?.name === "string" && source.manifest.name.trim()) {
return source.manifest.name.trim();
}
return undefined;
}
function resolveRootDescription(source, channelId) {
const channelMeta = resolvePackageChannelMeta(source.packageJson);
if (channelMeta?.id === channelId && typeof channelMeta.blurb === "string") {
return channelMeta.blurb.trim();
}
if (typeof source.manifest?.description === "string" && source.manifest.description.trim()) {
return source.manifest.description.trim();
}
return undefined;
}
async function collectBundledChannelConfigsForSource({ source, manifest }) {
const channelIds = Array.isArray(manifest.channels)
? manifest.channels.filter((entry) => typeof entry === "string" && entry.trim())
: [];
const existingChannelConfigs = normalizeObject(manifest.channelConfigs)
? { ...manifest.channelConfigs }
: {};
if (channelIds.length === 0) {
return Object.keys(existingChannelConfigs).length > 0 ? existingChannelConfigs : undefined;
}
const modulePath = resolveChannelConfigSchemaModulePath(source.pluginDir);
if (!modulePath || !fs.existsSync(modulePath)) {
return Object.keys(existingChannelConfigs).length > 0 ? existingChannelConfigs : undefined;
}
const surfaceJson = execFileSync(
process.execPath,
["--import", "tsx", "scripts/load-channel-config-surface.ts", modulePath],
{
// Run from the host repo so the generator always resolves its own loader/tooling,
// even when inspecting a temporary or alternate repo root.
cwd: FORMATTER_CWD,
encoding: "utf8",
},
);
const surface = JSON.parse(surfaceJson);
if (!surface?.schema) {
return Object.keys(existingChannelConfigs).length > 0 ? existingChannelConfigs : undefined;
}
for (const channelId of channelIds) {
const existing =
existingChannelConfigs[channelId] &&
typeof existingChannelConfigs[channelId] === "object" &&
!Array.isArray(existingChannelConfigs[channelId])
? existingChannelConfigs[channelId]
: undefined;
const label = existing?.label ?? resolveRootLabel(source, channelId);
const description = existing?.description ?? resolveRootDescription(source, channelId);
const uiHints =
surface.uiHints || existing?.uiHints
? {
...(surface.uiHints && Object.keys(surface.uiHints).length > 0
? { ...surface.uiHints }
: {}),
...(existing?.uiHints && Object.keys(existing.uiHints).length > 0
? { ...existing.uiHints }
: {}),
}
: undefined;
existingChannelConfigs[channelId] = {
schema: surface.schema,
...(uiHints && Object.keys(uiHints).length > 0 ? { uiHints } : {}),
...(label ? { label } : {}),
...(description ? { description } : {}),
};
}
return Object.keys(existingChannelConfigs).length > 0 ? existingChannelConfigs : undefined;
}
function formatTypeScriptModule(source, { outputPath }) {
return formatGeneratedModule(source, {
repoRoot: FORMATTER_CWD,
@ -161,7 +282,7 @@ function normalizeGeneratedImportPath(dirName, builtPath) {
return `../../extensions/${dirName}/${String(builtPath).replace(/^\.\//u, "")}`;
}
export function collectBundledPluginMetadata(params = {}) {
export async function collectBundledPluginMetadata(params = {}) {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const entries = [];
for (const source of collectBundledPluginSources({ repoRoot, requirePackageJson: true })) {
@ -192,6 +313,10 @@ export function collectBundledPluginMetadata(params = {}) {
built: rewriteEntryToBuiltPath(packageManifest.setupEntry.trim()),
}
: undefined;
const channelConfigs = await collectBundledChannelConfigsForSource({ source, manifest });
if (channelConfigs) {
manifest.channelConfigs = channelConfigs;
}
entries.push({
dirName: source.dirName,
@ -264,9 +389,9 @@ ${imports}
`;
}
export function writeBundledPluginMetadataModule(params = {}) {
export async function writeBundledPluginMetadataModule(params = {}) {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const entries = collectBundledPluginMetadata({ repoRoot });
const entries = await collectBundledPluginMetadata({ repoRoot });
const outputPath = path.resolve(repoRoot, params.outputPath ?? DEFAULT_OUTPUT_PATH);
const entriesOutputPath = path.resolve(
repoRoot,
@ -299,7 +424,7 @@ export function writeBundledPluginMetadataModule(params = {}) {
if (import.meta.url === new URL(process.argv[1] ?? "", "file:").href) {
const check = process.argv.includes("--check");
const result = writeBundledPluginMetadataModule({ check });
const result = await writeBundledPluginMetadataModule({ check });
if (!result.changed) {
process.exitCode = 0;
} else if (check) {

View File

@ -1,18 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { buildChannelConfigSchema } from "../src/channels/plugins/config-schema.js";
type BuiltChannelConfigSurface = {
schema: Record<string, unknown>;
uiHints?: Record<string, unknown>;
};
type JsonSchemaCapableSurface = {
toJSONSchema?: (params?: Record<string, unknown>) => unknown;
uiHints?: Record<string, unknown>;
};
function isBuiltChannelConfigSchema(value: unknown): value is BuiltChannelConfigSurface {
function isBuiltChannelConfigSchema(
value: unknown,
): value is { schema: Record<string, unknown>; uiHints?: Record<string, unknown> } {
if (!value || typeof value !== "object") {
return false;
}
@ -20,32 +13,9 @@ function isBuiltChannelConfigSchema(value: unknown): value is BuiltChannelConfig
return Boolean(candidate.schema && typeof candidate.schema === "object");
}
function buildSchemaSurface(value: unknown): BuiltChannelConfigSurface | null {
if (!value || typeof value !== "object") {
return null;
}
const candidate = value as JsonSchemaCapableSurface;
if (typeof candidate.toJSONSchema === "function") {
return {
schema: candidate.toJSONSchema({
target: "draft-07",
unrepresentable: "any",
}) as Record<string, unknown>,
...(candidate.uiHints ? { uiHints: candidate.uiHints } : {}),
};
}
return {
schema: {
type: "object",
additionalProperties: true,
},
...(candidate.uiHints ? { uiHints: candidate.uiHints } : {}),
};
}
function resolveChannelConfigSurfaceExport(
function resolveConfigSchemaExport(
imported: Record<string, unknown>,
): BuiltChannelConfigSurface | null {
): { schema: Record<string, unknown>; uiHints?: Record<string, unknown> } | null {
for (const [name, value] of Object.entries(imported)) {
if (name.endsWith("ChannelConfigSchema") && isBuiltChannelConfigSchema(value)) {
return value;
@ -59,9 +29,8 @@ function resolveChannelConfigSurfaceExport(
if (isBuiltChannelConfigSchema(value)) {
return value;
}
const wrapped = buildSchemaSurface(value);
if (wrapped) {
return wrapped;
if (value && typeof value === "object") {
return buildChannelConfigSchema(value as never);
}
}
@ -97,7 +66,8 @@ function shouldRetryViaIsolatedCopy(error: unknown): boolean {
return false;
}
const code = "code" in error ? error.code : undefined;
return code === "ERR_MODULE_NOT_FOUND";
const message = "message" in error && typeof error.message === "string" ? error.message : "";
return code === "ERR_MODULE_NOT_FOUND" && message.includes(`${path.sep}node_modules${path.sep}`);
}
const SOURCE_FILE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
@ -215,7 +185,7 @@ export async function loadChannelConfigSurfaceModule(
try {
const imported = (await import(pathToFileURL(modulePath).href)) as Record<string, unknown>;
return resolveChannelConfigSurfaceExport(imported);
return resolveConfigSchemaExport(imported);
} catch (error) {
if (!shouldRetryViaIsolatedCopy(error)) {
throw error;
@ -226,7 +196,7 @@ export async function loadChannelConfigSurfaceModule(
const imported = (await import(
`${pathToFileURL(isolatedCopy.copiedModulePath).href}?isolated=${Date.now()}`
)) as Record<string, unknown>;
return resolveChannelConfigSurfaceExport(imported);
return resolveConfigSchemaExport(imported);
} finally {
isolatedCopy.cleanup();
}

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +0,0 @@
import { describe, expect, it } from "vitest";
import { collectBundledChannelConfigMetadata } from "../../scripts/generate-bundled-channel-config-metadata.ts";
import { BUNDLED_CHANNEL_CONFIG_METADATA } from "./bundled-channel-config-metadata.js";
describe("bundled channel config metadata", () => {
it("matches the generated metadata snapshot", async () => {
expect(BUNDLED_CHANNEL_CONFIG_METADATA).toEqual(
await collectBundledChannelConfigMetadata({ repoRoot: process.cwd() }),
);
});
});

View File

@ -1,14 +0,0 @@
import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "./bundled-channel-config-metadata.generated.js";
import type { ConfigUiHint } from "./schema.hints.js";
export type BundledChannelConfigMetadata = {
pluginId: string;
channelId: string;
label?: string;
description?: string;
schema: Record<string, unknown>;
uiHints?: Record<string, ConfigUiHint>;
};
export const BUNDLED_CHANNEL_CONFIG_METADATA =
GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA as unknown as readonly BundledChannelConfigMetadata[];

View File

@ -11790,7 +11790,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
},
"agents.list[].runtime.acp.agent": {
label: "Agent ACP Harness Agent",
help: "Optional ACP harness agent id to use for this OpenClaw agent (for example codex, claude).",
help: "Optional ACP harness agent id to use for this OpenClaw agent (for example codex, claude, cursor, gemini, openclaw).",
tags: ["advanced"],
},
"agents.list[].runtime.acp.backend": {

View File

@ -1,13 +1,4 @@
import type { GroupPolicy } from "./types.base.js";
import type { DiscordConfig } from "./types.discord.js";
import type { GoogleChatConfig } from "./types.googlechat.js";
import type { IMessageConfig } from "./types.imessage.js";
import type { IrcConfig } from "./types.irc.js";
import type { MSTeamsConfig } from "./types.msteams.js";
import type { SignalConfig } from "./types.signal.js";
import type { SlackConfig } from "./types.slack.js";
import type { TelegramConfig } from "./types.telegram.js";
import type { WhatsAppConfig } from "./types.whatsapp.js";
export type ChannelHeartbeatVisibilityConfig = {
/** Show HEARTBEAT_OK acknowledgments in chat (default: false). */
@ -52,20 +43,11 @@ export type ExtensionChannelConfig = {
[key: string]: unknown;
};
export type ChannelsConfig = {
export interface ChannelsConfig {
defaults?: ChannelDefaultsConfig;
/** Map provider -> channel id -> model override. */
modelByChannel?: ChannelModelByChannelConfig;
whatsapp?: WhatsAppConfig;
telegram?: TelegramConfig;
discord?: DiscordConfig;
irc?: IrcConfig;
googlechat?: GoogleChatConfig;
slack?: SlackConfig;
signal?: SignalConfig;
imessage?: IMessageConfig;
msteams?: MSTeamsConfig;
// Extension channels use dynamic keys - use ExtensionChannelConfig in extensions
/** Channel sections are plugin-owned; concrete channel files augment this interface. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
};
}

View File

@ -377,3 +377,9 @@ export type DiscordConfig = {
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & DiscordAccountConfig;
declare module "./types.channels.js" {
interface ChannelsConfig {
discord?: DiscordConfig;
}
}

View File

@ -123,3 +123,9 @@ export type GoogleChatConfig = {
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & GoogleChatAccountConfig;
declare module "./types.channels.js" {
interface ChannelsConfig {
googlechat?: GoogleChatConfig;
}
}

View File

@ -92,3 +92,9 @@ export type IMessageConfig = {
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & IMessageAccountConfig;
declare module "./types.channels.js" {
interface ChannelsConfig {
imessage?: IMessageConfig;
}
}

View File

@ -59,3 +59,9 @@ export type IrcConfig = {
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & IrcAccountConfig;
declare module "./types.channels.js" {
interface ChannelsConfig {
irc?: IrcConfig;
}
}

View File

@ -134,3 +134,9 @@ export type MSTeamsConfig = {
/** Minimum interval (ms) between reflections per session. Default: 300000 (5 min). */
feedbackReflectionCooldownMs?: number;
};
declare module "./types.channels.js" {
interface ChannelsConfig {
msteams?: MSTeamsConfig;
}
}

View File

@ -60,3 +60,9 @@ export type SignalConfig = {
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & SignalAccountConfig;
declare module "./types.channels.js" {
interface ChannelsConfig {
signal?: SignalConfig;
}
}

View File

@ -207,3 +207,9 @@ export type SlackConfig = {
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & SlackAccountConfig;
declare module "./types.channels.js" {
interface ChannelsConfig {
slack?: SlackConfig;
}
}

View File

@ -298,3 +298,9 @@ export type TelegramConfig = {
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & TelegramAccountConfig;
declare module "./types.channels.js" {
interface ChannelsConfig {
telegram?: TelegramConfig;
}
}

View File

@ -119,3 +119,9 @@ export type WhatsAppAccountConfig = WhatsAppConfigCore &
/** Override auth directory (Baileys multi-file auth state). */
authDir?: string;
};
declare module "./types.channels.js" {
interface ChannelsConfig {
whatsapp?: WhatsAppConfig;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -19,8 +19,10 @@ import {
installGeneratedPluginTempRootCleanup();
describe("bundled plugin metadata", () => {
it("matches the generated metadata snapshot", () => {
expect(BUNDLED_PLUGIN_METADATA).toEqual(collectBundledPluginMetadata({ repoRoot }));
it("matches the generated metadata snapshot", async () => {
await expect(collectBundledPluginMetadata({ repoRoot })).resolves.toEqual(
BUNDLED_PLUGIN_METADATA,
);
});
it("captures setup-entry metadata for bundled channel plugins", () => {
@ -28,6 +30,11 @@ describe("bundled plugin metadata", () => {
expect(discord?.source).toEqual({ source: "./index.ts", built: "index.js" });
expect(discord?.setupSource).toEqual({ source: "./setup-entry.ts", built: "setup-entry.js" });
expect(discord?.manifest.id).toBe("discord");
expect(discord?.manifest.channelConfigs?.discord).toEqual(
expect.objectContaining({
schema: expect.objectContaining({ type: "object" }),
}),
);
});
it("prefers built generated paths when present and falls back to source paths", () => {
@ -51,7 +58,7 @@ describe("bundled plugin metadata", () => {
).toBe(path.join(tempRoot, "plugin", "index.js"));
});
it("supports check mode for stale generated artifacts", () => {
it("supports check mode for stale generated artifacts", async () => {
const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-generated-");
writeJson(path.join(tempRoot, "extensions", "alpha", "package.json"), {
@ -66,13 +73,13 @@ describe("bundled plugin metadata", () => {
configSchema: { type: "object" },
});
const initial = writeBundledPluginMetadataModule({
const initial = await writeBundledPluginMetadataModule({
repoRoot: tempRoot,
outputPath: "src/plugins/bundled-plugin-metadata.generated.ts",
});
expect(initial.wrote).toBe(true);
const current = writeBundledPluginMetadataModule({
const current = await writeBundledPluginMetadataModule({
repoRoot: tempRoot,
outputPath: "src/plugins/bundled-plugin-metadata.generated.ts",
check: true,
@ -86,7 +93,7 @@ describe("bundled plugin metadata", () => {
"utf8",
);
const stale = writeBundledPluginMetadataModule({
const stale = await writeBundledPluginMetadataModule({
repoRoot: tempRoot,
outputPath: "src/plugins/bundled-plugin-metadata.generated.ts",
check: true,
@ -94,4 +101,78 @@ describe("bundled plugin metadata", () => {
expect(stale.changed).toBe(true);
expect(stale.wrote).toBe(false);
});
it("merges generated channel schema metadata with manifest-owned channel config fields", async () => {
const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-channel-configs-");
writeJson(path.join(tempRoot, "extensions", "alpha", "package.json"), {
name: "@openclaw/alpha",
version: "0.0.1",
openclaw: {
extensions: ["./index.ts"],
channel: {
id: "alpha",
label: "Alpha Root Label",
blurb: "Alpha Root Description",
},
},
});
writeJson(path.join(tempRoot, "extensions", "alpha", "openclaw.plugin.json"), {
id: "alpha",
channels: ["alpha"],
configSchema: { type: "object" },
channelConfigs: {
alpha: {
schema: { type: "object", properties: { stale: { type: "boolean" } } },
label: "Manifest Label",
uiHints: {
"channels.alpha.explicitOnly": {
help: "manifest hint",
},
},
},
},
});
fs.writeFileSync(
path.join(tempRoot, "extensions", "alpha", "index.ts"),
"export {};\n",
"utf8",
);
fs.mkdirSync(path.join(tempRoot, "extensions", "alpha", "src"), { recursive: true });
fs.writeFileSync(
path.join(tempRoot, "extensions", "alpha", "src", "config-schema.js"),
[
"export const AlphaChannelConfigSchema = {",
" schema: {",
" type: 'object',",
" properties: { generated: { type: 'string' } },",
" },",
" uiHints: {",
" 'channels.alpha.generatedOnly': { help: 'generated hint' },",
" },",
"};",
"",
].join("\n"),
"utf8",
);
const entries = await collectBundledPluginMetadata({ repoRoot: tempRoot });
const channelConfigs = entries[0]?.manifest.channelConfigs as
| Record<string, unknown>
| undefined;
expect(channelConfigs?.alpha).toEqual({
schema: {
type: "object",
properties: {
generated: { type: "string" },
},
},
label: "Manifest Label",
description: "Alpha Root Description",
uiHints: {
"channels.alpha.generatedOnly": { help: "generated hint" },
"channels.alpha.explicitOnly": { help: "manifest hint" },
},
});
});
});

View File

@ -42,6 +42,8 @@ function createPluginCandidate(params: {
bundleFormat?: "codex" | "claude" | "cursor";
packageManifest?: OpenClawPackageManifest;
packageDir?: string;
bundledManifest?: PluginCandidate["bundledManifest"];
bundledManifestPath?: string;
}): PluginCandidate {
return {
idHint: params.idHint,
@ -52,6 +54,8 @@ function createPluginCandidate(params: {
bundleFormat: params.bundleFormat,
packageManifest: params.packageManifest,
packageDir: params.packageDir,
bundledManifest: params.bundledManifest,
bundledManifestPath: params.bundledManifestPath,
};
}
@ -338,17 +342,24 @@ describe("loadPluginManifestRegistry", () => {
it("hydrates bundled channel config metadata onto manifest records", () => {
const dir = makeTempDir();
writeManifest(dir, {
id: "telegram",
channels: ["telegram"],
configSchema: { type: "object" },
});
const registry = loadSingleCandidateRegistry({
idHint: "telegram",
rootDir: dir,
origin: "bundled",
});
const registry = loadRegistry([
createPluginCandidate({
idHint: "telegram",
rootDir: dir,
origin: "bundled",
bundledManifestPath: path.join(dir, "openclaw.plugin.json"),
bundledManifest: {
id: "telegram",
configSchema: { type: "object" },
channels: ["telegram"],
channelConfigs: {
telegram: {
schema: { type: "object" },
},
},
},
}),
]);
expect(registry.plugins[0]?.channelConfigs?.telegram).toEqual(
expect.objectContaining({

View File

@ -1,6 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { BUNDLED_CHANNEL_CONFIG_METADATA } from "../config/bundled-channel-config-metadata.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
import { resolveRuntimeServiceVersion } from "../version.js";
@ -169,7 +168,6 @@ function buildRecord(params: {
schemaCacheKey?: string;
configSchema?: Record<string, unknown>;
}): PluginManifestRecord {
const bundledChannelConfigs = resolveBundledChannelConfigs(params.manifest.id);
return {
id: params.manifest.id,
name: normalizeManifestLabel(params.manifest.name) ?? params.candidate.packageName,
@ -201,7 +199,7 @@ function buildRecord(params: {
configSchema: params.configSchema,
configUiHints: params.manifest.uiHints,
contracts: params.manifest.contracts,
channelConfigs: mergeChannelConfigs(bundledChannelConfigs, params.manifest.channelConfigs),
channelConfigs: params.manifest.channelConfigs,
...(params.candidate.packageManifest?.channel?.id
? {
channelCatalogMeta: {
@ -221,40 +219,6 @@ function buildRecord(params: {
};
}
function resolveBundledChannelConfigs(
pluginId: string,
): Record<string, PluginManifestChannelConfig> | undefined {
const entries = BUNDLED_CHANNEL_CONFIG_METADATA.filter((entry) => entry.pluginId === pluginId);
if (entries.length === 0) {
return undefined;
}
return Object.fromEntries(
entries.map((entry) => [
entry.channelId,
{
schema: entry.schema,
...(entry.uiHints ? { uiHints: entry.uiHints } : {}),
...(entry.label ? { label: entry.label } : {}),
...(entry.description ? { description: entry.description } : {}),
},
]),
);
}
function mergeChannelConfigs(
generated: Record<string, PluginManifestChannelConfig> | undefined,
manifest: Record<string, PluginManifestChannelConfig> | undefined,
): Record<string, PluginManifestChannelConfig> | undefined {
if (!generated) {
return manifest;
}
if (!manifest) {
return generated;
}
return { ...generated, ...manifest };
}
function buildBundleRecord(params: {
manifest: {
id: string;