fix(discord): avoid doctor runtime loading (#119391)

* fix(discord): publish doctor capability metadata

Punchcard-Session: amber-workshop-river-yr

* fix(doctor): read packaged channel metadata

Punchcard-Session: amber-workshop-river-yr
This commit is contained in:
Vincent Koc
2026-08-05 11:05:35 +08:00
committed by GitHub
parent 22de30f998
commit 1ee0564b09
9 changed files with 171 additions and 21 deletions
+6
View File
@@ -78,6 +78,12 @@
"commands": {
"nativeCommandsAutoEnabled": true,
"nativeSkillsAutoEnabled": true
},
"doctorCapabilities": {
"dmAllowFromMode": "topOnly",
"groupModel": "route",
"groupAllowFromFallbackToAllowFrom": false,
"warnOnEmptyGroupSenderAllowlist": false
}
},
"install": {
@@ -571,6 +571,12 @@
"commands": {
"nativeCommandsAutoEnabled": true,
"nativeSkillsAutoEnabled": true
},
"doctorCapabilities": {
"dmAllowFromMode": "topOnly",
"groupModel": "route",
"groupAllowFromFallbackToAllowFrom": false,
"warnOnEmptyGroupSenderAllowlist": false
}
},
"install": {
@@ -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");
@@ -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;
}
@@ -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();
});
});
@@ -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",
+7 -7
View File
@@ -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 = {
@@ -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");
});
});
@@ -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),
);
}