diff --git a/src/channels/plugins/doctor-contract-api.ts b/src/channels/plugins/doctor-contract-api.ts index 2bd848aac86..69657674b94 100644 --- a/src/channels/plugins/doctor-contract-api.ts +++ b/src/channels/plugins/doctor-contract-api.ts @@ -1,8 +1,17 @@ import type { LegacyConfigRule } from "../../config/legacy.shared.js"; +import type { OpenClawConfig } from "../../config/types.js"; import { loadBundledPluginPublicArtifactModuleSync } from "../../plugins/public-surface-loader.js"; +type BundledChannelDoctorCompatibilityMutation = { + config: OpenClawConfig; + changes: string[]; +}; + type BundledChannelDoctorContractApi = { legacyConfigRules?: readonly LegacyConfigRule[]; + normalizeCompatibilityConfig?: (params: { + cfg: OpenClawConfig; + }) => BundledChannelDoctorCompatibilityMutation; }; function loadBundledChannelPublicArtifact( diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index f143e30b9fe..20c8a07545e 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -60,7 +60,55 @@ vi.mock("../config/validation.js", () => ({ })); vi.mock("../channels/plugins/bootstrap-registry.js", () => ({ - getBootstrapChannelPlugin: vi.fn(() => undefined), + getBootstrapChannelPlugin: vi.fn((channelId: string) => { + if (channelId !== "discord") { + return undefined; + } + return { + doctor: { + normalizeCompatibilityConfig: ({ + cfg, + }: { + cfg: { channels?: { discord?: Record } }; + }) => { + const discord = cfg.channels?.discord; + if (!discord) { + return { config: cfg, changes: [] }; + } + if ( + !("streamMode" in discord) && + typeof discord.streaming !== "boolean" && + typeof discord.streaming !== "string" + ) { + return { config: cfg, changes: [] }; + } + const next = structuredClone(cfg); + const nextDiscord = next.channels?.discord; + if (!nextDiscord) { + return { config: cfg, changes: [] }; + } + const nextStreaming = + nextDiscord.streaming && typeof nextDiscord.streaming === "object" + ? { ...(nextDiscord.streaming as Record) } + : {}; + if (!("mode" in nextStreaming)) { + nextStreaming.mode = + nextDiscord.streamMode === "block" + ? "partial" + : nextDiscord.streaming === false + ? "off" + : "partial"; + } + delete nextDiscord.streamMode; + nextDiscord.streaming = nextStreaming; + return { + config: next, + changes: ["Discord allowlist ids normalized to strings."], + }; + }, + }, + }; + }), })); vi.mock("../plugins/doctor-contract-registry.js", () => { @@ -535,6 +583,43 @@ vi.mock("./doctor-config-preflight.js", async () => { return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir, "openclaw.json"); } + function normalizeDiscordStreamingCompat(cfg: Record): Record { + const channels = + cfg.channels && typeof cfg.channels === "object" && !Array.isArray(cfg.channels) + ? (cfg.channels as Record) + : null; + const discord = + channels?.discord && typeof channels.discord === "object" && !Array.isArray(channels.discord) + ? (channels.discord as Record) + : null; + if ( + !discord || + (!("streamMode" in discord) && + typeof discord.streaming !== "boolean" && + typeof discord.streaming !== "string") + ) { + return cfg; + } + const next = structuredClone(cfg); + const nextDiscord = ((next.channels as Record | undefined)?.discord ?? + {}) as Record; + const nextStreaming = + nextDiscord.streaming && typeof nextDiscord.streaming === "object" + ? { ...(nextDiscord.streaming as Record) } + : {}; + if (!("mode" in nextStreaming)) { + nextStreaming.mode = + nextDiscord.streamMode === "block" + ? "partial" + : nextDiscord.streaming === false + ? "off" + : "partial"; + } + delete nextDiscord.streamMode; + nextDiscord.streaming = nextStreaming; + return next; + } + return { runDoctorConfigPreflight: vi.fn(async () => { const injected = getDoctorConfigInputForTest(); @@ -596,7 +681,7 @@ vi.mock("./doctor-config-preflight.js", async () => { }), ); const compat = applyRuntimeLegacyConfigMigrations(parsed); - const effectiveConfig = compat.next ?? parsed; + const effectiveConfig = normalizeDiscordStreamingCompat(compat.next ?? parsed); return { snapshot: { exists, diff --git a/src/commands/doctor/shared/channel-legacy-config-migrate.test.ts b/src/commands/doctor/shared/channel-legacy-config-migrate.test.ts index b8a68c1d58b..6786d7ceb0d 100644 --- a/src/commands/doctor/shared/channel-legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/channel-legacy-config-migrate.test.ts @@ -1,12 +1,23 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const applyPluginDoctorCompatibilityMigrations = vi.hoisted(() => vi.fn()); +const loadBundledChannelDoctorContractApi = vi.hoisted(() => vi.fn()); +const getBootstrapChannelPlugin = vi.hoisted(() => vi.fn()); vi.mock("../../../plugins/doctor-contract-registry.js", () => ({ applyPluginDoctorCompatibilityMigrations: (...args: unknown[]) => applyPluginDoctorCompatibilityMigrations(...args), })); +vi.mock("../../../channels/plugins/doctor-contract-api.js", () => ({ + loadBundledChannelDoctorContractApi: (...args: unknown[]) => + loadBundledChannelDoctorContractApi(...args), +})); + +vi.mock("../../../channels/plugins/bootstrap-registry.js", () => ({ + getBootstrapChannelPlugin: (...args: unknown[]) => getBootstrapChannelPlugin(...args), +})); + let applyChannelDoctorCompatibilityMigrations: typeof import("./channel-legacy-config-migrate.js").applyChannelDoctorCompatibilityMigrations; beforeAll(async () => { @@ -17,8 +28,59 @@ beforeAll(async () => { await import("./channel-legacy-config-migrate.js")); }); +beforeEach(() => { + applyPluginDoctorCompatibilityMigrations.mockReset(); + loadBundledChannelDoctorContractApi.mockReset(); + getBootstrapChannelPlugin.mockReset(); +}); + describe("bundled channel legacy config migrations", () => { + it("prefers bundled channel doctor contract normalizers before plugin registry fallback", () => { + loadBundledChannelDoctorContractApi.mockImplementation((channelId: string) => + channelId === "slack" + ? { + normalizeCompatibilityConfig: ({ + cfg, + }: { + cfg: { channels?: { slack?: Record } }; + }) => ({ + config: { + ...cfg, + channels: { + ...cfg.channels, + slack: { + ...cfg.channels?.slack, + normalizedByBundledContract: true, + }, + }, + }, + changes: ["Normalized channels.slack via bundled doctor contract."], + }), + } + : undefined, + ); + getBootstrapChannelPlugin.mockReturnValue(undefined); + + const result = applyChannelDoctorCompatibilityMigrations({ + channels: { + slack: { + streaming: true, + }, + }, + }); + + expect(applyPluginDoctorCompatibilityMigrations).not.toHaveBeenCalled(); + expect(loadBundledChannelDoctorContractApi).toHaveBeenCalledWith("slack"); + expect(result.next.channels?.slack).toMatchObject({ + streaming: true, + normalizedByBundledContract: true, + }); + expect(result.changes).toEqual(["Normalized channels.slack via bundled doctor contract."]); + }); + it("normalizes legacy private-network aliases exposed through bundled contract surfaces", () => { + loadBundledChannelDoctorContractApi.mockReturnValue(undefined); + getBootstrapChannelPlugin.mockReturnValue(undefined); applyPluginDoctorCompatibilityMigrations.mockReturnValueOnce({ config: { channels: { diff --git a/src/commands/doctor/shared/channel-legacy-config-migrate.ts b/src/commands/doctor/shared/channel-legacy-config-migrate.ts index 0d72745bc01..1baa3886b3e 100644 --- a/src/commands/doctor/shared/channel-legacy-config-migrate.ts +++ b/src/commands/doctor/shared/channel-legacy-config-migrate.ts @@ -1,7 +1,18 @@ +import { getBootstrapChannelPlugin } from "../../../channels/plugins/bootstrap-registry.js"; +import { loadBundledChannelDoctorContractApi } from "../../../channels/plugins/doctor-contract-api.js"; import type { OpenClawConfig } from "../../../config/types.js"; import { applyPluginDoctorCompatibilityMigrations } from "../../../plugins/doctor-contract-registry.js"; import { isRecord } from "./legacy-config-record-shared.js"; +type ChannelDoctorCompatibilityMutation = { + config: OpenClawConfig; + changes: string[]; +}; + +type ChannelDoctorCompatibilityNormalizer = (params: { + cfg: OpenClawConfig; +}) => ChannelDoctorCompatibilityMutation; + function collectRelevantDoctorChannelIds(raw: unknown): string[] { const channels = isRecord(raw) && isRecord(raw.channels) ? raw.channels : null; if (!channels) { @@ -12,15 +23,49 @@ function collectRelevantDoctorChannelIds(raw: unknown): string[] { .toSorted(); } +function resolveBundledChannelCompatibilityNormalizer( + channelId: string, +): ChannelDoctorCompatibilityNormalizer | undefined { + const contractNormalizer = + loadBundledChannelDoctorContractApi(channelId)?.normalizeCompatibilityConfig; + if (typeof contractNormalizer === "function") { + return contractNormalizer; + } + return getBootstrapChannelPlugin(channelId)?.doctor?.normalizeCompatibilityConfig; +} + export function applyChannelDoctorCompatibilityMigrations(cfg: Record): { next: Record; changes: string[]; } { - const compat = applyPluginDoctorCompatibilityMigrations(cfg as OpenClawConfig, { - pluginIds: collectRelevantDoctorChannelIds(cfg), - }); + let nextCfg = cfg as OpenClawConfig; + const changes: string[] = []; + const unresolvedChannelIds: string[] = []; + + for (const channelId of collectRelevantDoctorChannelIds(cfg)) { + const normalizeCompatibilityConfig = resolveBundledChannelCompatibilityNormalizer(channelId); + if (!normalizeCompatibilityConfig) { + unresolvedChannelIds.push(channelId); + continue; + } + const mutation = normalizeCompatibilityConfig({ cfg: nextCfg }); + if (!mutation || mutation.changes.length === 0) { + continue; + } + nextCfg = mutation.config; + changes.push(...mutation.changes); + } + + if (unresolvedChannelIds.length > 0) { + const compat = applyPluginDoctorCompatibilityMigrations(nextCfg, { + pluginIds: unresolvedChannelIds, + }); + nextCfg = compat.config; + changes.push(...compat.changes); + } + return { - next: compat.config as OpenClawConfig & Record, - changes: compat.changes, + next: nextCfg as OpenClawConfig & Record, + changes, }; }