diff --git a/scripts/lib/official-external-channel-catalog.json b/scripts/lib/official-external-channel-catalog.json index ef34e291507..e96c8e89628 100644 --- a/scripts/lib/official-external-channel-catalog.json +++ b/scripts/lib/official-external-channel-catalog.json @@ -82,6 +82,44 @@ } } }, + { + "name": "@tencent-weixin/openclaw-weixin", + "description": "OpenClaw Weixin channel plugin by the Tencent Weixin team.", + "source": "external", + "kind": "channel", + "openclaw": { + "plugin": { + "id": "openclaw-weixin", + "label": "Weixin" + }, + "channel": { + "id": "openclaw-weixin", + "label": "Weixin", + "selectionLabel": "Weixin(微信)", + "detailLabel": "Weixin", + "docsPath": "/plugins/community#weixin", + "docsLabel": "weixin", + "blurb": "Personal WeChat messaging via QR-code login.", + "aliases": ["weixin", "wechat", "微信"], + "order": 75 + }, + "channelConfigs": { + "openclaw-weixin": { + "label": "Weixin", + "description": "Personal WeChat conversation channel.", + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "install": { + "npmSpec": "@tencent-weixin/openclaw-weixin", + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.22" + } + } + }, { "name": "@openclaw/bluebubbles", "description": "OpenClaw BlueBubbles channel plugin", diff --git a/src/plugins/channel-catalog-registry.test.ts b/src/plugins/channel-catalog-registry.test.ts new file mode 100644 index 00000000000..d21d3e85d56 --- /dev/null +++ b/src/plugins/channel-catalog-registry.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; +import type { PluginCandidate, PluginDiscoveryResult } from "./discovery.js"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.doUnmock("./discovery.js"); + vi.doUnmock("./installed-plugin-index-records.js"); +}); + +const ENV: NodeJS.ProcessEnv = { HOME: "/tmp/openclaw-test-home" }; + +const RECORDS: Record = { + weixin: { + source: "npm", + spec: "@tencent-weixin/openclaw-weixin@2.3.7", + installPath: + "/tmp/openclaw-test-home/.openclaw/npm/node_modules/@tencent-weixin/openclaw-weixin", + } as PluginInstallRecord, +}; + +function emptyDiscoveryResult(): PluginDiscoveryResult { + return { + candidates: [] as PluginCandidate[], + diagnostics: [], + }; +} + +async function loadWithMocks(params: { + loadRecords?: (env: NodeJS.ProcessEnv | undefined) => Record; +}): Promise<{ + module: typeof import("./channel-catalog-registry.js"); + discoverSpy: ReturnType; + loadRecordsSpy: ReturnType; +}> { + vi.resetModules(); + const discoverSpy = vi.fn(() => emptyDiscoveryResult()); + const loadRecordsSpy = vi.fn((opts: { env?: NodeJS.ProcessEnv } = {}) => { + return params.loadRecords ? params.loadRecords(opts.env) : RECORDS; + }); + + vi.doMock("./discovery.js", () => ({ discoverOpenClawPlugins: discoverSpy })); + vi.doMock("./installed-plugin-index-records.js", () => ({ + loadInstalledPluginIndexInstallRecordsSync: loadRecordsSpy, + })); + + const module = await import("./channel-catalog-registry.js"); + return { module, discoverSpy, loadRecordsSpy }; +} + +describe("listChannelCatalogEntries", () => { + it("forwards lazily loaded install records to discovery when origin is unspecified", async () => { + const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({}); + + module.listChannelCatalogEntries({ env: ENV }); + + expect(loadRecordsSpy).toHaveBeenCalledTimes(1); + expect(loadRecordsSpy).toHaveBeenCalledWith({ env: ENV }); + expect(discoverSpy).toHaveBeenCalledTimes(1); + expect(discoverSpy.mock.calls[0][0]).toMatchObject({ + env: ENV, + installRecords: RECORDS, + }); + }); + + it("skips ledger lookup when origin is 'bundled' and omits installRecords", async () => { + const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({}); + + module.listChannelCatalogEntries({ origin: "bundled", env: ENV }); + + expect(loadRecordsSpy).not.toHaveBeenCalled(); + expect(discoverSpy).toHaveBeenCalledTimes(1); + expect(discoverSpy.mock.calls[0][0]).not.toHaveProperty("installRecords"); + }); + + it("uses caller-supplied install records verbatim and does not load the ledger", async () => { + const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({}); + const supplied: Record = { + slack: { + source: "npm", + spec: "@openclaw/slack@1.0.0", + } as PluginInstallRecord, + }; + + module.listChannelCatalogEntries({ env: ENV, installRecords: supplied }); + + expect(loadRecordsSpy).not.toHaveBeenCalled(); + expect(discoverSpy.mock.calls[0][0]).toMatchObject({ installRecords: supplied }); + }); + + it("omits installRecords from discovery when the ledger is empty", async () => { + const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({ + loadRecords: () => ({}), + }); + + module.listChannelCatalogEntries({ env: ENV }); + + expect(loadRecordsSpy).toHaveBeenCalledTimes(1); + expect(discoverSpy.mock.calls[0][0]).not.toHaveProperty("installRecords"); + }); + + it("treats ledger read errors as a soft fallback (no installRecords propagated)", async () => { + const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({ + loadRecords: () => { + throw new Error("simulated reader failure"); + }, + }); + + expect(() => module.listChannelCatalogEntries({ env: ENV })).not.toThrow(); + + expect(loadRecordsSpy).toHaveBeenCalledTimes(1); + expect(discoverSpy).toHaveBeenCalledTimes(1); + expect(discoverSpy.mock.calls[0][0]).not.toHaveProperty("installRecords"); + }); +}); diff --git a/src/plugins/channel-catalog-registry.ts b/src/plugins/channel-catalog-registry.ts index f1c65050cf1..8a596b80ca2 100644 --- a/src/plugins/channel-catalog-registry.ts +++ b/src/plugins/channel-catalog-registry.ts @@ -1,4 +1,6 @@ +import type { PluginInstallRecord } from "../config/types.plugins.js"; import { discoverOpenClawPlugins } from "./discovery.js"; +import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-records.js"; import { loadPluginManifest, type PluginPackageChannel, @@ -21,11 +23,20 @@ export function listChannelCatalogEntries( origin?: PluginOrigin; workspaceDir?: string; env?: NodeJS.ProcessEnv; + /** + * Optional override. When omitted and `origin !== "bundled"`, the persisted + * plugin install ledger is loaded synchronously so that npm-installed + * channels stored outside the discovery roots are visible to the catalog. + * Bundled-only callers skip the load to avoid the disk read. + */ + installRecords?: Record; } = {}, ): PluginChannelCatalogEntry[] { + const installRecords = resolveInstallRecords(params); return discoverOpenClawPlugins({ workspaceDir: params.workspaceDir, env: params.env, + ...(installRecords && Object.keys(installRecords).length > 0 ? { installRecords } : {}), }).candidates.flatMap((candidate) => { if (params.origin && candidate.origin !== params.origin) { return []; @@ -53,3 +64,21 @@ export function listChannelCatalogEntries( ]; }); } + +function resolveInstallRecords(params: { + origin?: PluginOrigin; + env?: NodeJS.ProcessEnv; + installRecords?: Record; +}): Record | undefined { + if (params.installRecords) { + return params.installRecords; + } + if (params.origin === "bundled") { + return undefined; + } + try { + return loadInstalledPluginIndexInstallRecordsSync(params.env ? { env: params.env } : {}); + } catch { + return undefined; + } +}