Skip to content

Commit a0717ef

Browse files
committed
fix(testing): speed channel contract loading
1 parent f0237ca commit a0717ef

9 files changed

Lines changed: 261 additions & 10 deletions

File tree

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1-
export {
1+
import {
22
listDiscordDirectoryGroupsFromConfig,
33
listDiscordDirectoryPeersFromConfig,
44
} from "./src/directory-config.js";
5+
6+
export {
7+
listDiscordDirectoryGroupsFromConfig,
8+
listDiscordDirectoryPeersFromConfig,
9+
};
10+
11+
export const discordDirectoryContractPlugin = {
12+
id: "discord",
13+
directory: {
14+
listPeers: listDiscordDirectoryPeersFromConfig,
15+
listGroups: listDiscordDirectoryGroupsFromConfig,
16+
},
17+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { googlechatDirectoryAdapter } from "./src/channel.adapters.js";
2+
3+
export const googlechatDirectoryContractPlugin = {
4+
id: "googlechat",
5+
directory: googlechatDirectoryAdapter,
6+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { ChannelDirectoryAdapter } from "openclaw/plugin-sdk/channel-contract";
2+
import { listDirectoryEntriesFromSources } from "openclaw/plugin-sdk/directory-runtime";
3+
import { normalizeMSTeamsMessagingTarget } from "./src/resolve-allowlist.js";
4+
import { resolveMSTeamsCredentials } from "./src/token.js";
5+
6+
const msteamsDirectoryContractAdapter: ChannelDirectoryAdapter = {
7+
self: async ({ cfg }) => {
8+
const creds = resolveMSTeamsCredentials(cfg.channels?.msteams);
9+
return creds ? { kind: "user" as const, id: creds.appId, name: creds.appId } : null;
10+
},
11+
listPeers: async ({ cfg, query, limit }) =>
12+
listDirectoryEntriesFromSources({
13+
kind: "user",
14+
sources: [
15+
cfg.channels?.msteams?.allowFrom ?? [],
16+
Object.keys(cfg.channels?.msteams?.dms ?? {}),
17+
],
18+
query,
19+
limit,
20+
normalizeId: (raw) => {
21+
const normalized = normalizeMSTeamsMessagingTarget(raw) ?? raw;
22+
const lowered = normalized.toLowerCase();
23+
return lowered.startsWith("user:") || lowered.startsWith("conversation:")
24+
? normalized
25+
: `user:${normalized}`;
26+
},
27+
}),
28+
listGroups: async ({ cfg, query, limit }) =>
29+
listDirectoryEntriesFromSources({
30+
kind: "group",
31+
sources: [
32+
Object.values(cfg.channels?.msteams?.teams ?? {}).flatMap((team) =>
33+
Object.keys(team.channels ?? {}),
34+
),
35+
],
36+
query,
37+
limit,
38+
normalizeId: (raw) => `conversation:${raw.replace(/^conversation:/i, "").trim()}`,
39+
}),
40+
};
41+
42+
export const msteamsDirectoryContractPlugin = {
43+
id: "msteams",
44+
directory: msteamsDirectoryContractAdapter,
45+
};
Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1-
export {
1+
import {
22
listWhatsAppDirectoryGroupsFromConfig,
33
listWhatsAppDirectoryPeersFromConfig,
44
} from "./src/directory-config.js";
5+
6+
export {
7+
listWhatsAppDirectoryGroupsFromConfig,
8+
listWhatsAppDirectoryPeersFromConfig,
9+
};
10+
11+
export const whatsappDirectoryContractPlugin = {
12+
id: "whatsapp",
13+
directory: {
14+
listPeers: listWhatsAppDirectoryPeersFromConfig,
15+
listGroups: listWhatsAppDirectoryGroupsFromConfig,
16+
},
17+
};

extensions/whatsapp/src/shared.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
createAllowlistProviderGroupPolicyWarningCollector,
1111
} from "openclaw/plugin-sdk/channel-policy";
1212
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
13-
import { createChannelPluginBase, getChatChannelMeta } from "openclaw/plugin-sdk/core";
13+
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
1414
import {
1515
createDelegatedSetupWizardProxy,
1616
type ChannelSetupWizard,
@@ -150,7 +150,13 @@ export function createWhatsAppPluginBase(params: {
150150
const base = createChannelPluginBase({
151151
id: WHATSAPP_CHANNEL,
152152
meta: {
153-
...getChatChannelMeta(WHATSAPP_CHANNEL),
153+
label: "WhatsApp",
154+
selectionLabel: "WhatsApp (QR link)",
155+
detailLabel: "WhatsApp Web",
156+
docsPath: "/channels/whatsapp",
157+
docsLabel: "whatsapp",
158+
blurb: "works with your own number; recommend a separate phone + eSIM.",
159+
systemImage: "message",
154160
showConfigured: false,
155161
quickstartAllowFrom: true,
156162
forceAccountBinding: true,

src/channels/plugins/contracts/test-helpers/bundled-channel-plugin-loader.ts

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,22 @@ import type { ChannelId } from "../../channel-id.types.js";
1010
import type { ChannelPlugin } from "../../types.js";
1111

1212
type ChannelPluginApiModule = Record<string, unknown>;
13+
type ChannelDirectoryContractModule = Record<string, unknown>;
1314

1415
const channelPluginCache = new Map<ChannelId, ChannelPlugin | null>();
1516
const channelPluginPromiseCache = new Map<ChannelId, Promise<ChannelPlugin | null>>();
17+
const channelDirectoryPluginCache = new Map<ChannelId, Pick<ChannelPlugin, "id" | "directory"> | null>();
18+
const channelDirectoryPluginPromiseCache = new Map<
19+
ChannelId,
20+
Promise<Pick<ChannelPlugin, "id" | "directory"> | null>
21+
>();
22+
23+
class MissingBundledDirectoryContractArtifactError extends Error {
24+
constructor(id: ChannelId) {
25+
super(`Missing bundled directory contract artifact for ${id}`);
26+
this.name = "MissingBundledDirectoryContractArtifactError";
27+
}
28+
}
1629

1730
function isChannelPlugin(value: unknown): value is ChannelPlugin {
1831
return (
@@ -24,6 +37,38 @@ function isChannelPlugin(value: unknown): value is ChannelPlugin {
2437
);
2538
}
2639

40+
function isChannelDirectoryContractPlugin(
41+
value: unknown,
42+
): value is Pick<ChannelPlugin, "id" | "directory"> {
43+
return (
44+
Boolean(value) &&
45+
typeof value === "object" &&
46+
typeof (value as Partial<ChannelPlugin>).id === "string" &&
47+
Boolean((value as Partial<ChannelPlugin>).directory)
48+
);
49+
}
50+
51+
function findChannelPlugin(module: ChannelPluginApiModule): ChannelPlugin | null {
52+
return Object.values(module).find(isChannelPlugin) ?? null;
53+
}
54+
55+
function findChannelDirectoryContractPlugin(
56+
module: ChannelDirectoryContractModule,
57+
): Pick<ChannelPlugin, "id" | "directory"> | null {
58+
return Object.values(module).find(isChannelDirectoryContractPlugin) ?? null;
59+
}
60+
61+
function hasBasePluginMetadata(plugin: ChannelPlugin | null, id: ChannelId): boolean {
62+
return (
63+
plugin?.id === id &&
64+
plugin.meta?.id === id &&
65+
typeof plugin.meta.label === "string" &&
66+
typeof plugin.meta.selectionLabel === "string" &&
67+
typeof plugin.meta.docsPath === "string" &&
68+
typeof plugin.meta.blurb === "string"
69+
);
70+
}
71+
2772
function isBuiltArtifactMissingDependency(error: unknown): boolean {
2873
const record = error as
2974
| {
@@ -101,6 +146,68 @@ async function importBundledChannelPluginSourceSurface(id: ChannelId) {
101146
return (await import(pathToFileURL(sourcePath).href)) as ChannelPluginApiModule;
102147
}
103148

149+
function resolveSourceArtifactPath(artifactPath: string): string {
150+
if (artifactPath.endsWith(".js") && fs.existsSync(`${artifactPath.slice(0, -3)}.ts`)) {
151+
return `${artifactPath.slice(0, -3)}.ts`;
152+
}
153+
return artifactPath;
154+
}
155+
156+
async function importBundledChannelDirectoryContractSourceSurface(
157+
id: ChannelId,
158+
): Promise<ChannelDirectoryContractModule> {
159+
const artifactPath = resolveBundledPluginPublicModulePath({
160+
pluginId: id,
161+
artifactBasename: "directory-contract-api.js",
162+
});
163+
const sourcePath = resolveSourceArtifactPath(artifactPath);
164+
if (!fs.existsSync(sourcePath)) {
165+
throw new MissingBundledDirectoryContractArtifactError(id);
166+
}
167+
return (await import(pathToFileURL(sourcePath).href)) as ChannelDirectoryContractModule;
168+
}
169+
170+
function isMissingBundledDirectoryContractArtifact(error: unknown, id: ChannelId): boolean {
171+
return (
172+
error instanceof Error &&
173+
error.message === `Unable to resolve bundled plugin public surface ${id}/directory-contract-api.js`
174+
);
175+
}
176+
177+
async function loadBundledChannelDirectoryContractSurface(
178+
id: ChannelId,
179+
): Promise<ChannelDirectoryContractModule> {
180+
return await loadBundledPluginPublicSurface<ChannelDirectoryContractModule>({
181+
pluginId: id,
182+
artifactBasename: "directory-contract-api.js",
183+
}).catch((error: unknown) => {
184+
if (isMissingBundledDirectoryContractArtifact(error, id)) {
185+
throw new MissingBundledDirectoryContractArtifactError(id);
186+
}
187+
if (!isBuiltArtifactMissingDependency(error) || !canFallbackToPackageSource()) {
188+
throw error;
189+
}
190+
return importBundledChannelDirectoryContractSourceSurface(id);
191+
});
192+
}
193+
194+
async function resolveBundledChannelPluginFromSurface(
195+
id: ChannelId,
196+
loaded: ChannelPluginApiModule,
197+
): Promise<ChannelPlugin | null> {
198+
const plugin = findChannelPlugin(loaded);
199+
if (!plugin) {
200+
return plugin;
201+
}
202+
if (hasBasePluginMetadata(plugin, id)) {
203+
return plugin;
204+
}
205+
206+
const sourceLoaded = await importBundledChannelPluginSourceSurface(id);
207+
const sourcePlugin = findChannelPlugin(sourceLoaded);
208+
return sourcePlugin ?? plugin;
209+
}
210+
104211
export function listBundledChannelPluginIds(): readonly ChannelId[] {
105212
return listCatalogBundledChannelPluginIds() as ChannelId[];
106213
}
@@ -127,8 +234,8 @@ export async function getBundledChannelPluginAsync(
127234
}
128235
return importBundledChannelPluginSourceSurface(id);
129236
})
130-
.then((loaded) => {
131-
const plugin = Object.values(loaded).find(isChannelPlugin) ?? null;
237+
.then(async (loaded) => {
238+
const plugin = await resolveBundledChannelPluginFromSurface(id, loaded);
132239
channelPluginCache.set(id, plugin);
133240
return plugin;
134241
})
@@ -138,3 +245,40 @@ export async function getBundledChannelPluginAsync(
138245
channelPluginPromiseCache.set(id, loading);
139246
return (await loading) ?? undefined;
140247
}
248+
249+
export async function getBundledChannelDirectoryPluginAsync(
250+
id: ChannelId,
251+
): Promise<Pick<ChannelPlugin, "id" | "directory"> | undefined> {
252+
if (channelDirectoryPluginCache.has(id)) {
253+
return channelDirectoryPluginCache.get(id) ?? undefined;
254+
}
255+
256+
const cachedPromise = channelDirectoryPluginPromiseCache.get(id);
257+
if (cachedPromise) {
258+
return (await cachedPromise) ?? undefined;
259+
}
260+
261+
const loading = loadBundledChannelDirectoryContractSurface(id)
262+
.catch(async (error: unknown) => {
263+
if (error instanceof MissingBundledDirectoryContractArtifactError) {
264+
return null;
265+
}
266+
throw error;
267+
})
268+
.then(async (loaded) => {
269+
if (!loaded) {
270+
return (await getBundledChannelPluginAsync(id)) ?? null;
271+
}
272+
const plugin = findChannelDirectoryContractPlugin(loaded);
273+
return plugin ?? (await getBundledChannelPluginAsync(id)) ?? null;
274+
})
275+
.then((plugin) => {
276+
channelDirectoryPluginCache.set(id, plugin);
277+
return plugin;
278+
})
279+
.finally(() => {
280+
channelDirectoryPluginPromiseCache.delete(id);
281+
});
282+
channelDirectoryPluginPromiseCache.set(id, loading);
283+
return (await loading) ?? undefined;
284+
}

src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { expectChannelPluginContract } from "openclaw/plugin-sdk/channel-test-helpers";
22
import { beforeAll, describe, it } from "vitest";
3-
import { getBundledChannelPluginAsync } from "./bundled-channel-plugin-loader.js";
3+
import {
4+
getBundledChannelDirectoryPluginAsync,
5+
getBundledChannelPluginAsync,
6+
} from "./bundled-channel-plugin-loader.js";
47
import { channelPluginSurfaceKeys } from "./manifest.js";
58
import { getPluginContractRegistryShardRefs } from "./registry-plugin.js";
69
import {
@@ -68,11 +71,14 @@ export function installDirectoryContractRegistryShard(params: ContractShardParam
6871
installEmptyShardSuite("directory contract registry shard");
6972
return;
7073
}
71-
const pluginCache = new Map<string, Awaited<ReturnType<typeof getBundledChannelPluginAsync>>>();
74+
const pluginCache = new Map<
75+
string,
76+
Awaited<ReturnType<typeof getBundledChannelDirectoryPluginAsync>>
77+
>();
7278
beforeAll(async () => {
7379
await Promise.all(
7480
entries.map(async (entry) => {
75-
pluginCache.set(entry.id, await getBundledChannelPluginAsync(entry.id));
81+
pluginCache.set(entry.id, await getBundledChannelDirectoryPluginAsync(entry.id));
7682
}),
7783
);
7884
});

src/plugin-sdk/core.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
22
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
33
import type { PluginRuntime } from "../plugins/runtime/types.js";
44
import type { OpenClawPluginApi, PluginRegistrationMode } from "../plugins/types.js";
5-
import { defineChannelPluginEntry } from "./core.js";
5+
import { createChannelPluginBase, defineChannelPluginEntry } from "./core.js";
66

77
function createChannelPlugin(id: string): ChannelPlugin {
88
return {
@@ -119,3 +119,20 @@ describe("defineChannelPluginEntry", () => {
119119
expect(registerFull).toHaveBeenCalledWith(fullApi);
120120
});
121121
});
122+
123+
describe("createChannelPluginBase", () => {
124+
it("keeps meta id aligned with the channel id", () => {
125+
const plugin = createChannelPluginBase({
126+
id: "metadata-id-channel",
127+
meta: {
128+
label: "Metadata ID Channel",
129+
selectionLabel: "Metadata ID Channel",
130+
docsPath: "/channels/metadata-id-channel",
131+
blurb: "metadata id channel",
132+
},
133+
setup: {} as NonNullable<ChannelPlugin["setup"]>,
134+
});
135+
136+
expect(plugin.meta.id).toBe("metadata-id-channel");
137+
});
138+
});

src/plugin-sdk/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,7 @@ export function createChannelPluginBase<TResolvedAccount>(
826826
meta: {
827827
...resolveSdkChatChannelMeta(params.id),
828828
...params.meta,
829+
id: params.id,
829830
},
830831
...(params.setupWizard ? { setupWizard: params.setupWizard } : {}),
831832
...(params.capabilities ? { capabilities: params.capabilities } : {}),

0 commit comments

Comments
 (0)