openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
246 lines (245 loc) • 8.46 kB
JavaScript
import "node:fs/promises";
//#region packages/normalization-core/src/record-coerce.ts
/** Type guard for non-array object records at browser-safe boundaries. */
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/** Returns a non-array record or undefined. */
function asOptionalRecord(value) {
return isRecord(value) ? value : void 0;
}
/** Returns a non-array record or null. */
function asNullableRecord(value) {
return isRecord(value) ? value : null;
}
//#endregion
//#region src/utils/boolean.ts
const DEFAULT_TRUTHY = [
"true",
"1",
"yes",
"on"
];
const DEFAULT_FALSY = [
"false",
"0",
"no",
"off"
];
new Set(DEFAULT_TRUTHY);
new Set(DEFAULT_FALSY);
/** Returns only real boolean values and leaves boolean-like strings for explicit parsing. */
function asBoolean(value) {
return typeof value === "boolean" ? value : void 0;
}
//#endregion
//#region src/plugin-sdk/runtime-doctor-migrations.ts
/** Collects a channel's root config and object-shaped account overrides in config order. */
function collectChannelAccountScopes(params) {
const scopes = [];
const pathSegments = ["channels", params.channelId];
const channel = asNullableRecord(asNullableRecord(params.cfg.channels)?.[params.channelId]);
if (!channel) return scopes;
scopes.push({
prefix: pathSegments.join("."),
pathSegments,
account: channel
});
const accounts = asNullableRecord(channel.accounts);
if (!accounts) return scopes;
for (const [accountId, value] of Object.entries(accounts)) {
const account = asNullableRecord(value);
if (account) {
const accountPathSegments = [
...pathSegments,
"accounts",
accountId
];
scopes.push({
prefix: accountPathSegments.join("."),
pathSegments: accountPathSegments,
account
});
}
}
return scopes;
}
function readKeyMovePath(entry, path, own = true) {
let current = entry;
for (const segment of path.slice(0, -1)) {
const next = asNullableRecord(current[segment]);
if (!next) return null;
current = next;
}
const key = path.at(-1);
return key && (own ? Object.hasOwn(current, key) : key in current) ? { value: current[key] } : null;
}
function setKeyMovePath(entry, path, value) {
const [key, ...rest] = path;
if (!key) return entry;
if (rest.length === 0) return {
...entry,
[key]: value
};
return {
...entry,
[key]: setKeyMovePath(asNullableRecord(entry[key]) ?? {}, rest, value)
};
}
function deleteKeyMovePath(entry, path, pruneEmpty) {
const [key, ...rest] = path;
if (!key) return entry;
const next = { ...entry };
if (rest.length === 0) {
delete next[key];
return next;
}
const child = asNullableRecord(entry[key]);
if (!child) return entry;
const updatedChild = deleteKeyMovePath(child, rest, pruneEmpty);
if (pruneEmpty && Object.keys(updatedChild).length === 0) delete next[key];
else next[key] = updatedChild;
return next;
}
/** Defines an immutable legacy-key move across fixed or `*`-mapped object paths. */
function defineKeyMoveMigration(params) {
const visitScopes = (entry, scope, visit, scopePath = []) => {
const [segment, ...rest] = scope;
if (!segment) return visit(entry, scopePath);
if (segment === "*") return Object.entries(entry).some(([key, value]) => {
const child = asNullableRecord(value);
return child ? visitScopes(child, rest, visit, [...scopePath, key]) : false;
});
const child = asNullableRecord(entry[segment]);
return child ? visitScopes(child, rest, visit, [...scopePath, segment]) : false;
};
const hasLegacy = (value) => {
const entry = asNullableRecord(value);
return entry ? visitScopes(entry, params.scope ?? [], (scopeEntry) => {
const source = readKeyMovePath(scopeEntry, params.from, params.sourceOwn);
return Boolean(source && (params.match?.(source.value) ?? true));
}) : false;
};
const normalizeScope = (scopeEntry, scopePath, pathPrefix, changes) => {
const source = readKeyMovePath(scopeEntry, params.from, params.sourceOwn);
if (!source || !(params.match?.(source.value) ?? true)) return {
entry: scopeEntry,
changed: false
};
const target = readKeyMovePath(scopeEntry, params.to);
const mapped = params.map ? params.map(source.value) : { value: source.value };
const context = {
sourcePath: [
pathPrefix,
...scopePath,
...params.from
].join("."),
targetPath: [
pathPrefix,
...scopePath,
...params.to
].join("."),
sourceValue: source.value,
targetValue: target?.value,
mappedValue: mapped?.value
};
const targetSet = params.targetIsSet?.(target?.value) ?? target?.value !== void 0;
let updated = scopeEntry;
if (targetSet) changes.push(params.existingMessage?.(context) ?? `Removed ${context.sourcePath} (${context.targetPath} already set).`);
else if (mapped) {
updated = setKeyMovePath(updated, params.to, mapped.value);
changes.push(params.movedMessage?.(context) ?? `Moved ${context.sourcePath} → ${context.targetPath}.`);
} else changes.push(params.invalidMessage?.(context) ?? `Removed invalid ${context.sourcePath} value.`);
return {
entry: deleteKeyMovePath(updated, params.from, params.pruneEmptySource ?? false),
changed: true
};
};
const normalizeScopes = (entry, scope, pathPrefix, changes, scopePath = []) => {
const [segment, ...rest] = scope;
if (!segment) return normalizeScope(entry, scopePath, pathPrefix, changes);
let changed = false;
const updated = { ...entry };
const keys = segment === "*" ? Object.keys(entry) : [segment];
for (const key of keys) {
const child = asNullableRecord(entry[key]);
if (!child) continue;
const normalized = normalizeScopes(child, rest, pathPrefix, changes, [...scopePath, key]);
if (normalized.changed) {
updated[key] = normalized.entry;
changed = true;
}
}
return changed ? {
entry: updated,
changed: true
} : {
entry,
changed: false
};
};
return {
hasLegacy,
normalize: ({ entry, pathPrefix, changes }) => normalizeScopes(entry, params.scope ?? [], pathPrefix, changes)
};
}
/**
* Defines the repair for channel config parked under `plugins.entries.<id>.config`.
* Channel plugins read `channels.<channelId>` only, but retired rich plugin-entry
* schemas let config UIs park values in the unread plugin-entry location.
*/
function defineStrayPluginEntryConfigMigration(params) {
const { pluginId, channelId } = params;
const entryConfigPath = `plugins.entries.${pluginId}.config`;
const readStrayEntryConfig = (cfg) => {
const config = asNullableRecord(asNullableRecord(asNullableRecord(asNullableRecord(cfg.plugins)?.entries)?.[pluginId])?.config);
return config && Object.keys(config).length > 0 ? config : null;
};
return {
legacyConfigRule: {
path: [
"plugins",
"entries",
pluginId,
"config"
],
message: `${entryConfigPath} is not read by the ${channelId} channel; run "openclaw doctor --fix" to move its keys to channels.${channelId}.`,
match: (value) => {
const record = asNullableRecord(value);
return Boolean(record && Object.keys(record).length > 0);
}
},
normalizeConfig: ({ cfg }) => {
const stray = readStrayEntryConfig(cfg);
if (!stray) return {
config: cfg,
changes: []
};
const next = structuredClone(cfg);
const currentChannel = asNullableRecord(asNullableRecord(next.channels)?.[channelId]) ?? {};
const staged = [];
const dropped = [];
const merged = { ...currentChannel };
for (const [key, value] of Object.entries(stray)) if (Object.hasOwn(currentChannel, key)) dropped.push(key);
else {
merged[key] = value;
staged.push(key);
}
if (!params.validateMergedChannelConfig(merged)) return {
config: cfg,
changes: []
};
const channels = asNullableRecord(next.channels) ?? {};
channels[channelId] = merged;
next.channels = channels;
const entry = asNullableRecord(asNullableRecord(asNullableRecord(next.plugins)?.entries)?.[pluginId]);
if (entry) delete entry.config;
return {
config: next,
changes: [...staged.map((key) => `Moved ${entryConfigPath}.${key} to channels.${channelId}.${key}.`), ...dropped.map((key) => `Removed ${entryConfigPath}.${key}; channels.${channelId}.${key} is authoritative.`)]
};
}
};
}
//#endregion
export { asNullableRecord as a, asBoolean as i, defineKeyMoveMigration as n, asOptionalRecord as o, defineStrayPluginEntryConfigMigration as r, isRecord as s, collectChannelAccountScopes as t };