Config: schema-driven channels and settings

This commit is contained in:
Shadow
2026-01-16 14:13:30 -06:00
committed by Peter Steinberger
parent bcfc9bead5
commit 1ad26d6fea
79 changed files with 2290 additions and 6326 deletions

View File

@@ -0,0 +1,12 @@
import type { ZodTypeAny } from "zod";
import type { ChannelConfigSchema } from "./types.js";
export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema {
return {
schema: schema.toJSONSchema({
target: "draft-07",
unrepresentable: "any",
}) as Record<string, unknown>,
};
}

View File

@@ -13,7 +13,9 @@ import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
import { shouldLogVerbose } from "../../globals.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { getChatChannelMeta } from "../registry.js";
import { DiscordConfigSchema } from "../../config/zod-schema.providers-core.js";
import { discordMessageActions } from "./actions/discord.js";
import { buildChannelConfigSchema } from "./config-schema.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
@@ -57,6 +59,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.discord"] },
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
config: {
listAccountIds: (cfg) => listDiscordAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),

View File

@@ -9,6 +9,8 @@ import { probeIMessage } from "../../imessage/probe.js";
import { sendMessageIMessage } from "../../imessage/send.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { getChatChannelMeta } from "../registry.js";
import { IMessageConfigSchema } from "../../config/zod-schema.providers-core.js";
import { buildChannelConfigSchema } from "./config-schema.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
@@ -44,6 +46,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
media: true,
},
reload: { configPrefixes: ["channels.imessage"] },
configSchema: buildChannelConfigSchema(IMessageConfigSchema),
config: {
listAccountIds: (cfg) => listIMessageAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }),

View File

@@ -10,6 +10,8 @@ import { probeSignal } from "../../signal/probe.js";
import { sendMessageSignal } from "../../signal/send.js";
import { normalizeE164 } from "../../utils.js";
import { getChatChannelMeta } from "../registry.js";
import { SignalConfigSchema } from "../../config/zod-schema.providers-core.js";
import { buildChannelConfigSchema } from "./config-schema.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
@@ -48,6 +50,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.signal"] },
configSchema: buildChannelConfigSchema(SignalConfigSchema),
config: {
listAccountIds: (cfg) => listSignalAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }),

View File

@@ -12,6 +12,8 @@ import {
import { probeSlack } from "../../slack/probe.js";
import { sendMessageSlack } from "../../slack/send.js";
import { getChatChannelMeta } from "../registry.js";
import { SlackConfigSchema } from "../../config/zod-schema.providers-core.js";
import { buildChannelConfigSchema } from "./config-schema.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
@@ -80,6 +82,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.slack"] },
configSchema: buildChannelConfigSchema(SlackConfigSchema),
config: {
listAccountIds: (cfg) => listSlackAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),

View File

@@ -17,7 +17,9 @@ import { probeTelegram } from "../../telegram/probe.js";
import { sendMessageTelegram } from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import { getChatChannelMeta } from "../registry.js";
import { TelegramConfigSchema } from "../../config/zod-schema.providers-core.js";
import { telegramMessageActions } from "./actions/telegram.js";
import { buildChannelConfigSchema } from "./config-schema.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
@@ -77,6 +79,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
blockStreaming: true,
},
reload: { configPrefixes: ["channels.telegram"] },
configSchema: buildChannelConfigSchema(TelegramConfigSchema),
config: {
listAccountIds: (cfg) => listTelegramAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }),

View File

@@ -29,6 +29,20 @@ import type {
// Channel docking: implement this contract in src/channels/plugins/<id>.ts.
// biome-ignore lint/suspicious/noExplicitAny: registry aggregates heterogeneous account types.
export type ChannelConfigUiHint = {
label?: string;
help?: string;
advanced?: boolean;
sensitive?: boolean;
placeholder?: string;
itemTemplate?: unknown;
};
export type ChannelConfigSchema = {
schema: Record<string, unknown>;
uiHints?: Record<string, ChannelConfigUiHint>;
};
export type ChannelPlugin<ResolvedAccount = any> = {
id: ChannelId;
meta: ChannelMeta;
@@ -37,6 +51,7 @@ export type ChannelPlugin<ResolvedAccount = any> = {
// CLI onboarding wizard hooks for this channel.
onboarding?: ChannelOnboardingAdapter;
config: ChannelConfigAdapter<ResolvedAccount>;
configSchema?: ChannelConfigSchema;
setup?: ChannelSetupAdapter;
pairing?: ChannelPairingAdapter;
security?: ChannelSecurityAdapter<ResolvedAccount>;

View File

@@ -21,6 +21,8 @@ import {
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
import { getChatChannelMeta } from "../registry.js";
import { WhatsAppConfigSchema } from "../../config/zod-schema.providers-whatsapp.js";
import { buildChannelConfigSchema } from "./config-schema.js";
import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js";
import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
@@ -60,6 +62,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
},
reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] },
gatewayMethods: ["web.login.start", "web.login.wait"],
configSchema: buildChannelConfigSchema(WhatsAppConfigSchema),
config: {
listAccountIds: (cfg) => listWhatsAppAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }),

View File

@@ -36,4 +36,52 @@ describe("config schema", () => {
);
expect(res.uiHints["plugins.entries.voice-call.config.twilio.authToken"]?.sensitive).toBe(true);
});
it("merges plugin + channel schemas", () => {
const res = buildConfigSchema({
plugins: [
{
id: "voice-call",
name: "Voice Call",
configSchema: {
type: "object",
properties: {
provider: { type: "string" },
},
},
},
],
channels: [
{
id: "matrix",
label: "Matrix",
configSchema: {
type: "object",
properties: {
accessToken: { type: "string" },
},
},
},
],
});
const schema = res.schema as {
properties?: Record<string, unknown>;
};
const pluginsNode = schema.properties?.plugins as Record<string, unknown> | undefined;
const entriesNode = pluginsNode?.properties as Record<string, unknown> | undefined;
const entriesProps = entriesNode?.entries as Record<string, unknown> | undefined;
const entryProps = entriesProps?.properties as Record<string, unknown> | undefined;
const pluginEntry = entryProps?.["voice-call"] as Record<string, unknown> | undefined;
const pluginConfig = pluginEntry?.properties as Record<string, unknown> | undefined;
const pluginConfigSchema = pluginConfig?.config as Record<string, unknown> | undefined;
const pluginConfigProps = pluginConfigSchema?.properties as Record<string, unknown> | undefined;
expect(pluginConfigProps?.provider).toBeTruthy();
const channelsNode = schema.properties?.channels as Record<string, unknown> | undefined;
const channelsProps = channelsNode?.properties as Record<string, unknown> | undefined;
const channelSchema = channelsProps?.matrix as Record<string, unknown> | undefined;
const channelProps = channelSchema?.properties as Record<string, unknown> | undefined;
expect(channelProps?.accessToken).toBeTruthy();
});
});

View File

@@ -16,6 +16,8 @@ export type ConfigUiHints = Record<string, ConfigUiHint>;
export type ConfigSchema = ReturnType<typeof ClawdbotSchema.toJSONSchema>;
type JsonSchemaNode = Record<string, unknown>;
export type ConfigSchemaResponse = {
schema: ConfigSchema;
uiHints: ConfigUiHints;
@@ -31,12 +33,15 @@ export type PluginUiMetadata = {
string,
Pick<ConfigUiHint, "label" | "help" | "advanced" | "sensitive" | "placeholder">
>;
configSchema?: JsonSchemaNode;
};
export type ChannelUiMetadata = {
id: string;
label?: string;
description?: string;
configSchema?: JsonSchemaNode;
configUiHints?: Record<string, ConfigUiHint>;
};
const GROUP_LABELS: Record<string, string> = {
@@ -433,6 +438,51 @@ function isSensitivePath(path: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path));
}
type JsonSchemaObject = JsonSchemaNode & {
type?: string | string[];
properties?: Record<string, JsonSchemaObject>;
required?: string[];
additionalProperties?: JsonSchemaObject | boolean;
};
function cloneSchema<T>(value: T): T {
if (typeof structuredClone === "function") return structuredClone(value);
return JSON.parse(JSON.stringify(value)) as T;
}
function asSchemaObject(value: unknown): JsonSchemaObject | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as JsonSchemaObject;
}
function isObjectSchema(schema: JsonSchemaObject): boolean {
const type = schema.type;
if (type === "object") return true;
if (Array.isArray(type) && type.includes("object")) return true;
return Boolean(schema.properties || schema.additionalProperties);
}
function mergeObjectSchema(base: JsonSchemaObject, extension: JsonSchemaObject): JsonSchemaObject {
const mergedRequired = new Set<string>([
...(base.required ?? []),
...(extension.required ?? []),
]);
const merged: JsonSchemaObject = {
...base,
...extension,
properties: {
...base.properties,
...extension.properties,
},
};
if (mergedRequired.size > 0) {
merged.required = Array.from(mergedRequired);
}
const additional = extension.additionalProperties ?? base.additionalProperties;
if (additional !== undefined) merged.additionalProperties = additional;
return merged;
}
function buildBaseHints(): ConfigUiHints {
const hints: ConfigUiHints = {};
for (const [group, label] of Object.entries(GROUP_LABELS)) {
@@ -520,12 +570,90 @@ function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]):
...(label ? { label } : {}),
...(help ? { help } : {}),
};
const uiHints = channel.configUiHints ?? {};
for (const [relPathRaw, hint] of Object.entries(uiHints)) {
const relPath = relPathRaw.trim().replace(/^\./, "");
if (!relPath) continue;
const key = `${basePath}.${relPath}`;
next[key] = {
...next[key],
...hint,
};
}
}
return next;
}
function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
const pluginsNode = asSchemaObject(root?.properties?.plugins);
const entriesNode = asSchemaObject(pluginsNode?.properties?.entries);
if (!entriesNode) return next;
const entryBase = asSchemaObject(entriesNode.additionalProperties);
const entryProperties = entriesNode.properties ?? {};
entriesNode.properties = entryProperties;
for (const plugin of plugins) {
if (!plugin.configSchema) continue;
const entrySchema = entryBase ? cloneSchema(entryBase) : ({ type: "object" } as JsonSchemaObject);
const entryObject = asSchemaObject(entrySchema) ?? ({ type: "object" } as JsonSchemaObject);
const baseConfigSchema = asSchemaObject(entryObject.properties?.config);
const pluginSchema = asSchemaObject(plugin.configSchema);
const nextConfigSchema =
baseConfigSchema && pluginSchema && isObjectSchema(baseConfigSchema) && isObjectSchema(pluginSchema)
? mergeObjectSchema(baseConfigSchema, pluginSchema)
: cloneSchema(plugin.configSchema);
entryObject.properties = {
...entryObject.properties,
config: nextConfigSchema,
};
entryProperties[plugin.id] = entryObject;
}
return next;
}
function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[]): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
const channelsNode = asSchemaObject(root?.properties?.channels);
if (!channelsNode) return next;
const channelProps = channelsNode.properties ?? {};
channelsNode.properties = channelProps;
for (const channel of channels) {
if (!channel.configSchema) continue;
const existing = asSchemaObject(channelProps[channel.id]);
const incoming = asSchemaObject(channel.configSchema);
if (existing && incoming && isObjectSchema(existing) && isObjectSchema(incoming)) {
channelProps[channel.id] = mergeObjectSchema(existing, incoming);
} else {
channelProps[channel.id] = cloneSchema(channel.configSchema);
}
}
return next;
}
let cachedBase: ConfigSchemaResponse | null = null;
function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
if (!root || !root.properties) return next;
const channelsNode = asSchemaObject(root.properties.channels);
if (channelsNode) {
channelsNode.properties = {};
channelsNode.required = [];
channelsNode.additionalProperties = true;
}
return next;
}
function buildBaseConfigSchema(): ConfigSchemaResponse {
if (cachedBase) return cachedBase;
const schema = ClawdbotSchema.toJSONSchema({
@@ -535,7 +663,7 @@ function buildBaseConfigSchema(): ConfigSchemaResponse {
schema.title = "ClawdbotConfig";
const hints = applySensitiveHints(buildBaseHints());
const next = {
schema,
schema: stripChannelSchema(schema),
uiHints: hints,
version: VERSION,
generatedAt: new Date().toISOString(),
@@ -552,11 +680,16 @@ export function buildConfigSchema(params?: {
const plugins = params?.plugins ?? [];
const channels = params?.channels ?? [];
if (plugins.length === 0 && channels.length === 0) return base;
const merged = applySensitiveHints(
const mergedHints = applySensitiveHints(
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
);
const mergedSchema = applyChannelSchemas(
applyPluginSchemas(base.schema, plugins),
channels,
);
return {
...base,
uiHints: merged,
schema: mergedSchema,
uiHints: mergedHints,
};
}

View File

@@ -11,6 +11,7 @@ import {
import { applyLegacyMigrations } from "../config/legacy.js";
import { applyMergePatch } from "../config/merge-patch.js";
import { buildConfigSchema } from "../config/schema.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { loadClawdbotPlugins } from "../plugins/loader.js";
import {
ErrorCodes,
@@ -114,11 +115,14 @@ export const handleConfigBridgeMethods: BridgeMethodHandler = async (
name: plugin.name,
description: plugin.description,
configUiHints: plugin.configUiHints,
configSchema: plugin.configJsonSchema,
})),
channels: pluginRegistry.channels.map((entry) => ({
id: entry.plugin.id,
label: entry.plugin.meta.label,
description: entry.plugin.meta.blurb,
channels: listChannelPlugins().map((entry) => ({
id: entry.id,
label: entry.meta.label,
description: entry.meta.blurb,
configSchema: entry.configSchema?.schema,
configUiHints: entry.configSchema?.uiHints,
})),
});
return { ok: true, payloadJSON: JSON.stringify(schema) };

View File

@@ -17,6 +17,7 @@ import {
type RestartSentinelPayload,
writeRestartSentinel,
} from "../../infra/restart-sentinel.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { loadClawdbotPlugins } from "../../plugins/loader.js";
import {
ErrorCodes,
@@ -127,11 +128,14 @@ export const configHandlers: GatewayRequestHandlers = {
name: plugin.name,
description: plugin.description,
configUiHints: plugin.configUiHints,
configSchema: plugin.configJsonSchema,
})),
channels: pluginRegistry.channels.map((entry) => ({
id: entry.plugin.id,
label: entry.plugin.meta.label,
description: entry.plugin.meta.blurb,
channels: listChannelPlugins().map((entry) => ({
id: entry.id,
label: entry.meta.label,
description: entry.meta.blurb,
configSchema: entry.configSchema?.schema,
configUiHints: entry.configSchema?.uiHints,
})),
});
respond(true, schema, undefined);

View File

@@ -197,6 +197,7 @@ function createPluginRecord(params: {
httpHandlers: 0,
configSchema: params.configSchema,
configUiHints: undefined,
configJsonSchema: undefined,
};
}
@@ -302,6 +303,17 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
PluginConfigUiHint
>)
: undefined;
record.configJsonSchema =
definition?.configSchema &&
typeof definition.configSchema === "object" &&
(definition.configSchema as { jsonSchema?: unknown }).jsonSchema &&
typeof (definition.configSchema as { jsonSchema?: unknown }).jsonSchema === "object" &&
!Array.isArray((definition.configSchema as { jsonSchema?: unknown }).jsonSchema)
? ((definition.configSchema as { jsonSchema?: unknown }).jsonSchema as Record<
string,
unknown
>)
: undefined;
const validatedConfig = validatePluginConfig({
schema: definition?.configSchema,

View File

@@ -80,6 +80,7 @@ export type PluginRecord = {
httpHandlers: number;
configSchema: boolean;
configUiHints?: Record<string, PluginConfigUiHint>;
configJsonSchema?: Record<string, unknown>;
};
export type PluginRegistry = {

View File

@@ -42,6 +42,7 @@ export type ClawdbotPluginConfigSchema = {
parse?: (value: unknown) => unknown;
validate?: (value: unknown) => PluginConfigValidation;
uiHints?: Record<string, PluginConfigUiHint>;
jsonSchema?: Record<string, unknown>;
};
export type ClawdbotPluginToolContext = {