fix(plugins): forward install records to channel catalog registry

`listChannelCatalogEntries` invoked `discoverOpenClawPlugins` without
forwarding `installRecords`, so npm-installed channel plugins recorded
in `~/.openclaw/plugins/installs.json` were absent from the CLI channel
catalog. `openclaw channels add --channel <id>` and
`openclaw channels login --channel <id>` therefore reported
"Unsupported channel" / "Unknown channel" for any third-party plugin
even when its ledger entry was healthy. Bundled plugins (qqbot, telegram,
etc.) reach the same catalog via the stock discovery path, which is why
they were unaffected.

Lazy-load the persisted ledger via
`loadInstalledPluginIndexInstallRecordsSync` when the caller does not
specify `origin === "bundled"`, and forward the records to discovery.
Bundled-only callers continue to skip the disk read; callers that
already loaded records (e.g. tests, batch flows) can pass them
explicitly. Reader failures fall back silently to "no install records",
preserving prior behaviour.

Also register `@tencent-weixin/openclaw-weixin` in
`scripts/lib/official-external-channel-catalog.json` so the channel
appears in onboarding flows that consult the catalog directly.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pumpkinxing
2026-05-04 19:23:33 +08:00
committed by George Zhang
parent 7188e4f4ad
commit 4f7498d8d2
3 changed files with 183 additions and 0 deletions

View File

@@ -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",

View File

@@ -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<string, PluginInstallRecord> = {
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<string, PluginInstallRecord>;
}): Promise<{
module: typeof import("./channel-catalog-registry.js");
discoverSpy: ReturnType<typeof vi.fn>;
loadRecordsSpy: ReturnType<typeof vi.fn>;
}> {
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<string, PluginInstallRecord> = {
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");
});
});

View File

@@ -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<string, PluginInstallRecord>;
} = {},
): 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<string, PluginInstallRecord>;
}): Record<string, PluginInstallRecord> | undefined {
if (params.installRecords) {
return params.installRecords;
}
if (params.origin === "bundled") {
return undefined;
}
try {
return loadInstalledPluginIndexInstallRecordsSync(params.env ? { env: params.env } : {});
} catch {
return undefined;
}
}