From a0717ef61c77e3d7fd7c4f269bcdc72d285e8b27 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 3 Jun 2026 21:13:59 +0200 Subject: [PATCH] fix(testing): speed channel contract loading --- extensions/discord/directory-contract-api.ts | 15 +- .../googlechat/directory-contract-api.ts | 6 + extensions/msteams/directory-contract-api.ts | 45 ++++++ extensions/whatsapp/directory-contract-api.ts | 15 +- extensions/whatsapp/src/shared.ts | 10 +- .../bundled-channel-plugin-loader.ts | 148 +++++++++++++++++- .../registry-backed-contract-shards.ts | 12 +- src/plugin-sdk/core.test.ts | 19 ++- src/plugin-sdk/core.ts | 1 + 9 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 extensions/googlechat/directory-contract-api.ts create mode 100644 extensions/msteams/directory-contract-api.ts diff --git a/extensions/discord/directory-contract-api.ts b/extensions/discord/directory-contract-api.ts index 027b29f04594..9871716ae4a8 100644 --- a/extensions/discord/directory-contract-api.ts +++ b/extensions/discord/directory-contract-api.ts @@ -1,4 +1,17 @@ -export { +import { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, } from "./src/directory-config.js"; + +export { + listDiscordDirectoryGroupsFromConfig, + listDiscordDirectoryPeersFromConfig, +}; + +export const discordDirectoryContractPlugin = { + id: "discord", + directory: { + listPeers: listDiscordDirectoryPeersFromConfig, + listGroups: listDiscordDirectoryGroupsFromConfig, + }, +}; diff --git a/extensions/googlechat/directory-contract-api.ts b/extensions/googlechat/directory-contract-api.ts new file mode 100644 index 000000000000..0c7d96596ebc --- /dev/null +++ b/extensions/googlechat/directory-contract-api.ts @@ -0,0 +1,6 @@ +import { googlechatDirectoryAdapter } from "./src/channel.adapters.js"; + +export const googlechatDirectoryContractPlugin = { + id: "googlechat", + directory: googlechatDirectoryAdapter, +}; diff --git a/extensions/msteams/directory-contract-api.ts b/extensions/msteams/directory-contract-api.ts new file mode 100644 index 000000000000..542e1b04df7d --- /dev/null +++ b/extensions/msteams/directory-contract-api.ts @@ -0,0 +1,45 @@ +import type { ChannelDirectoryAdapter } from "openclaw/plugin-sdk/channel-contract"; +import { listDirectoryEntriesFromSources } from "openclaw/plugin-sdk/directory-runtime"; +import { normalizeMSTeamsMessagingTarget } from "./src/resolve-allowlist.js"; +import { resolveMSTeamsCredentials } from "./src/token.js"; + +const msteamsDirectoryContractAdapter: ChannelDirectoryAdapter = { + self: async ({ cfg }) => { + const creds = resolveMSTeamsCredentials(cfg.channels?.msteams); + return creds ? { kind: "user" as const, id: creds.appId, name: creds.appId } : null; + }, + listPeers: async ({ cfg, query, limit }) => + listDirectoryEntriesFromSources({ + kind: "user", + sources: [ + cfg.channels?.msteams?.allowFrom ?? [], + Object.keys(cfg.channels?.msteams?.dms ?? {}), + ], + query, + limit, + normalizeId: (raw) => { + const normalized = normalizeMSTeamsMessagingTarget(raw) ?? raw; + const lowered = normalized.toLowerCase(); + return lowered.startsWith("user:") || lowered.startsWith("conversation:") + ? normalized + : `user:${normalized}`; + }, + }), + listGroups: async ({ cfg, query, limit }) => + listDirectoryEntriesFromSources({ + kind: "group", + sources: [ + Object.values(cfg.channels?.msteams?.teams ?? {}).flatMap((team) => + Object.keys(team.channels ?? {}), + ), + ], + query, + limit, + normalizeId: (raw) => `conversation:${raw.replace(/^conversation:/i, "").trim()}`, + }), +}; + +export const msteamsDirectoryContractPlugin = { + id: "msteams", + directory: msteamsDirectoryContractAdapter, +}; diff --git a/extensions/whatsapp/directory-contract-api.ts b/extensions/whatsapp/directory-contract-api.ts index 389f33d5e64e..ee3a69f9b38e 100644 --- a/extensions/whatsapp/directory-contract-api.ts +++ b/extensions/whatsapp/directory-contract-api.ts @@ -1,4 +1,17 @@ -export { +import { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, } from "./src/directory-config.js"; + +export { + listWhatsAppDirectoryGroupsFromConfig, + listWhatsAppDirectoryPeersFromConfig, +}; + +export const whatsappDirectoryContractPlugin = { + id: "whatsapp", + directory: { + listPeers: listWhatsAppDirectoryPeersFromConfig, + listGroups: listWhatsAppDirectoryGroupsFromConfig, + }, +}; diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 3b8e11590769..5dff9abbf869 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -10,7 +10,7 @@ import { createAllowlistProviderGroupPolicyWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; -import { createChannelPluginBase, getChatChannelMeta } from "openclaw/plugin-sdk/core"; +import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { createDelegatedSetupWizardProxy, type ChannelSetupWizard, @@ -150,7 +150,13 @@ export function createWhatsAppPluginBase(params: { const base = createChannelPluginBase({ id: WHATSAPP_CHANNEL, meta: { - ...getChatChannelMeta(WHATSAPP_CHANNEL), + label: "WhatsApp", + selectionLabel: "WhatsApp (QR link)", + detailLabel: "WhatsApp Web", + docsPath: "/channels/whatsapp", + docsLabel: "whatsapp", + blurb: "works with your own number; recommend a separate phone + eSIM.", + systemImage: "message", showConfigured: false, quickstartAllowFrom: true, forceAccountBinding: true, diff --git a/src/channels/plugins/contracts/test-helpers/bundled-channel-plugin-loader.ts b/src/channels/plugins/contracts/test-helpers/bundled-channel-plugin-loader.ts index e49e26493e94..193e8f48b515 100644 --- a/src/channels/plugins/contracts/test-helpers/bundled-channel-plugin-loader.ts +++ b/src/channels/plugins/contracts/test-helpers/bundled-channel-plugin-loader.ts @@ -10,9 +10,22 @@ import type { ChannelId } from "../../channel-id.types.js"; import type { ChannelPlugin } from "../../types.js"; type ChannelPluginApiModule = Record; +type ChannelDirectoryContractModule = Record; const channelPluginCache = new Map(); const channelPluginPromiseCache = new Map>(); +const channelDirectoryPluginCache = new Map | null>(); +const channelDirectoryPluginPromiseCache = new Map< + ChannelId, + Promise | null> +>(); + +class MissingBundledDirectoryContractArtifactError extends Error { + constructor(id: ChannelId) { + super(`Missing bundled directory contract artifact for ${id}`); + this.name = "MissingBundledDirectoryContractArtifactError"; + } +} function isChannelPlugin(value: unknown): value is ChannelPlugin { return ( @@ -24,6 +37,38 @@ function isChannelPlugin(value: unknown): value is ChannelPlugin { ); } +function isChannelDirectoryContractPlugin( + value: unknown, +): value is Pick { + return ( + Boolean(value) && + typeof value === "object" && + typeof (value as Partial).id === "string" && + Boolean((value as Partial).directory) + ); +} + +function findChannelPlugin(module: ChannelPluginApiModule): ChannelPlugin | null { + return Object.values(module).find(isChannelPlugin) ?? null; +} + +function findChannelDirectoryContractPlugin( + module: ChannelDirectoryContractModule, +): Pick | null { + return Object.values(module).find(isChannelDirectoryContractPlugin) ?? null; +} + +function hasBasePluginMetadata(plugin: ChannelPlugin | null, id: ChannelId): boolean { + return ( + plugin?.id === id && + plugin.meta?.id === id && + typeof plugin.meta.label === "string" && + typeof plugin.meta.selectionLabel === "string" && + typeof plugin.meta.docsPath === "string" && + typeof plugin.meta.blurb === "string" + ); +} + function isBuiltArtifactMissingDependency(error: unknown): boolean { const record = error as | { @@ -101,6 +146,68 @@ async function importBundledChannelPluginSourceSurface(id: ChannelId) { return (await import(pathToFileURL(sourcePath).href)) as ChannelPluginApiModule; } +function resolveSourceArtifactPath(artifactPath: string): string { + if (artifactPath.endsWith(".js") && fs.existsSync(`${artifactPath.slice(0, -3)}.ts`)) { + return `${artifactPath.slice(0, -3)}.ts`; + } + return artifactPath; +} + +async function importBundledChannelDirectoryContractSourceSurface( + id: ChannelId, +): Promise { + const artifactPath = resolveBundledPluginPublicModulePath({ + pluginId: id, + artifactBasename: "directory-contract-api.js", + }); + const sourcePath = resolveSourceArtifactPath(artifactPath); + if (!fs.existsSync(sourcePath)) { + throw new MissingBundledDirectoryContractArtifactError(id); + } + return (await import(pathToFileURL(sourcePath).href)) as ChannelDirectoryContractModule; +} + +function isMissingBundledDirectoryContractArtifact(error: unknown, id: ChannelId): boolean { + return ( + error instanceof Error && + error.message === `Unable to resolve bundled plugin public surface ${id}/directory-contract-api.js` + ); +} + +async function loadBundledChannelDirectoryContractSurface( + id: ChannelId, +): Promise { + return await loadBundledPluginPublicSurface({ + pluginId: id, + artifactBasename: "directory-contract-api.js", + }).catch((error: unknown) => { + if (isMissingBundledDirectoryContractArtifact(error, id)) { + throw new MissingBundledDirectoryContractArtifactError(id); + } + if (!isBuiltArtifactMissingDependency(error) || !canFallbackToPackageSource()) { + throw error; + } + return importBundledChannelDirectoryContractSourceSurface(id); + }); +} + +async function resolveBundledChannelPluginFromSurface( + id: ChannelId, + loaded: ChannelPluginApiModule, +): Promise { + const plugin = findChannelPlugin(loaded); + if (!plugin) { + return plugin; + } + if (hasBasePluginMetadata(plugin, id)) { + return plugin; + } + + const sourceLoaded = await importBundledChannelPluginSourceSurface(id); + const sourcePlugin = findChannelPlugin(sourceLoaded); + return sourcePlugin ?? plugin; +} + export function listBundledChannelPluginIds(): readonly ChannelId[] { return listCatalogBundledChannelPluginIds() as ChannelId[]; } @@ -127,8 +234,8 @@ export async function getBundledChannelPluginAsync( } return importBundledChannelPluginSourceSurface(id); }) - .then((loaded) => { - const plugin = Object.values(loaded).find(isChannelPlugin) ?? null; + .then(async (loaded) => { + const plugin = await resolveBundledChannelPluginFromSurface(id, loaded); channelPluginCache.set(id, plugin); return plugin; }) @@ -138,3 +245,40 @@ export async function getBundledChannelPluginAsync( channelPluginPromiseCache.set(id, loading); return (await loading) ?? undefined; } + +export async function getBundledChannelDirectoryPluginAsync( + id: ChannelId, +): Promise | undefined> { + if (channelDirectoryPluginCache.has(id)) { + return channelDirectoryPluginCache.get(id) ?? undefined; + } + + const cachedPromise = channelDirectoryPluginPromiseCache.get(id); + if (cachedPromise) { + return (await cachedPromise) ?? undefined; + } + + const loading = loadBundledChannelDirectoryContractSurface(id) + .catch(async (error: unknown) => { + if (error instanceof MissingBundledDirectoryContractArtifactError) { + return null; + } + throw error; + }) + .then(async (loaded) => { + if (!loaded) { + return (await getBundledChannelPluginAsync(id)) ?? null; + } + const plugin = findChannelDirectoryContractPlugin(loaded); + return plugin ?? (await getBundledChannelPluginAsync(id)) ?? null; + }) + .then((plugin) => { + channelDirectoryPluginCache.set(id, plugin); + return plugin; + }) + .finally(() => { + channelDirectoryPluginPromiseCache.delete(id); + }); + channelDirectoryPluginPromiseCache.set(id, loading); + return (await loading) ?? undefined; +} diff --git a/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts b/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts index c5ff26b14505..f53d5a0d7381 100644 --- a/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts +++ b/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts @@ -1,6 +1,9 @@ import { expectChannelPluginContract } from "openclaw/plugin-sdk/channel-test-helpers"; import { beforeAll, describe, it } from "vitest"; -import { getBundledChannelPluginAsync } from "./bundled-channel-plugin-loader.js"; +import { + getBundledChannelDirectoryPluginAsync, + getBundledChannelPluginAsync, +} from "./bundled-channel-plugin-loader.js"; import { channelPluginSurfaceKeys } from "./manifest.js"; import { getPluginContractRegistryShardRefs } from "./registry-plugin.js"; import { @@ -68,11 +71,14 @@ export function installDirectoryContractRegistryShard(params: ContractShardParam installEmptyShardSuite("directory contract registry shard"); return; } - const pluginCache = new Map>>(); + const pluginCache = new Map< + string, + Awaited> + >(); beforeAll(async () => { await Promise.all( entries.map(async (entry) => { - pluginCache.set(entry.id, await getBundledChannelPluginAsync(entry.id)); + pluginCache.set(entry.id, await getBundledChannelDirectoryPluginAsync(entry.id)); }), ); }); diff --git a/src/plugin-sdk/core.test.ts b/src/plugin-sdk/core.test.ts index 6fb75ba39da0..8fd8a4fc5e44 100644 --- a/src/plugin-sdk/core.test.ts +++ b/src/plugin-sdk/core.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { OpenClawPluginApi, PluginRegistrationMode } from "../plugins/types.js"; -import { defineChannelPluginEntry } from "./core.js"; +import { createChannelPluginBase, defineChannelPluginEntry } from "./core.js"; function createChannelPlugin(id: string): ChannelPlugin { return { @@ -119,3 +119,20 @@ describe("defineChannelPluginEntry", () => { expect(registerFull).toHaveBeenCalledWith(fullApi); }); }); + +describe("createChannelPluginBase", () => { + it("keeps meta id aligned with the channel id", () => { + const plugin = createChannelPluginBase({ + id: "metadata-id-channel", + meta: { + label: "Metadata ID Channel", + selectionLabel: "Metadata ID Channel", + docsPath: "/channels/metadata-id-channel", + blurb: "metadata id channel", + }, + setup: {} as NonNullable, + }); + + expect(plugin.meta.id).toBe("metadata-id-channel"); + }); +}); diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index d7c777d65a06..080f8b79178a 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -826,6 +826,7 @@ export function createChannelPluginBase( meta: { ...resolveSdkChatChannelMeta(params.id), ...params.meta, + id: params.id, }, ...(params.setupWizard ? { setupWizard: params.setupWizard } : {}), ...(params.capabilities ? { capabilities: params.capabilities } : {}),