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

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