diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index a0674aac3ac..213528d2641 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -43,7 +43,13 @@ ], "order": 55, "systemImage": "message.badge", - "markdownCapable": true + "markdownCapable": true, + "doctorCapabilities": { + "dmAllowFromMode": "nestedOnly", + "groupModel": "route", + "groupAllowFromFallbackToAllowFrom": false, + "warnOnEmptyGroupSenderAllowlist": false + } }, "install": { "npmSpec": "@openclaw/googlechat", diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index ddb42d2864d..55ef273fecb 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -42,6 +42,12 @@ "blurb": "open protocol; install the plugin to enable.", "order": 70, "quickstartAllowFrom": true, + "doctorCapabilities": { + "dmAllowFromMode": "nestedOnly", + "groupModel": "sender", + "groupAllowFromFallbackToAllowFrom": false, + "warnOnEmptyGroupSenderAllowlist": true + }, "persistedAuthState": { "specifier": "./auth-presence", "exportName": "hasAnyMatrixAuth" diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 2f5ade1d98e..2ce33eae7e8 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -40,7 +40,13 @@ "aliases": [ "teams" ], - "order": 60 + "order": 60, + "doctorCapabilities": { + "dmAllowFromMode": "topOnly", + "groupModel": "hybrid", + "groupAllowFromFallbackToAllowFrom": false, + "warnOnEmptyGroupSenderAllowlist": true + } }, "install": { "npmSpec": "@openclaw/msteams", diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index dee50e2d8b4..954e9869270 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -35,7 +35,13 @@ "zlu" ], "order": 85, - "quickstartAllowFrom": false + "quickstartAllowFrom": false, + "doctorCapabilities": { + "dmAllowFromMode": "topOnly", + "groupModel": "hybrid", + "groupAllowFromFallbackToAllowFrom": false, + "warnOnEmptyGroupSenderAllowlist": false + } }, "install": { "npmSpec": "@openclaw/zalouser", diff --git a/src/commands/doctor/channel-capabilities.test.ts b/src/commands/doctor/channel-capabilities.test.ts index cca06186758..c342556eafd 100644 --- a/src/commands/doctor/channel-capabilities.test.ts +++ b/src/commands/doctor/channel-capabilities.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { getDoctorChannelCapabilities } from "./channel-capabilities.js"; describe("doctor channel capabilities", () => { - it("returns nested route semantics for googlechat before plugin metadata loads", () => { + it("returns nested route semantics from googlechat plugin metadata", () => { expect(getDoctorChannelCapabilities("googlechat")).toEqual({ dmAllowFromMode: "nestedOnly", groupModel: "route", @@ -11,7 +11,7 @@ describe("doctor channel capabilities", () => { }); }); - it("returns built-in capability overrides for matrix", () => { + it("returns capability overrides from matrix plugin metadata", () => { expect(getDoctorChannelCapabilities("matrix")).toEqual({ dmAllowFromMode: "nestedOnly", groupModel: "sender", diff --git a/src/commands/doctor/channel-capabilities.ts b/src/commands/doctor/channel-capabilities.ts index 3eafef5a507..372e3f204dd 100644 --- a/src/commands/doctor/channel-capabilities.ts +++ b/src/commands/doctor/channel-capabilities.ts @@ -1,6 +1,8 @@ import { getBundledChannelPlugin } from "../../channels/plugins/bundled.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import { normalizeAnyChannelId } from "../../channels/registry.js"; +import { findBundledPackageChannelMetadata } from "../../plugins/bundled-package-channel-metadata.js"; +import type { PluginPackageChannelDoctorCapabilities } from "../../plugins/manifest.js"; import type { AllowFromMode } from "./shared/allow-from-mode.types.js"; export type DoctorGroupModel = "sender" | "route" | "hybrid"; @@ -19,60 +21,46 @@ const DEFAULT_DOCTOR_CHANNEL_CAPABILITIES: DoctorChannelCapabilities = { warnOnEmptyGroupSenderAllowlist: true, }; -const STATIC_DOCTOR_CHANNEL_CAPABILITIES: Readonly> = { - googlechat: { - dmAllowFromMode: "nestedOnly", - groupModel: "route", - groupAllowFromFallbackToAllowFrom: false, - warnOnEmptyGroupSenderAllowlist: false, - }, - matrix: { - dmAllowFromMode: "nestedOnly", - groupModel: "sender", - groupAllowFromFallbackToAllowFrom: false, - warnOnEmptyGroupSenderAllowlist: true, - }, - msteams: { - dmAllowFromMode: "topOnly", - groupModel: "hybrid", - groupAllowFromFallbackToAllowFrom: false, - warnOnEmptyGroupSenderAllowlist: true, - }, - zalouser: { - dmAllowFromMode: "topOnly", - groupModel: "hybrid", - groupAllowFromFallbackToAllowFrom: false, - warnOnEmptyGroupSenderAllowlist: false, - }, -}; +function mergeDoctorChannelCapabilities( + capabilities?: PluginPackageChannelDoctorCapabilities, +): DoctorChannelCapabilities { + return { + dmAllowFromMode: + capabilities?.dmAllowFromMode ?? DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.dmAllowFromMode, + groupModel: capabilities?.groupModel ?? DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.groupModel, + groupAllowFromFallbackToAllowFrom: + capabilities?.groupAllowFromFallbackToAllowFrom ?? + DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.groupAllowFromFallbackToAllowFrom, + warnOnEmptyGroupSenderAllowlist: + capabilities?.warnOnEmptyGroupSenderAllowlist ?? + DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.warnOnEmptyGroupSenderAllowlist, + }; +} + +function getManifestDoctorCapabilities( + channelId: string, +): PluginPackageChannelDoctorCapabilities | undefined { + return findBundledPackageChannelMetadata(channelId)?.doctorCapabilities; +} export function getDoctorChannelCapabilities(channelName?: string): DoctorChannelCapabilities { if (!channelName) { return DEFAULT_DOCTOR_CHANNEL_CAPABILITIES; } - const staticCapabilities = STATIC_DOCTOR_CHANNEL_CAPABILITIES[channelName]; - if (staticCapabilities) { - return staticCapabilities; + + const manifestCapabilities = getManifestDoctorCapabilities(channelName); + if (manifestCapabilities) { + return mergeDoctorChannelCapabilities(manifestCapabilities); } - const registeredChannelId = normalizeAnyChannelId(channelName); - if (!registeredChannelId) { + + const channelId = normalizeAnyChannelId(channelName); + if (!channelId) { return DEFAULT_DOCTOR_CHANNEL_CAPABILITIES; } const pluginDoctor = - getChannelPlugin(registeredChannelId)?.doctor ?? - getBundledChannelPlugin(registeredChannelId)?.doctor; + getChannelPlugin(channelId)?.doctor ?? getBundledChannelPlugin(channelId)?.doctor; if (pluginDoctor) { - return { - dmAllowFromMode: - pluginDoctor.dmAllowFromMode ?? DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.dmAllowFromMode, - groupModel: pluginDoctor.groupModel ?? DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.groupModel, - groupAllowFromFallbackToAllowFrom: - pluginDoctor.groupAllowFromFallbackToAllowFrom ?? - DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.groupAllowFromFallbackToAllowFrom, - warnOnEmptyGroupSenderAllowlist: - pluginDoctor.warnOnEmptyGroupSenderAllowlist ?? - DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.warnOnEmptyGroupSenderAllowlist, - }; + return mergeDoctorChannelCapabilities(pluginDoctor); } - return DEFAULT_DOCTOR_CHANNEL_CAPABILITIES; + return mergeDoctorChannelCapabilities(getManifestDoctorCapabilities(channelId)); } diff --git a/src/plugins/bundled-package-channel-metadata.ts b/src/plugins/bundled-package-channel-metadata.ts new file mode 100644 index 00000000000..214fbb8ccc8 --- /dev/null +++ b/src/plugins/bundled-package-channel-metadata.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveBundledPluginScanDir } from "./bundled-plugin-scan.js"; +import { + getPackageManifestMetadata, + type PackageManifest, + type PluginPackageChannel, +} from "./manifest.js"; + +const PACKAGE_ROOT = fileURLToPath(new URL("../..", import.meta.url)); +const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url); +const RUNNING_FROM_BUILT_ARTIFACT = + CURRENT_MODULE_PATH.includes(`${path.sep}dist${path.sep}`) || + CURRENT_MODULE_PATH.includes(`${path.sep}dist-runtime${path.sep}`); + +let bundledPackageChannelMetadataCache: readonly PluginPackageChannel[] | undefined; + +function readPackageManifest(pluginDir: string): PackageManifest | undefined { + const packagePath = path.join(pluginDir, "package.json"); + if (!fs.existsSync(packagePath)) { + return undefined; + } + try { + return JSON.parse(fs.readFileSync(packagePath, "utf-8")) as PackageManifest; + } catch { + return undefined; + } +} + +function listBundledPackageChannelMetadata(): readonly PluginPackageChannel[] { + if (bundledPackageChannelMetadataCache) { + return bundledPackageChannelMetadataCache; + } + const scanDir = resolveBundledPluginScanDir({ + packageRoot: PACKAGE_ROOT, + runningFromBuiltArtifact: RUNNING_FROM_BUILT_ARTIFACT, + }); + if (!scanDir || !fs.existsSync(scanDir)) { + bundledPackageChannelMetadataCache = []; + return bundledPackageChannelMetadataCache; + } + bundledPackageChannelMetadataCache = fs + .readdirSync(scanDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => readPackageManifest(path.join(scanDir, entry.name))) + .map((manifest) => getPackageManifestMetadata(manifest)?.channel) + .filter((channel): channel is PluginPackageChannel => Boolean(channel?.id)); + return bundledPackageChannelMetadataCache; +} + +export function findBundledPackageChannelMetadata( + channelId: string, +): PluginPackageChannel | undefined { + return listBundledPackageChannelMetadata().find( + (channel) => channel.id === channelId || channel.aliases?.includes(channelId), + ); +} diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 3714aeaa46c..1845b8f0bf3 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -959,6 +959,14 @@ export type PluginPackageChannel = { specifier?: string; exportName?: string; }; + doctorCapabilities?: PluginPackageChannelDoctorCapabilities; +}; + +export type PluginPackageChannelDoctorCapabilities = { + dmAllowFromMode?: "topOnly" | "topOrNested" | "nestedOnly"; + groupModel?: "sender" | "route" | "hybrid"; + groupAllowFromFallbackToAllowFrom?: boolean; + warnOnEmptyGroupSenderAllowlist?: boolean; }; export type PluginPackageInstall = {