diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 97a292f3dd64..6fda31a1a8b6 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -78,6 +78,12 @@ "commands": { "nativeCommandsAutoEnabled": true, "nativeSkillsAutoEnabled": true + }, + "doctorCapabilities": { + "dmAllowFromMode": "topOnly", + "groupModel": "route", + "groupAllowFromFallbackToAllowFrom": false, + "warnOnEmptyGroupSenderAllowlist": false } }, "install": { diff --git a/scripts/lib/official-external-channel-catalog.json b/scripts/lib/official-external-channel-catalog.json index 9893dede3040..c2c9d2eafe45 100644 --- a/scripts/lib/official-external-channel-catalog.json +++ b/scripts/lib/official-external-channel-catalog.json @@ -571,6 +571,12 @@ "commands": { "nativeCommandsAutoEnabled": true, "nativeSkillsAutoEnabled": true + }, + "doctorCapabilities": { + "dmAllowFromMode": "topOnly", + "groupModel": "route", + "groupAllowFromFallbackToAllowFrom": false, + "warnOnEmptyGroupSenderAllowlist": false } }, "install": { diff --git a/src/channels/bundled-channel-catalog-read.test.ts b/src/channels/bundled-channel-catalog-read.test.ts index 56fe63fbdaa2..25befe32f925 100644 --- a/src/channels/bundled-channel-catalog-read.test.ts +++ b/src/channels/bundled-channel-catalog-read.test.ts @@ -39,7 +39,10 @@ vi.mock("../infra/openclaw-root.js", () => ({ import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js"; import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js"; -import { listBundledChannelCatalogEntries } from "./bundled-channel-catalog-read.js"; +import { + findBundledChannelCatalogMetadata, + listBundledChannelCatalogEntries, +} from "./bundled-channel-catalog-read.js"; import { listBundledChannelIds } from "./plugins/bundled-ids.js"; const tempDirs: string[] = []; @@ -126,6 +129,12 @@ function seedGeneratedChannelCatalog( label: string; docsPath: string; blurb: string; + doctorCapabilities?: { + dmAllowFromMode?: "topOnly" | "nestedOnly"; + groupModel?: "sender" | "route" | "hybrid"; + groupAllowFromFallbackToAllowFrom?: boolean; + warnOnEmptyGroupSenderAllowlist?: boolean; + }; }, ): void { const { packageName, ...channel } = params; @@ -222,6 +231,31 @@ describe("listBundledChannelCatalogEntries", () => { expect(ids.has("telegram")).toBe(true); }); + it("finds doctor capabilities from the generated catalog when the package is excluded", () => { + const root = seedRoot("bcr-generated-doctor-"); + useBundledPluginsDir(undefined); + seedGeneratedChannelCatalog(root, { + packageName: "@openclaw/discord", + id: "discord", + label: "Discord", + docsPath: "/channels/discord", + blurb: "downloadable channel", + doctorCapabilities: { + dmAllowFromMode: "topOnly", + groupModel: "route", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: false, + }, + }); + + expect(findBundledChannelCatalogMetadata("Discord")?.doctorCapabilities).toEqual({ + dmAllowFromMode: "topOnly", + groupModel: "route", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: false, + }); + }); + it("keeps bundled package metadata when generated catalog entries are stale", () => { const root = seedRoot("bcr-package-wins-"); const extensionsRoot = path.join(root, "dist", "extensions"); diff --git a/src/channels/bundled-channel-catalog-read.ts b/src/channels/bundled-channel-catalog-read.ts index 5b75e7526520..070b6e6e034e 100644 --- a/src/channels/bundled-channel-catalog-read.ts +++ b/src/channels/bundled-channel-catalog-read.ts @@ -157,3 +157,16 @@ export function listBundledChannelCatalogEntries(): BundledChannelCatalogEntry[] (left, right) => left.order - right.order || left.id.localeCompare(right.id), ); } + +/** Finds bundled or generated channel metadata by id or alias. */ +export function findBundledChannelCatalogMetadata( + channelId: string, +): PluginPackageChannel | undefined { + const normalized = normalizeOptionalLowercaseString(channelId); + if (!normalized) { + return undefined; + } + return listBundledChannelCatalogEntries().find( + (entry) => entry.id === normalized || entry.aliases.includes(normalized), + )?.channel; +} diff --git a/src/commands/doctor/channel-capabilities.packaged.test.ts b/src/commands/doctor/channel-capabilities.packaged.test.ts new file mode 100644 index 000000000000..97cb8bf08806 --- /dev/null +++ b/src/commands/doctor/channel-capabilities.packaged.test.ts @@ -0,0 +1,85 @@ +// Packaged doctor capability tests cover generated catalog lookup without plugin runtime loading. +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cleanupTempDirs, + makeTempRepoRoot, + writeJsonFile, +} from "../../../test/helpers/temp-repo.js"; + +const packageRootMock = vi.hoisted(() => ({ value: "" })); +const channelPluginMocks = vi.hoisted(() => ({ + getBundledChannelPlugin: vi.fn(() => undefined), + getChannelPlugin: vi.fn(() => undefined), +})); + +vi.mock("../../channels/plugins/bundled.js", () => ({ + getBundledChannelPlugin: channelPluginMocks.getBundledChannelPlugin, +})); + +vi.mock("../../channels/plugins/index.js", () => ({ + getChannelPlugin: channelPluginMocks.getChannelPlugin, +})); + +vi.mock("../../plugins/bundled-dir.js", () => ({ + resolveBundledPluginsDir: () => undefined, + resolveSourceCheckoutDependencyDiagnostic: () => null, +})); + +vi.mock("../../infra/openclaw-root.js", () => ({ + resolveOpenClawPackageRootSync: () => packageRootMock.value, + resolveOpenClawPackageRoot: async () => packageRootMock.value, +})); + +import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js"; +import { getDoctorChannelCapabilities } from "./channel-capabilities.js"; + +const tempDirs: string[] = []; + +beforeEach(() => { + const root = makeTempRepoRoot(tempDirs, "doctor-channel-packaged-"); + packageRootMock.value = root; + writeJsonFile(path.join(root, "package.json"), { name: "openclaw" }); + writeJsonFile(path.join(root, "dist", "channel-catalog.json"), { + entries: [ + { + name: "@openclaw/discord", + openclaw: { + channel: { + id: "discord", + label: "Discord", + doctorCapabilities: { + dmAllowFromMode: "topOnly", + groupModel: "route", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: false, + }, + }, + }, + }, + ], + }); + clearPluginMetadataLifecycleCaches(); + channelPluginMocks.getBundledChannelPlugin.mockReset().mockReturnValue(undefined); + channelPluginMocks.getChannelPlugin.mockReset().mockReturnValue(undefined); +}); + +afterEach(() => { + packageRootMock.value = ""; + clearPluginMetadataLifecycleCaches(); + cleanupTempDirs(tempDirs); + vi.restoreAllMocks(); +}); + +describe("doctor channel capabilities in a packaged install", () => { + it("reads Discord semantics from the generated catalog without loading a plugin", () => { + expect(getDoctorChannelCapabilities("discord")).toEqual({ + dmAllowFromMode: "topOnly", + groupModel: "route", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: false, + }); + expect(channelPluginMocks.getChannelPlugin).not.toHaveBeenCalled(); + expect(channelPluginMocks.getBundledChannelPlugin).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/doctor/channel-capabilities.test.ts b/src/commands/doctor/channel-capabilities.test.ts index 5a4cfd0ea81f..94b9960c1c65 100644 --- a/src/commands/doctor/channel-capabilities.test.ts +++ b/src/commands/doctor/channel-capabilities.test.ts @@ -69,6 +69,17 @@ describe("doctor channel capabilities", () => { }); }); + it("returns Discord route semantics without loading its channel plugin", () => { + expect(getDoctorChannelCapabilities("discord")).toEqual({ + dmAllowFromMode: "topOnly", + groupModel: "route", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: false, + }); + expect(channelPluginMocks.getChannelPlugin).not.toHaveBeenCalled(); + expect(channelPluginMocks.getBundledChannelPlugin).not.toHaveBeenCalled(); + }); + it("returns capability overrides from matrix plugin metadata", () => { expect(getDoctorChannelCapabilities("matrix")).toEqual({ dmAllowFromMode: "nestedOnly", diff --git a/src/commands/doctor/channel-capabilities.ts b/src/commands/doctor/channel-capabilities.ts index ffe5f05b2343..e2dffc1142e5 100644 --- a/src/commands/doctor/channel-capabilities.ts +++ b/src/commands/doctor/channel-capabilities.ts @@ -1,10 +1,10 @@ +import { findBundledChannelCatalogMetadata } from "../../channels/bundled-channel-catalog-read.js"; // Doctor capability lookup for channel-specific policy and migration behavior. import { getBundledChannelPlugin } from "../../channels/plugins/bundled.js"; import type { ChannelDmAllowFromMode } from "../../channels/plugins/dm-access.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import { normalizeAnyChannelId } from "../../channels/registry.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { findBundledPackageChannelMetadata } from "../../plugins/bundled-package-channel-metadata.js"; import type { PluginPackageChannelDoctorCapabilities } from "../../plugins/manifest.js"; type DoctorGroupModel = "sender" | "route" | "hybrid"; @@ -39,10 +39,10 @@ function mergeDoctorChannelCapabilities( }; } -function getManifestDoctorCapabilities( +function getCatalogDoctorCapabilities( channelId: string, ): PluginPackageChannelDoctorCapabilities | undefined { - return findBundledPackageChannelMetadata(channelId)?.doctorCapabilities; + return findBundledChannelCatalogMetadata(channelId)?.doctorCapabilities; } /** Resolve doctor behavior capabilities from channel metadata, plugin runtime, or defaults. */ @@ -51,9 +51,9 @@ export function getDoctorChannelCapabilities(channelName?: string): DoctorChanne return DEFAULT_DOCTOR_CHANNEL_CAPABILITIES; } - const manifestCapabilities = getManifestDoctorCapabilities(channelName); - if (manifestCapabilities) { - return mergeDoctorChannelCapabilities(manifestCapabilities); + const catalogCapabilities = getCatalogDoctorCapabilities(channelName); + if (catalogCapabilities) { + return mergeDoctorChannelCapabilities(catalogCapabilities); } const channelId = normalizeAnyChannelId(channelName); @@ -65,7 +65,7 @@ export function getDoctorChannelCapabilities(channelName?: string): DoctorChanne if (pluginDoctor) { return mergeDoctorChannelCapabilities(pluginDoctor); } - return mergeDoctorChannelCapabilities(getManifestDoctorCapabilities(channelId)); + return mergeDoctorChannelCapabilities(getCatalogDoctorCapabilities(channelId)); } type DoctorChannelAccountIds = { diff --git a/src/plugins/bundled-package-channel-metadata.test.ts b/src/plugins/bundled-package-channel-metadata.test.ts index 7394ae095d0c..7730e76fdc86 100644 --- a/src/plugins/bundled-package-channel-metadata.test.ts +++ b/src/plugins/bundled-package-channel-metadata.test.ts @@ -10,7 +10,7 @@ vi.mock("./bundled-dir.js", () => ({ })); import { resolveBundledPluginsDir } from "./bundled-dir.js"; -import { findBundledPackageChannelMetadata } from "./bundled-package-channel-metadata.js"; +import { listBundledPackageChannelMetadata } from "./bundled-package-channel-metadata.js"; import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; const tempDirs: string[] = []; @@ -72,7 +72,7 @@ describe("bundled package channel metadata", () => { ); useBundledPluginsDir(extensionsRoot); - const matrix = findBundledPackageChannelMetadata("matrix"); + const matrix = listBundledPackageChannelMetadata().find((channel) => channel.id === "matrix"); expect(matrix?.doctorCapabilities).toEqual({ dmAllowFromMode: "nestedOnly", @@ -107,7 +107,9 @@ describe("bundled package channel metadata", () => { "export default {};\n", "utf8", ); - expect(findBundledPackageChannelMetadata("matrix")?.label).toBe("Before"); + expect( + listBundledPackageChannelMetadata().find((channel) => channel.id === "matrix")?.label, + ).toBe("Before"); writeJsonFile(packagePath, { name: "@openclaw/matrix", @@ -120,6 +122,8 @@ describe("bundled package channel metadata", () => { }); clearPluginMetadataLifecycleCaches(); - expect(findBundledPackageChannelMetadata("matrix")?.label).toBe("After"); + expect( + listBundledPackageChannelMetadata().find((channel) => channel.id === "matrix")?.label, + ).toBe("After"); }); }); diff --git a/src/plugins/bundled-package-channel-metadata.ts b/src/plugins/bundled-package-channel-metadata.ts index cf59a49f75dc..f0cffdaf90d9 100644 --- a/src/plugins/bundled-package-channel-metadata.ts +++ b/src/plugins/bundled-package-channel-metadata.ts @@ -6,12 +6,3 @@ import type { PluginPackageChannel } from "./manifest.js"; export function listBundledPackageChannelMetadata(): readonly PluginPackageChannel[] { return listChannelCatalogEntries({ origin: "bundled" }).map((entry) => entry.channel); } - -/** Finds bundled package channel metadata by id or alias. */ -export function findBundledPackageChannelMetadata( - channelId: string, -): PluginPackageChannel | undefined { - return listBundledPackageChannelMetadata().find( - (channel) => channel.id === channelId || channel.aliases?.includes(channelId), - ); -}