diff --git a/extensions/discord/src/doctor-contract.ts b/extensions/discord/src/doctor-contract.ts index 76ebf4be832..557299802b5 100644 --- a/extensions/discord/src/doctor-contract.ts +++ b/extensions/discord/src/doctor-contract.ts @@ -3,10 +3,6 @@ import type { ChannelDoctorLegacyConfigRule, } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { - normalizeLegacyDmAliases, - normalizeLegacyStreamingAliases, -} from "openclaw/plugin-sdk/runtime-doctor"; import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js"; function asObjectRecord(value: unknown): Record | null { @@ -15,6 +11,238 @@ function asObjectRecord(value: unknown): Record | null { : null; } +function ensureNestedRecord(owner: Record, key: string): Record { + const existing = asObjectRecord(owner[key]); + if (existing) { + return { ...existing }; + } + return {}; +} + +function allowFromListsMatch(left: unknown, right: unknown): boolean { + if (!Array.isArray(left) || !Array.isArray(right)) { + return false; + } + const normalizedLeft = left.map((value) => String(value).trim()).filter(Boolean); + const normalizedRight = right.map((value) => String(value).trim()).filter(Boolean); + if (normalizedLeft.length !== normalizedRight.length) { + return false; + } + return normalizedLeft.every((value, index) => value === normalizedRight[index]); +} + +function normalizeLegacyDmAliases(params: { + entry: Record; + pathPrefix: string; + changes: string[]; + promoteAllowFrom?: boolean; +}): { entry: Record; changed: boolean } { + let changed = false; + let updated: Record = params.entry; + const rawDm = updated.dm; + const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record) : null; + let dmChanged = false; + + const topDmPolicy = updated.dmPolicy; + const legacyDmPolicy = dm?.policy; + if (topDmPolicy === undefined && legacyDmPolicy !== undefined) { + updated = { ...updated, dmPolicy: legacyDmPolicy }; + changed = true; + if (dm) { + delete dm.policy; + dmChanged = true; + } + params.changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`); + } else if ( + topDmPolicy !== undefined && + legacyDmPolicy !== undefined && + topDmPolicy === legacyDmPolicy + ) { + if (dm) { + delete dm.policy; + dmChanged = true; + params.changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`); + } + } + + if (params.promoteAllowFrom !== false) { + const topAllowFrom = updated.allowFrom; + const legacyAllowFrom = dm?.allowFrom; + if (topAllowFrom === undefined && legacyAllowFrom !== undefined) { + updated = { ...updated, allowFrom: legacyAllowFrom }; + changed = true; + if (dm) { + delete dm.allowFrom; + dmChanged = true; + } + params.changes.push( + `Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`, + ); + } else if ( + topAllowFrom !== undefined && + legacyAllowFrom !== undefined && + allowFromListsMatch(topAllowFrom, legacyAllowFrom) + ) { + if (dm) { + delete dm.allowFrom; + dmChanged = true; + params.changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`); + } + } + } + + if (dm && asObjectRecord(rawDm) && dmChanged) { + const keys = Object.keys(dm); + if (keys.length === 0) { + if (updated.dm !== undefined) { + const { dm: _ignored, ...rest } = updated; + updated = rest; + changed = true; + params.changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`); + } + } else { + updated = { ...updated, dm }; + changed = true; + } + } + + return { entry: updated, changed }; +} + +function normalizeLegacyStreamingAliases(params: { + entry: Record; + pathPrefix: string; + changes: string[]; + resolvedMode: string; + includePreviewChunk?: boolean; + resolvedNativeTransport?: unknown; + offModeLegacyNotice?: (pathPrefix: string) => string; +}): { entry: Record; changed: boolean } { + const beforeStreaming = params.entry.streaming; + const hadLegacyStreamMode = params.entry.streamMode !== undefined; + const hasLegacyFlatFields = + params.entry.chunkMode !== undefined || + params.entry.blockStreaming !== undefined || + params.entry.blockStreamingCoalesce !== undefined || + (params.includePreviewChunk === true && params.entry.draftChunk !== undefined) || + params.entry.nativeStreaming !== undefined; + const shouldNormalize = + hadLegacyStreamMode || + typeof beforeStreaming === "boolean" || + typeof beforeStreaming === "string" || + hasLegacyFlatFields; + if (!shouldNormalize) { + return { entry: params.entry, changed: false }; + } + + let updated = { ...params.entry }; + let changed = false; + const streaming = ensureNestedRecord(updated, "streaming"); + const block = ensureNestedRecord(streaming, "block"); + const preview = ensureNestedRecord(streaming, "preview"); + + if ( + (hadLegacyStreamMode || + typeof beforeStreaming === "boolean" || + typeof beforeStreaming === "string") && + streaming.mode === undefined + ) { + streaming.mode = params.resolvedMode; + if (hadLegacyStreamMode) { + params.changes.push( + `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${params.resolvedMode}).`, + ); + } else if (typeof beforeStreaming === "boolean") { + params.changes.push( + `Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${params.resolvedMode}).`, + ); + } else if (typeof beforeStreaming === "string") { + params.changes.push( + `Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${params.resolvedMode}).`, + ); + } + changed = true; + } + if (hadLegacyStreamMode) { + delete updated.streamMode; + changed = true; + } + if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) { + streaming.chunkMode = updated.chunkMode; + delete updated.chunkMode; + params.changes.push( + `Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`, + ); + changed = true; + } + if (updated.blockStreaming !== undefined && block.enabled === undefined) { + block.enabled = updated.blockStreaming; + delete updated.blockStreaming; + params.changes.push( + `Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`, + ); + changed = true; + } + if ( + params.includePreviewChunk === true && + updated.draftChunk !== undefined && + preview.chunk === undefined + ) { + preview.chunk = updated.draftChunk; + delete updated.draftChunk; + params.changes.push( + `Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`, + ); + changed = true; + } + if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) { + block.coalesce = updated.blockStreamingCoalesce; + delete updated.blockStreamingCoalesce; + params.changes.push( + `Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`, + ); + changed = true; + } + if ( + updated.nativeStreaming !== undefined && + streaming.nativeTransport === undefined && + params.resolvedNativeTransport !== undefined + ) { + streaming.nativeTransport = params.resolvedNativeTransport; + delete updated.nativeStreaming; + params.changes.push( + `Moved ${params.pathPrefix}.nativeStreaming → ${params.pathPrefix}.streaming.nativeTransport.`, + ); + changed = true; + } else if ( + typeof beforeStreaming === "boolean" && + streaming.nativeTransport === undefined && + params.resolvedNativeTransport !== undefined + ) { + streaming.nativeTransport = params.resolvedNativeTransport; + params.changes.push( + `Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.nativeTransport.`, + ); + changed = true; + } + + if (Object.keys(preview).length > 0) { + streaming.preview = preview; + } + if (Object.keys(block).length > 0) { + streaming.block = block; + } + updated.streaming = streaming; + if ( + hadLegacyStreamMode && + params.resolvedMode === "off" && + params.offModeLegacyNotice !== undefined + ) { + params.changes.push(params.offModeLegacyNotice(params.pathPrefix)); + } + return { entry: updated, changed }; +} + function hasLegacyDiscordStreamingAliases(value: unknown): boolean { const entry = asObjectRecord(value); if (!entry) { diff --git a/packages/plugin-sdk/src/runtime-doctor.ts b/packages/plugin-sdk/src/runtime-doctor.ts new file mode 100644 index 00000000000..5658a0b5193 --- /dev/null +++ b/packages/plugin-sdk/src/runtime-doctor.ts @@ -0,0 +1,13 @@ +export { collectProviderDangerousNameMatchingScopes } from "../../../src/config/dangerous-name-matching.js"; +export { + asObjectRecord, + hasLegacyAccountStreamingAliases, + hasLegacyStreamingAliases, + normalizeLegacyDmAliases, + normalizeLegacyStreamingAliases, +} from "../../../src/config/channel-compat-normalization.js"; +export { + detectPluginInstallPathIssue, + formatPluginInstallPathIssue, +} from "../../../src/infra/plugin-install-path-warnings.js"; +export { removePluginFromConfig } from "../../../src/plugins/uninstall.js"; diff --git a/src/channels/plugins/legacy-config.ts b/src/channels/plugins/legacy-config.ts index d7b44784f6f..706c9be8424 100644 --- a/src/channels/plugins/legacy-config.ts +++ b/src/channels/plugins/legacy-config.ts @@ -1,4 +1,5 @@ import type { LegacyConfigRule } from "../../config/legacy.shared.js"; +import { listPluginDoctorLegacyConfigRules } from "../../plugins/doctor-contract-registry.js"; import { getBootstrapChannelPlugin } from "./bootstrap-registry.js"; import type { ChannelId } from "./types.js"; @@ -16,13 +17,24 @@ function collectConfiguredChannelIds(raw: unknown): ChannelId[] { } export function collectChannelLegacyConfigRules(raw?: unknown): LegacyConfigRule[] { + const channelIds = collectConfiguredChannelIds(raw); const rules: LegacyConfigRule[] = []; - for (const channelId of collectConfiguredChannelIds(raw)) { + for (const channelId of channelIds) { const plugin = getBootstrapChannelPlugin(channelId); if (!plugin) { continue; } rules.push(...(plugin.doctor?.legacyConfigRules ?? [])); } - return rules; + rules.push(...listPluginDoctorLegacyConfigRules({ pluginIds: channelIds })); + + const seen = new Set(); + return rules.filter((rule) => { + const key = `${rule.path.join(".")}::${rule.message}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); }