diff --git a/src/channels/plugins/doctor-contract-api.fast-path.test.ts b/src/channels/plugins/doctor-contract-api.fast-path.test.ts new file mode 100644 index 00000000000..f49f5836c94 --- /dev/null +++ b/src/channels/plugins/doctor-contract-api.fast-path.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; + +const { loadBundledPluginPublicArtifactModuleSyncMock } = vi.hoisted(() => ({ + loadBundledPluginPublicArtifactModuleSyncMock: vi.fn( + ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { + if (dirName === "discord" && artifactBasename === "doctor-contract-api.js") { + return { + legacyConfigRules: [ + { + path: ["channels", "discord", "voice", "tts"], + message: "legacy discord rule", + }, + ], + }; + } + if (dirName === "telegram" && artifactBasename === "contract-api.js") { + return { + legacyConfigRules: [ + { + path: ["channels", "telegram", "groupMentionsOnly"], + message: "legacy telegram rule", + }, + ], + }; + } + throw new Error( + `Unable to resolve bundled plugin public surface ${dirName}/${artifactBasename}`, + ); + }, + ), +})); + +vi.mock("../../plugins/public-surface-loader.js", () => ({ + loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock, +})); + +import { loadBundledChannelDoctorContractApi } from "./doctor-contract-api.js"; + +describe("channel doctor contract api fast path", () => { + it("prefers the explicit doctor contract artifact for bundled channels", () => { + const api = loadBundledChannelDoctorContractApi("discord"); + + expect(api?.legacyConfigRules).toEqual([ + { + path: ["channels", "discord", "voice", "tts"], + message: "legacy discord rule", + }, + ]); + expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ + dirName: "discord", + artifactBasename: "doctor-contract-api.js", + }); + }); + + it("falls back to the generic contract artifact when the doctor artifact is absent", () => { + const api = loadBundledChannelDoctorContractApi("telegram"); + + expect(api?.legacyConfigRules).toEqual([ + { + path: ["channels", "telegram", "groupMentionsOnly"], + message: "legacy telegram rule", + }, + ]); + expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ + dirName: "telegram", + artifactBasename: "doctor-contract-api.js", + }); + expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ + dirName: "telegram", + artifactBasename: "contract-api.js", + }); + }); +}); diff --git a/src/channels/plugins/doctor-contract-api.ts b/src/channels/plugins/doctor-contract-api.ts new file mode 100644 index 00000000000..2bd848aac86 --- /dev/null +++ b/src/channels/plugins/doctor-contract-api.ts @@ -0,0 +1,34 @@ +import type { LegacyConfigRule } from "../../config/legacy.shared.js"; +import { loadBundledPluginPublicArtifactModuleSync } from "../../plugins/public-surface-loader.js"; + +type BundledChannelDoctorContractApi = { + legacyConfigRules?: readonly LegacyConfigRule[]; +}; + +function loadBundledChannelPublicArtifact( + channelId: string, + artifactBasenames: readonly string[], +): BundledChannelDoctorContractApi | undefined { + for (const artifactBasename of artifactBasenames) { + try { + return loadBundledPluginPublicArtifactModuleSync({ + dirName: channelId, + artifactBasename, + }); + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith("Unable to resolve bundled plugin public surface ") + ) { + continue; + } + } + } + return undefined; +} + +export function loadBundledChannelDoctorContractApi( + channelId: string, +): BundledChannelDoctorContractApi | undefined { + return loadBundledChannelPublicArtifact(channelId, ["doctor-contract-api.js", "contract-api.js"]); +} diff --git a/src/channels/plugins/legacy-config.test.ts b/src/channels/plugins/legacy-config.test.ts new file mode 100644 index 00000000000..d2afd0f0a18 --- /dev/null +++ b/src/channels/plugins/legacy-config.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + loadBundledChannelDoctorContractApiMock, + getBootstrapChannelPluginMock, + listPluginDoctorLegacyConfigRulesMock, +} = vi.hoisted(() => ({ + loadBundledChannelDoctorContractApiMock: vi.fn(), + getBootstrapChannelPluginMock: vi.fn(), + listPluginDoctorLegacyConfigRulesMock: vi.fn(() => []), +})); + +vi.mock("./doctor-contract-api.js", () => ({ + loadBundledChannelDoctorContractApi: loadBundledChannelDoctorContractApiMock, +})); + +vi.mock("./bootstrap-registry.js", () => ({ + getBootstrapChannelPlugin: getBootstrapChannelPluginMock, +})); + +vi.mock("../../plugins/doctor-contract-registry.js", () => ({ + listPluginDoctorLegacyConfigRules: listPluginDoctorLegacyConfigRulesMock, +})); + +import { collectChannelLegacyConfigRules } from "./legacy-config.js"; + +describe("collectChannelLegacyConfigRules", () => { + beforeEach(() => { + loadBundledChannelDoctorContractApiMock.mockReset(); + getBootstrapChannelPluginMock.mockReset(); + listPluginDoctorLegacyConfigRulesMock.mockReset(); + listPluginDoctorLegacyConfigRulesMock.mockReturnValue([]); + }); + + it("uses bundled doctor contract rules before falling back to registry scans", () => { + loadBundledChannelDoctorContractApiMock.mockImplementation((channelId: string) => + channelId === "discord" + ? { + legacyConfigRules: [ + { + path: ["channels", "discord", "voice", "tts"], + message: "legacy discord rule", + }, + ], + } + : undefined, + ); + + const rules = collectChannelLegacyConfigRules({ + channels: { + discord: {}, + }, + }); + + expect(rules).toEqual([ + { + path: ["channels", "discord", "voice", "tts"], + message: "legacy discord rule", + }, + ]); + expect(getBootstrapChannelPluginMock).not.toHaveBeenCalled(); + expect(listPluginDoctorLegacyConfigRulesMock).not.toHaveBeenCalled(); + }); + + it("falls back to bootstrap rules and only scans unresolved channels", () => { + getBootstrapChannelPluginMock.mockImplementation((channelId: string) => + channelId === "slack" + ? { + doctor: { + legacyConfigRules: [ + { + path: ["channels", "slack", "legacy"], + message: "legacy slack rule", + }, + ], + }, + } + : undefined, + ); + listPluginDoctorLegacyConfigRulesMock.mockReturnValue([ + { + path: ["channels", "custom-chat", "legacy"], + message: "legacy custom rule", + }, + ]); + + const rules = collectChannelLegacyConfigRules({ + channels: { + slack: {}, + "custom-chat": {}, + }, + }); + + expect(rules).toEqual([ + { + path: ["channels", "slack", "legacy"], + message: "legacy slack rule", + }, + { + path: ["channels", "custom-chat", "legacy"], + message: "legacy custom rule", + }, + ]); + expect(listPluginDoctorLegacyConfigRulesMock).toHaveBeenCalledWith({ + pluginIds: ["custom-chat"], + }); + }); +}); diff --git a/src/channels/plugins/legacy-config.ts b/src/channels/plugins/legacy-config.ts index fdfb1313c9a..92bfa60e173 100644 --- a/src/channels/plugins/legacy-config.ts +++ b/src/channels/plugins/legacy-config.ts @@ -1,6 +1,7 @@ import type { LegacyConfigRule } from "../../config/legacy.shared.js"; import { listPluginDoctorLegacyConfigRules } from "../../plugins/doctor-contract-registry.js"; import { getBootstrapChannelPlugin } from "./bootstrap-registry.js"; +import { loadBundledChannelDoctorContractApi } from "./doctor-contract-api.js"; import type { ChannelId } from "./types.public.js"; function collectConfiguredChannelIds(raw: unknown): ChannelId[] { @@ -19,14 +20,25 @@ function collectConfiguredChannelIds(raw: unknown): ChannelId[] { export function collectChannelLegacyConfigRules(raw?: unknown): LegacyConfigRule[] { const channelIds = collectConfiguredChannelIds(raw); const rules: LegacyConfigRule[] = []; + const unresolvedChannelIds: ChannelId[] = []; for (const channelId of channelIds) { - const plugin = getBootstrapChannelPlugin(channelId); - if (!plugin) { + const contractRules = loadBundledChannelDoctorContractApi(channelId)?.legacyConfigRules; + if (Array.isArray(contractRules) && contractRules.length > 0) { + rules.push(...contractRules); continue; } - rules.push(...(plugin.doctor?.legacyConfigRules ?? [])); + + const plugin = getBootstrapChannelPlugin(channelId); + if (plugin?.doctor?.legacyConfigRules?.length) { + rules.push(...plugin.doctor.legacyConfigRules); + continue; + } + + unresolvedChannelIds.push(channelId); + } + if (unresolvedChannelIds.length > 0) { + rules.push(...listPluginDoctorLegacyConfigRules({ pluginIds: unresolvedChannelIds })); } - rules.push(...listPluginDoctorLegacyConfigRules({ pluginIds: channelIds })); const seen = new Set(); return rules.filter((rule) => { diff --git a/src/config/validation.ts b/src/config/validation.ts index b13aad8e81e..05f5266c24c 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -573,11 +573,10 @@ export function validateConfigObjectRaw( raw: unknown, ): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } { const policyIssues = collectUnsupportedSecretRefPolicyIssues(raw); - const legacyIssues = findLegacyConfigIssues( - raw, - raw, - listPluginDoctorLegacyConfigRules({ pluginIds: collectRelevantDoctorPluginIds(raw) }), - ); + const extraLegacyRules = listPluginDoctorLegacyConfigRules({ + pluginIds: collectRelevantDoctorPluginIds(raw), + }); + const legacyIssues = findLegacyConfigIssues(raw, raw, extraLegacyRules); if (legacyIssues.length > 0) { return { ok: false,