refactor: dedupe plugin lazy runtime helpers

This commit is contained in:
Peter Steinberger
2026-03-17 09:24:14 -07:00
parent c94beb03b2
commit 39a8dab0da
20 changed files with 94 additions and 78 deletions

View File

@@ -949,12 +949,14 @@ authoring plugins:
- Domain subpaths such as `openclaw/plugin-sdk/channel-config-helpers`,
`openclaw/plugin-sdk/channel-config-schema`,
`openclaw/plugin-sdk/channel-policy`,
`openclaw/plugin-sdk/lazy-runtime`,
`openclaw/plugin-sdk/reply-history`,
`openclaw/plugin-sdk/routing`,
`openclaw/plugin-sdk/runtime-store`, and
`openclaw/plugin-sdk/directory-runtime` for shared runtime/config helpers.
- `openclaw/plugin-sdk/compat` remains as a legacy migration surface for older
external plugins. Bundled plugins should not use it.
external plugins. Bundled plugins should not use it, and non-test imports emit
a one-time deprecation warning outside test environments.
- `openclaw/plugin-sdk/telegram` for Telegram channel plugin types and shared channel-facing helpers. Built-in Telegram implementation internals stay private to the bundled extension.
- `openclaw/plugin-sdk/discord` for Discord channel plugin types and shared channel-facing helpers. Built-in Discord implementation internals stay private to the bundled extension.
- `openclaw/plugin-sdk/slack` for Slack channel plugin types and shared channel-facing helpers. Built-in Slack implementation internals stay private to the bundled extension.

View File

@@ -11,18 +11,16 @@ import {
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
} from "openclaw/plugin-sdk/bluebubbles";
import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
import type { BlueBubblesSendTarget } from "./types.js";
type BlueBubblesActionsRuntime = typeof import("./actions.runtime.js").blueBubblesActionsRuntime;
const loadBlueBubblesActionsRuntime = createLazyRuntimeSurface(
const loadBlueBubblesActionsRuntime = createLazyRuntimeNamedExport(
() => import("./actions.runtime.js"),
({ blueBubblesActionsRuntime }) => blueBubblesActionsRuntime,
"blueBubblesActionsRuntime",
);
const providerId = "bluebubbles";

View File

@@ -18,7 +18,7 @@ import {
buildAccountScopedDmSecurityPolicy,
collectOpenGroupPolicyRestrictSendersWarnings,
} from "openclaw/plugin-sdk/channel-policy";
import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import {
listBlueBubblesAccountIds,
type ResolvedBlueBubblesAccount,
@@ -38,11 +38,9 @@ import {
parseBlueBubblesTarget,
} from "./targets.js";
type BlueBubblesChannelRuntime = typeof import("./channel.runtime.js").blueBubblesChannelRuntime;
const loadBlueBubblesChannelRuntime = createLazyRuntimeSurface(
const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
({ blueBubblesChannelRuntime }) => blueBubblesChannelRuntime,
"blueBubblesChannelRuntime",
);
const meta = {

View File

@@ -1 +0,0 @@
export * from "../../../../test/helpers/extensions/discord-provider.test-support.js";

View File

@@ -12,7 +12,7 @@ import {
PAIRING_APPROVED_MESSAGE,
} from "openclaw/plugin-sdk/feishu";
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/feishu";
import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import {
resolveFeishuAccount,
resolveFeishuCredentials,
@@ -42,11 +42,9 @@ const meta: ChannelMeta = {
order: 70,
};
type FeishuChannelRuntime = typeof import("./channel.runtime.js").feishuChannelRuntime;
const loadFeishuChannelRuntime = createLazyRuntimeSurface(
const loadFeishuChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
({ feishuChannelRuntime }) => feishuChannelRuntime,
"feishuChannelRuntime",
);
function setFeishuNamedAccountEnabled(

View File

@@ -27,7 +27,7 @@ import {
type OpenClawConfig,
} from "openclaw/plugin-sdk/googlechat";
import { GoogleChatConfigSchema } from "openclaw/plugin-sdk/googlechat";
import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import {
listGoogleChatAccountIds,
@@ -48,11 +48,9 @@ import {
const meta = getChatChannelMeta("googlechat");
type GoogleChatChannelRuntime = typeof import("./channel.runtime.js").googleChatChannelRuntime;
const loadGoogleChatChannelRuntime = createLazyRuntimeSurface(
const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
({ googleChatChannelRuntime }) => googleChatChannelRuntime,
"googleChatChannelRuntime",
);
const formatAllowFromEntry = (entry: string) =>

View File

@@ -17,6 +17,7 @@ import {
resolveIMessageGroupToolPolicy,
type ChannelPlugin,
} from "openclaw/plugin-sdk/imessage";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { type RoutePeer } from "openclaw/plugin-sdk/routing";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
@@ -25,12 +26,7 @@ import { imessageSetupAdapter } from "./setup-core.js";
import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js";
import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js";
let imessageChannelRuntimePromise: Promise<typeof import("./channel.runtime.js")> | null = null;
async function loadIMessageChannelRuntime() {
imessageChannelRuntimePromise ??= import("./channel.runtime.js");
return imessageChannelRuntimePromise;
}
const loadIMessageChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
function buildIMessageBaseSessionKey(params: {
cfg: Parameters<typeof resolveIMessageAccount>[0]["cfg"];

View File

@@ -7,7 +7,7 @@ import {
buildOpenGroupPolicyWarning,
collectAllowlistProviderGroupPolicyWarnings,
} from "openclaw/plugin-sdk/channel-policy";
import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import {
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
@@ -39,11 +39,9 @@ import type { CoreConfig } from "./types.js";
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
let matrixStartupLock: Promise<void> = Promise.resolve();
type MatrixChannelRuntime = typeof import("./channel.runtime.js").matrixChannelRuntime;
const loadMatrixChannelRuntime = createLazyRuntimeSurface(
const loadMatrixChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
({ matrixChannelRuntime }) => matrixChannelRuntime,
"matrixChannelRuntime",
);
const meta = {

View File

@@ -1,6 +1,6 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import type {
ChannelMessageActionName,
ChannelPlugin,
@@ -57,11 +57,9 @@ const TEAMS_GRAPH_PERMISSION_HINTS: Record<string, string> = {
"Files.Read.All": "files (OneDrive)",
};
type MSTeamsChannelRuntime = typeof import("./channel.runtime.js").msTeamsChannelRuntime;
const loadMSTeamsChannelRuntime = createLazyRuntimeSurface(
const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
({ msTeamsChannelRuntime }) => msTeamsChannelRuntime,
"msTeamsChannelRuntime",
);
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {

View File

@@ -4,16 +4,16 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { writeSkill } from "../../../src/agents/skills.e2e-test-helpers.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
pluginCommandMocks,
resetPluginCommandMocks,
} from "../../../test/helpers/extensions/telegram-plugin-command.js";
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
import {
createNativeCommandTestParams,
resetNativeCommandMenuMocks,
waitForRegisteredCommands,
} from "./bot-native-commands.menu-test-support.js";
import {
pluginCommandMocks,
resetPluginCommandMocks,
} from "./bot-native-commands.plugin-command-test-support.js";
const tempDirs: string[] = [];

View File

@@ -8,7 +8,7 @@ import type { RuntimeEnv } from "../../../src/runtime.js";
import {
pluginCommandMocks,
resetPluginCommandMocks,
} from "./bot-native-commands.plugin-command-test-support.js";
} from "../../../test/helpers/extensions/telegram-plugin-command.js";
const skillCommandMocks = vi.hoisted(() => ({
listSkillCommandsForAgents: vi.fn(() => []),
}));

View File

@@ -1,3 +1,4 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/tlon";
import { tlonChannelConfigSchema } from "./config-schema.js";
import {
@@ -17,12 +18,7 @@ import { validateUrbitBaseUrl } from "./urbit/base-url.js";
const TLON_CHANNEL_ID = "tlon" as const;
let tlonChannelRuntimePromise: Promise<typeof import("./channel.runtime.js")> | null = null;
async function loadTlonChannelRuntime() {
tlonChannelRuntimePromise ??= import("./channel.runtime.js");
return tlonChannelRuntimePromise;
}
const loadTlonChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
const tlonSetupWizardProxy = createTlonSetupWizardBase({
resolveConfigured: async ({ cfg }) =>

View File

@@ -1,4 +1,4 @@
import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
@@ -7,11 +7,9 @@ import type {
import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk/zalo";
import { listEnabledZaloAccounts } from "./accounts.js";
type ZaloActionsRuntime = typeof import("./actions.runtime.js").zaloActionsRuntime;
const loadZaloActionsRuntime = createLazyRuntimeSurface(
const loadZaloActionsRuntime = createLazyRuntimeNamedExport(
() => import("./actions.runtime.js"),
({ zaloActionsRuntime }) => zaloActionsRuntime,
"zaloActionsRuntime",
);
const providerId = "zalo";

View File

@@ -5,6 +5,7 @@ import {
buildOpenGroupPolicyWarning,
collectOpenProviderGroupPolicyWarnings,
} from "openclaw/plugin-sdk/channel-policy";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import type {
ChannelAccountSnapshot,
ChannelPlugin,
@@ -56,12 +57,7 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined {
return trimmed.replace(/^(zalo|zl):/i, "");
}
let zaloChannelRuntimePromise: Promise<typeof import("./channel.runtime.js")> | null = null;
async function loadZaloChannelRuntime() {
zaloChannelRuntimePromise ??= import("./channel.runtime.js");
return zaloChannelRuntimePromise;
}
const loadZaloChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
id: "zalo",

View File

@@ -1,6 +1,22 @@
// Legacy compat surface for external plugins that still depend on older
// broad plugin-sdk imports. Keep this file intentionally small.
const shouldWarnCompatImport =
process.env.VITEST !== "true" &&
process.env.NODE_ENV !== "test" &&
process.env.OPENCLAW_SUPPRESS_PLUGIN_SDK_COMPAT_WARNING !== "1";
if (shouldWarnCompatImport) {
process.emitWarning(
"openclaw/plugin-sdk/compat is deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/<subpath> imports.",
{
code: "OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED",
detail:
"Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat temporarily while migrating.",
},
);
}
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export { resolveControlCommandGate } from "../channels/command-gating.js";

View File

@@ -1,5 +1,7 @@
export {
createLazyRuntimeModule,
createLazyRuntimeMethod,
createLazyRuntimeMethodBinder,
createLazyRuntimeNamedExport,
createLazyRuntimeSurface,
} from "../shared/lazy-runtime.js";

View File

@@ -7,6 +7,7 @@ import type {
} from "openclaw/plugin-sdk/core";
import * as discordSdk from "openclaw/plugin-sdk/discord";
import * as imessageSdk from "openclaw/plugin-sdk/imessage";
import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime";
import * as lineSdk from "openclaw/plugin-sdk/line";
import * as msteamsSdk from "openclaw/plugin-sdk/msteams";
import * as nostrSdk from "openclaw/plugin-sdk/nostr";
@@ -99,6 +100,12 @@ describe("plugin-sdk subpath exports", () => {
expect(typeof setupSdk.formatResolvedUnresolvedNote).toBe("function");
});
it("exports shared lazy runtime helpers from the dedicated subpath", () => {
expect(typeof lazyRuntimeSdk.createLazyRuntimeSurface).toBe("function");
expect(typeof lazyRuntimeSdk.createLazyRuntimeModule).toBe("function");
expect(typeof lazyRuntimeSdk.createLazyRuntimeNamedExport).toBe("function");
});
it("exports narrow self-hosted provider setup helpers", () => {
expect(typeof selfHostedProviderSetupSdk.buildVllmProvider).toBe("function");
expect(typeof selfHostedProviderSetupSdk.buildSglangProvider).toBe("function");

View File

@@ -9,6 +9,21 @@ export function createLazyRuntimeSurface<TModule, TSurface>(
};
}
/** Cache the raw dynamically imported runtime module behind a stable loader. */
export function createLazyRuntimeModule<TModule>(
importer: () => Promise<TModule>,
): () => Promise<TModule> {
return createLazyRuntimeSurface(importer, (module) => module);
}
/** Cache a single named runtime export without repeating a custom selector closure per caller. */
export function createLazyRuntimeNamedExport<TModule, const TKey extends keyof TModule>(
importer: () => Promise<TModule>,
key: TKey,
): () => Promise<TModule[TKey]> {
return createLazyRuntimeSurface(importer, (module) => module[key]);
}
export function createLazyRuntimeMethod<TSurface, TArgs extends unknown[], TResult>(
load: () => Promise<TSurface>,
select: (surface: TSurface) => (...args: TArgs) => TResult,

View File

@@ -255,6 +255,7 @@ export const baseConfig = (): OpenClawConfig =>
}) as OpenClawConfig;
vi.mock("@buape/carbon", () => {
class Command {}
class ReadyListener {}
class RateLimitError extends Error {
status = 429;
@@ -292,7 +293,7 @@ vi.mock("@buape/carbon", () => {
return clientGetPluginMock(name);
}
}
return { Client, RateLimitError, ReadyListener };
return { Client, Command, RateLimitError, ReadyListener };
});
vi.mock("@buape/carbon/gateway", () => ({
@@ -377,23 +378,23 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async () => {
};
});
vi.mock("../discord/src/accounts.js", () => ({
vi.mock("../../../extensions/discord/src/accounts.js", () => ({
resolveDiscordAccount: resolveDiscordAccountMock,
}));
vi.mock("../discord/src/probe.js", () => ({
vi.mock("../../../extensions/discord/src/probe.js", () => ({
fetchDiscordApplicationId: async () => "app-1",
}));
vi.mock("../discord/src/token.js", () => ({
vi.mock("../../../extensions/discord/src/token.js", () => ({
normalizeDiscordToken: (value?: string) => value,
}));
vi.mock("../discord/src/voice/command.js", () => ({
vi.mock("../../../extensions/discord/src/voice/command.js", () => ({
createDiscordVoiceCommand: () => ({ name: "voice-command" }),
}));
vi.mock("../discord/src/monitor/agent-components.js", () => ({
vi.mock("../../../extensions/discord/src/monitor/agent-components.js", () => ({
createAgentComponentButton: () => ({ id: "btn" }),
createAgentSelectMenu: () => ({ id: "menu" }),
createDiscordComponentButton: () => ({ id: "btn2" }),
@@ -405,15 +406,15 @@ vi.mock("../discord/src/monitor/agent-components.js", () => ({
createDiscordComponentUserSelect: () => ({ id: "user" }),
}));
vi.mock("../discord/src/monitor/auto-presence.js", () => ({
vi.mock("../../../extensions/discord/src/monitor/auto-presence.js", () => ({
createDiscordAutoPresenceController: createDiscordAutoPresenceControllerMock,
}));
vi.mock("../discord/src/monitor/commands.js", () => ({
vi.mock("../../../extensions/discord/src/monitor/commands.js", () => ({
resolveDiscordSlashCommandConfig: () => ({ ephemeral: false }),
}));
vi.mock("../discord/src/monitor/exec-approvals.js", () => ({
vi.mock("../../../extensions/discord/src/monitor/exec-approvals.js", () => ({
createExecApprovalButton: () => ({ id: "exec-approval" }),
DiscordExecApprovalHandler: class DiscordExecApprovalHandler {
async start() {
@@ -425,11 +426,11 @@ vi.mock("../discord/src/monitor/exec-approvals.js", () => ({
},
}));
vi.mock("../discord/src/monitor/gateway-plugin.js", () => ({
vi.mock("../../../extensions/discord/src/monitor/gateway-plugin.js", () => ({
createDiscordGatewayPlugin: () => ({ id: "gateway-plugin" }),
}));
vi.mock("../discord/src/monitor/listeners.js", () => ({
vi.mock("../../../extensions/discord/src/monitor/listeners.js", () => ({
DiscordMessageListener: class DiscordMessageListener {},
DiscordPresenceListener: class DiscordPresenceListener {},
DiscordReactionListener: class DiscordReactionListener {},
@@ -438,34 +439,34 @@ vi.mock("../discord/src/monitor/listeners.js", () => ({
registerDiscordListener: vi.fn(),
}));
vi.mock("../discord/src/monitor/message-handler.js", () => ({
vi.mock("../../../extensions/discord/src/monitor/message-handler.js", () => ({
createDiscordMessageHandler: createDiscordMessageHandlerMock,
}));
vi.mock("../discord/src/monitor/native-command.js", () => ({
vi.mock("../../../extensions/discord/src/monitor/native-command.js", () => ({
createDiscordCommandArgFallbackButton: () => ({ id: "arg-fallback" }),
createDiscordModelPickerFallbackButton: () => ({ id: "model-fallback-btn" }),
createDiscordModelPickerFallbackSelect: () => ({ id: "model-fallback-select" }),
createDiscordNativeCommand: createDiscordNativeCommandMock,
}));
vi.mock("../discord/src/monitor/presence.js", () => ({
vi.mock("../../../extensions/discord/src/monitor/presence.js", () => ({
resolveDiscordPresenceUpdate: () => undefined,
}));
vi.mock("../discord/src/monitor/provider.allowlist.js", () => ({
vi.mock("../../../extensions/discord/src/monitor/provider.allowlist.js", () => ({
resolveDiscordAllowlistConfig: resolveDiscordAllowlistConfigMock,
}));
vi.mock("../discord/src/monitor/provider.lifecycle.js", () => ({
vi.mock("../../../extensions/discord/src/monitor/provider.lifecycle.js", () => ({
runDiscordGatewayLifecycle: monitorLifecycleMock,
}));
vi.mock("../discord/src/monitor/rest-fetch.js", () => ({
vi.mock("../../../extensions/discord/src/monitor/rest-fetch.js", () => ({
resolveDiscordRestFetch: () => async () => undefined,
}));
vi.mock("../discord/src/monitor/thread-bindings.js", () => ({
vi.mock("../../../extensions/discord/src/monitor/thread-bindings.js", () => ({
createNoopThreadBindingManager: createNoopThreadBindingManagerMock,
createThreadBindingManager: createThreadBindingManagerMock,
reconcileAcpThreadBindingsOnStartup: reconcileAcpThreadBindingsOnStartupMock,