Config: schema-driven channels and settings
This commit is contained in:
committed by
Peter Steinberger
parent
bcfc9bead5
commit
1ad26d6fea
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user