UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

246 lines (245 loc) 8.46 kB
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 };