mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 22:12:32 +08:00
perf(slack): narrow runtime-setter + lazy-load 4 modules + narrow 2 SDK surfaces (#69317)
Lazy load modules showing a ~50% gateway startup performance improvement
This commit is contained in:
@@ -14,7 +14,7 @@ export default defineBundledChannelEntry({
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
specifier: "./runtime-setter-api.js",
|
||||
exportName: "setSlackRuntime",
|
||||
},
|
||||
});
|
||||
|
||||
4
extensions/slack/http-routes-api.ts
Normal file
4
extensions/slack/http-routes-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Narrow entry point for registerSlackPluginHttpRoutes — avoids pulling in
|
||||
// the full runtime-api barrel (~284KB, 13 chunks) during plugin register().
|
||||
// Mirrors the runtime-setter-api.ts split.
|
||||
export { registerSlackPluginHttpRoutes } from "./src/http/plugin-routes.js";
|
||||
@@ -6,7 +6,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-entry-contra
|
||||
|
||||
function registerSlackPluginHttpRoutes(api: OpenClawPluginApi): void {
|
||||
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
|
||||
specifier: "./runtime-api.js",
|
||||
specifier: "./http-routes-api.js",
|
||||
exportName: "registerSlackPluginHttpRoutes",
|
||||
});
|
||||
register(api);
|
||||
@@ -26,7 +26,7 @@ export default defineBundledChannelEntry({
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
specifier: "./runtime-setter-api.js",
|
||||
exportName: "setSlackRuntime",
|
||||
},
|
||||
accountInspect: {
|
||||
|
||||
3
extensions/slack/runtime-setter-api.ts
Normal file
3
extensions/slack/runtime-setter-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Narrow entry point for setSlackRuntime — avoids pulling in the full
|
||||
// runtime-api barrel (284KB, 29 chunks) during plugin register().
|
||||
export { setSlackRuntime } from "./src/runtime.js";
|
||||
318
extensions/slack/src/channel.lazy-seams.test.ts
Normal file
318
extensions/slack/src/channel.lazy-seams.test.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
// Regression tests for the lazy-loading boundaries introduced for Slack
|
||||
// startup-perf work (see PR #69317). Each test asserts both:
|
||||
// - that the lazy module is reached (call mocks fire), and
|
||||
// - that the inputs forwarded into the lazy module are correct, and
|
||||
// - that the lazy module's return value is propagated back through the
|
||||
// plugin surface unchanged.
|
||||
//
|
||||
// Together these guard against:
|
||||
// - dynamic-import path/specifier drift on cold paths,
|
||||
// - silent contract drift between the channel and its lazy modules,
|
||||
// - and accidental loss of the perf intent (re-introducing eager imports
|
||||
// without updating the seam).
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { slackPlugin } from "./channel.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { setSlackRuntime } from "./runtime.js";
|
||||
|
||||
// --- Hoisted mocks for lazy seams ------------------------------------------------
|
||||
|
||||
const collectAuditFindingsMock = vi.hoisted(() => vi.fn());
|
||||
const fetchSlackScopesMock = vi.hoisted(() => vi.fn());
|
||||
const resolveTargetsWithOptionalTokenMock = vi.hoisted(() => vi.fn());
|
||||
const buildPassiveProbedChannelStatusSummaryMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("./security-audit.js", () => ({
|
||||
collectSlackSecurityAuditFindings: collectAuditFindingsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./scopes.js", () => ({
|
||||
fetchSlackScopes: fetchSlackScopesMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/target-resolver-runtime", async (orig) => {
|
||||
// Preserve any sibling exports so importers that touch unrelated helpers
|
||||
// do not break; only override the function the channel actually calls.
|
||||
const original = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
...original,
|
||||
resolveTargetsWithOptionalToken: resolveTargetsWithOptionalTokenMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/extension-shared", async (orig) => {
|
||||
const original = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
...original,
|
||||
buildPassiveProbedChannelStatusSummary: buildPassiveProbedChannelStatusSummaryMock,
|
||||
};
|
||||
});
|
||||
|
||||
// --- Test setup -----------------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
collectAuditFindingsMock.mockReset();
|
||||
fetchSlackScopesMock.mockReset();
|
||||
resolveTargetsWithOptionalTokenMock.mockReset();
|
||||
buildPassiveProbedChannelStatusSummaryMock.mockReset();
|
||||
setSlackRuntime({ channel: { slack: {} } } as never);
|
||||
});
|
||||
|
||||
function makeMinimalSlackConfig(
|
||||
opts: { botToken?: string; userToken?: string } = {},
|
||||
): OpenClawConfig {
|
||||
const slack: Record<string, unknown> = {};
|
||||
if (opts.botToken !== undefined) {
|
||||
slack.botToken = opts.botToken;
|
||||
}
|
||||
if (opts.userToken !== undefined) {
|
||||
slack.userToken = opts.userToken;
|
||||
}
|
||||
return { channels: { slack } } as OpenClawConfig;
|
||||
}
|
||||
|
||||
// --- Status: buildChannelSummary -------------------------------------------------
|
||||
|
||||
describe("slackPlugin.status.buildChannelSummary lazy SDK forwarding", () => {
|
||||
it("calls the lazy extension-shared SDK helper with the snapshot and token sources, and returns its output unchanged", async () => {
|
||||
const buildChannelSummary = slackPlugin.status?.buildChannelSummary;
|
||||
if (!buildChannelSummary) {
|
||||
throw new Error("slackPlugin.status.buildChannelSummary should be exposed");
|
||||
}
|
||||
|
||||
const sentinelSummary = { sentinel: "passive-summary" };
|
||||
buildPassiveProbedChannelStatusSummaryMock.mockReturnValue(sentinelSummary);
|
||||
|
||||
const snapshot = {
|
||||
accountId: "default",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
botTokenSource: "config" as const,
|
||||
appTokenSource: "config" as const,
|
||||
extra: { custom: 1 },
|
||||
};
|
||||
|
||||
const result = await buildChannelSummary({
|
||||
account: { accountId: "default" } as never,
|
||||
snapshot,
|
||||
cfg: makeMinimalSlackConfig({ botToken: "xoxb-test" }),
|
||||
runtime: undefined,
|
||||
} as never);
|
||||
|
||||
expect(buildPassiveProbedChannelStatusSummaryMock).toHaveBeenCalledTimes(1);
|
||||
const [forwardedSnapshot, forwardedExtras] =
|
||||
buildPassiveProbedChannelStatusSummaryMock.mock.calls[0] ?? [];
|
||||
// Snapshot must be forwarded by reference / structurally intact.
|
||||
expect(forwardedSnapshot).toBe(snapshot);
|
||||
// The channel must forward the (possibly fallback'd) token sources.
|
||||
expect(forwardedExtras).toEqual({ botTokenSource: "config", appTokenSource: "config" });
|
||||
// The SDK return value must be propagated through unchanged.
|
||||
expect(result).toBe(sentinelSummary);
|
||||
});
|
||||
|
||||
it("falls back to 'none' for missing token sources before forwarding to the SDK helper", async () => {
|
||||
const buildChannelSummary = slackPlugin.status?.buildChannelSummary;
|
||||
if (!buildChannelSummary) {
|
||||
throw new Error("slackPlugin.status.buildChannelSummary should be exposed");
|
||||
}
|
||||
|
||||
buildPassiveProbedChannelStatusSummaryMock.mockReturnValue({ sentinel: true });
|
||||
|
||||
await buildChannelSummary({
|
||||
account: { accountId: "default" } as never,
|
||||
snapshot: { accountId: "default", configured: false, enabled: true } as never,
|
||||
cfg: makeMinimalSlackConfig(),
|
||||
runtime: undefined,
|
||||
} as never);
|
||||
|
||||
const [, forwardedExtras] = buildPassiveProbedChannelStatusSummaryMock.mock.calls[0] ?? [];
|
||||
expect(forwardedExtras).toEqual({ botTokenSource: "none", appTokenSource: "none" });
|
||||
});
|
||||
});
|
||||
|
||||
// --- Status: buildCapabilitiesDiagnostics ---------------------------------------
|
||||
|
||||
describe("slackPlugin.status.buildCapabilitiesDiagnostics lazy scopes loader", () => {
|
||||
it("invokes fetchSlackScopes once when only a bot token is present", async () => {
|
||||
const buildDiagnostics = slackPlugin.status?.buildCapabilitiesDiagnostics;
|
||||
if (!buildDiagnostics) {
|
||||
throw new Error("slackPlugin.status.buildCapabilitiesDiagnostics should be exposed");
|
||||
}
|
||||
|
||||
fetchSlackScopesMock.mockResolvedValue({ ok: true, scopes: ["chat:write"] });
|
||||
|
||||
const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot" });
|
||||
const account = slackPlugin.config.resolveAccount(cfg, "default");
|
||||
const result = await buildDiagnostics({ account, timeoutMs: 1234, cfg } as never);
|
||||
|
||||
expect(fetchSlackScopesMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSlackScopesMock).toHaveBeenCalledWith("xoxb-bot", 1234);
|
||||
expect(result?.details).toMatchObject({
|
||||
botScopes: { ok: true, scopes: ["chat:write"] },
|
||||
});
|
||||
expect(result?.lines?.length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("invokes fetchSlackScopes twice (bot and user) when both tokens are present", async () => {
|
||||
const buildDiagnostics = slackPlugin.status?.buildCapabilitiesDiagnostics;
|
||||
if (!buildDiagnostics) {
|
||||
throw new Error("slackPlugin.status.buildCapabilitiesDiagnostics should be exposed");
|
||||
}
|
||||
|
||||
fetchSlackScopesMock
|
||||
.mockResolvedValueOnce({ ok: true, scopes: ["chat:write"] })
|
||||
.mockResolvedValueOnce({ ok: true, scopes: ["users:read"] });
|
||||
|
||||
const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot", userToken: "xoxp-user" });
|
||||
const account = slackPlugin.config.resolveAccount(cfg, "default");
|
||||
const result = await buildDiagnostics({ account, timeoutMs: 5000, cfg } as never);
|
||||
|
||||
expect(fetchSlackScopesMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchSlackScopesMock.mock.calls[0]).toEqual(["xoxb-bot", 5000]);
|
||||
expect(fetchSlackScopesMock.mock.calls[1]).toEqual(["xoxp-user", 5000]);
|
||||
expect(result?.details).toMatchObject({
|
||||
botScopes: { ok: true, scopes: ["chat:write"] },
|
||||
userScopes: { ok: true, scopes: ["users:read"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not invoke fetchSlackScopes when no bot token is present and reports a missing-token diagnostic", async () => {
|
||||
const buildDiagnostics = slackPlugin.status?.buildCapabilitiesDiagnostics;
|
||||
if (!buildDiagnostics) {
|
||||
throw new Error("slackPlugin.status.buildCapabilitiesDiagnostics should be exposed");
|
||||
}
|
||||
|
||||
const cfg = makeMinimalSlackConfig();
|
||||
const account = slackPlugin.config.resolveAccount(cfg, "default");
|
||||
const result = await buildDiagnostics({ account, timeoutMs: 1000, cfg } as never);
|
||||
|
||||
expect(fetchSlackScopesMock).not.toHaveBeenCalled();
|
||||
expect(result?.details).toMatchObject({
|
||||
botScopes: { ok: false, error: "Slack bot token missing." },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Security: collectAuditFindings ---------------------------------------------
|
||||
|
||||
describe("slackPlugin.security.collectAuditFindings lazy module forwarding", () => {
|
||||
it("delegates to the lazy security-audit module with the original params and returns its output", async () => {
|
||||
const collectAuditFindings = slackPlugin.security?.collectAuditFindings;
|
||||
if (!collectAuditFindings) {
|
||||
throw new Error("slackPlugin.security.collectAuditFindings should be exposed");
|
||||
}
|
||||
|
||||
const sentinel = [
|
||||
{
|
||||
checkId: "test-check",
|
||||
severity: "info" as const,
|
||||
title: "t",
|
||||
detail: "d",
|
||||
},
|
||||
];
|
||||
collectAuditFindingsMock.mockResolvedValue(sentinel);
|
||||
|
||||
const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot" });
|
||||
const account = slackPlugin.config.resolveAccount(cfg, "default");
|
||||
const result = await collectAuditFindings({ cfg, accountId: "default", account } as never);
|
||||
|
||||
expect(collectAuditFindingsMock).toHaveBeenCalledTimes(1);
|
||||
expect(collectAuditFindingsMock.mock.calls[0]?.[0]).toEqual({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
account,
|
||||
});
|
||||
expect(result).toBe(sentinel);
|
||||
});
|
||||
|
||||
it("propagates an empty findings array unchanged", async () => {
|
||||
const collectAuditFindings = slackPlugin.security?.collectAuditFindings;
|
||||
if (!collectAuditFindings) {
|
||||
throw new Error("slackPlugin.security.collectAuditFindings should be exposed");
|
||||
}
|
||||
|
||||
collectAuditFindingsMock.mockResolvedValue([]);
|
||||
|
||||
const cfg = makeMinimalSlackConfig();
|
||||
const account = slackPlugin.config.resolveAccount(cfg, "default");
|
||||
const result = await collectAuditFindings({ cfg, account } as never);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Resolver: resolveTargets ---------------------------------------------------
|
||||
|
||||
describe("slackPlugin.resolver.resolveTargets lazy SDK forwarding", () => {
|
||||
it("forwards user inputs and the configured token to the lazy SDK helper and returns its output", async () => {
|
||||
const resolveTargets = slackPlugin.resolver?.resolveTargets;
|
||||
if (!resolveTargets) {
|
||||
throw new Error("slackPlugin.resolver.resolveTargets should be exposed");
|
||||
}
|
||||
|
||||
const sentinelOutput = [{ input: "U123", resolved: true, id: "U123", note: undefined }];
|
||||
resolveTargetsWithOptionalTokenMock.mockResolvedValue(sentinelOutput);
|
||||
|
||||
const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot" });
|
||||
const result = await resolveTargets({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
inputs: ["U123"],
|
||||
kind: "user",
|
||||
} as never);
|
||||
|
||||
expect(resolveTargetsWithOptionalTokenMock).toHaveBeenCalledTimes(1);
|
||||
const [params] = resolveTargetsWithOptionalTokenMock.mock.calls[0] ?? [];
|
||||
expect(params).toMatchObject({
|
||||
token: "xoxb-bot",
|
||||
inputs: ["U123"],
|
||||
missingTokenNote: "missing Slack token",
|
||||
});
|
||||
expect(typeof params.resolveWithToken).toBe("function");
|
||||
expect(typeof params.mapResolved).toBe("function");
|
||||
expect(result).toBe(sentinelOutput);
|
||||
});
|
||||
|
||||
it("prefers the user token over the bot token when both are configured", async () => {
|
||||
const resolveTargets = slackPlugin.resolver?.resolveTargets;
|
||||
if (!resolveTargets) {
|
||||
throw new Error("slackPlugin.resolver.resolveTargets should be exposed");
|
||||
}
|
||||
|
||||
resolveTargetsWithOptionalTokenMock.mockResolvedValue([]);
|
||||
|
||||
await resolveTargets({
|
||||
cfg: makeMinimalSlackConfig({ botToken: "xoxb-bot", userToken: "xoxp-user" }),
|
||||
accountId: "default",
|
||||
inputs: ["U1"],
|
||||
kind: "user",
|
||||
} as never);
|
||||
|
||||
const [params] = resolveTargetsWithOptionalTokenMock.mock.calls[0] ?? [];
|
||||
expect(params).toMatchObject({ token: "xoxp-user" });
|
||||
});
|
||||
|
||||
it("uses the same lazy SDK helper for kind='group'", async () => {
|
||||
const resolveTargets = slackPlugin.resolver?.resolveTargets;
|
||||
if (!resolveTargets) {
|
||||
throw new Error("slackPlugin.resolver.resolveTargets should be exposed");
|
||||
}
|
||||
|
||||
resolveTargetsWithOptionalTokenMock.mockResolvedValue([]);
|
||||
|
||||
await resolveTargets({
|
||||
cfg: makeMinimalSlackConfig({ botToken: "xoxb-bot" }),
|
||||
accountId: "default",
|
||||
inputs: ["C1"],
|
||||
kind: "group",
|
||||
} as never);
|
||||
|
||||
expect(resolveTargetsWithOptionalTokenMock).toHaveBeenCalledTimes(1);
|
||||
const [params] = resolveTargetsWithOptionalTokenMock.mock.calls[0] ?? [];
|
||||
expect(params).toMatchObject({ token: "xoxb-bot", inputs: ["C1"] });
|
||||
});
|
||||
});
|
||||
|
||||
// Setup-wizard proxy delegation is unit-tested directly in
|
||||
// setup-core.lazy-proxy.test.ts so it can be type-safe against the wider
|
||||
// ChannelSetupWizard contract returned by createSlackSetupWizardProxy.
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
createChannelDirectoryAdapter,
|
||||
createRuntimeDirectoryLiveAdapter,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing";
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/target-resolver-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
resolveDefaultSlackAccountId,
|
||||
@@ -51,14 +49,11 @@ import {
|
||||
isSlackInteractiveRepliesEnabled,
|
||||
} from "./interactive-replies.js";
|
||||
import { SLACK_TEXT_LIMIT } from "./limits.js";
|
||||
import { slackOutbound } from "./outbound-adapter.js";
|
||||
import type { SlackProbe } from "./probe.js";
|
||||
import { resolveSlackReplyBlocks } from "./reply-blocks.js";
|
||||
import { getOptionalSlackRuntime } from "./runtime.js";
|
||||
import { fetchSlackScopes } from "./scopes.js";
|
||||
import { slackSecurityAdapter } from "./security.js";
|
||||
import { slackSetupAdapter } from "./setup-core.js";
|
||||
import { slackSetupWizard } from "./setup-surface.js";
|
||||
import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js";
|
||||
import {
|
||||
createSlackPluginBase,
|
||||
isSlackPluginAccountConfigured,
|
||||
@@ -68,6 +63,70 @@ import {
|
||||
import { parseSlackTarget } from "./target-parsing.js";
|
||||
import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
|
||||
|
||||
// Lazy SDK loaders. The dynamic import is hidden behind a string-literal
|
||||
// module id and typed by a hand-written structural alias so TypeScript does
|
||||
// not have to crawl the SDK module's type graph just to type the loader.
|
||||
//
|
||||
// `openclaw/plugin-sdk/channel-policy` is intentionally NOT lazy here —
|
||||
// `./group-policy.js` already imports it eagerly, so deferring it from
|
||||
// `channel.ts` would not change the load graph.
|
||||
|
||||
type ExtensionSharedSurface = {
|
||||
buildPassiveProbedChannelStatusSummary: <TExtra extends object>(
|
||||
snapshot: {
|
||||
configured?: boolean;
|
||||
running?: boolean;
|
||||
lastStartAt?: number | null;
|
||||
lastStopAt?: number | null;
|
||||
lastError?: string | null;
|
||||
probe?: unknown;
|
||||
lastProbeAt?: number | null;
|
||||
},
|
||||
extra?: TExtra,
|
||||
) => {
|
||||
configured: boolean;
|
||||
running: boolean;
|
||||
lastStartAt: number | null;
|
||||
lastStopAt: number | null;
|
||||
lastError: string | null;
|
||||
probe: unknown;
|
||||
lastProbeAt: number | null;
|
||||
} & TExtra;
|
||||
};
|
||||
|
||||
type TargetResolverRuntimeSurface = {
|
||||
resolveTargetsWithOptionalToken: <TResult>(params: {
|
||||
token?: string | null;
|
||||
inputs: string[];
|
||||
missingTokenNote: string;
|
||||
resolveWithToken: (params: { token: string; inputs: string[] }) => Promise<TResult[]>;
|
||||
mapResolved: (entry: TResult) => {
|
||||
input: string;
|
||||
resolved: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
note?: string;
|
||||
};
|
||||
}) => Promise<
|
||||
Array<{ input: string; resolved: boolean; id?: string; name?: string; note?: string }>
|
||||
>;
|
||||
};
|
||||
|
||||
const EXTENSION_SHARED_MODULE_ID = "openclaw/plugin-sdk/extension-shared";
|
||||
const TARGET_RESOLVER_RUNTIME_MODULE_ID = "openclaw/plugin-sdk/target-resolver-runtime";
|
||||
|
||||
const loadExtensionSharedSdk = createLazyRuntimeModule(
|
||||
() => import(EXTENSION_SHARED_MODULE_ID) as Promise<ExtensionSharedSurface>,
|
||||
);
|
||||
const loadTargetResolverRuntimeSdk = createLazyRuntimeModule(
|
||||
() => import(TARGET_RESOLVER_RUNTIME_MODULE_ID) as Promise<TargetResolverRuntimeSurface>,
|
||||
);
|
||||
|
||||
const loadSlackSetupSurfaceModule = createLazyRuntimeModule(() => import("./setup-surface.js"));
|
||||
const loadSlackScopesModule = createLazyRuntimeModule(() => import("./scopes.js"));
|
||||
const loadSlackOutboundAdapterModule = createLazyRuntimeModule(
|
||||
() => import("./outbound-adapter.js"),
|
||||
);
|
||||
async function resolveSlackHandleAction() {
|
||||
return (
|
||||
getOptionalSlackRuntime()?.channel?.slack?.handleSlackAction ??
|
||||
@@ -258,9 +317,18 @@ async function resolveSlackOutboundSessionRoute(params: {
|
||||
});
|
||||
}
|
||||
|
||||
// Mirrors `SlackScopesResult` in ./scopes.ts so the type does not pull the
|
||||
// scopes module back in at module-load time. Keep the two in sync.
|
||||
type SlackScopesResultShape = {
|
||||
ok: boolean;
|
||||
scopes?: string[];
|
||||
source?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function formatSlackScopeDiagnostic(params: {
|
||||
tokenType: "bot" | "user";
|
||||
result: Awaited<ReturnType<typeof fetchSlackScopes>>;
|
||||
result: SlackScopesResultShape;
|
||||
}) {
|
||||
const source = params.result.source ? ` (${params.result.source})` : "";
|
||||
const label = params.tokenType === "user" ? "User scopes" : "Bot scopes";
|
||||
@@ -293,7 +361,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
>({
|
||||
base: {
|
||||
...createSlackPluginBase({
|
||||
setupWizard: slackSetupWizard,
|
||||
setupWizard: createSlackSetupWizardProxy(loadSlackSetupSurfaceModule),
|
||||
setup: slackSetupAdapter,
|
||||
}),
|
||||
allowlist: {
|
||||
@@ -379,6 +447,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
note,
|
||||
});
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const { resolveTargetsWithOptionalToken } = await loadTargetResolverRuntimeSdk();
|
||||
if (kind === "group") {
|
||||
return resolveTargetsWithOptionalToken({
|
||||
token:
|
||||
@@ -418,11 +487,13 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
}),
|
||||
status: createComputedAccountStatusAdapter<ResolvedSlackAccount, SlackProbe>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildPassiveProbedChannelStatusSummary(snapshot, {
|
||||
buildChannelSummary: async ({ snapshot }) => {
|
||||
const { buildPassiveProbedChannelStatusSummary } = await loadExtensionSharedSdk();
|
||||
return buildPassiveProbedChannelStatusSummary(snapshot, {
|
||||
botTokenSource: snapshot.botTokenSource ?? "none",
|
||||
appTokenSource: snapshot.appTokenSource ?? "none",
|
||||
}),
|
||||
});
|
||||
},
|
||||
probeAccount: async ({ account, timeoutMs }) => {
|
||||
const token = account.botToken?.trim();
|
||||
if (!token) {
|
||||
@@ -447,7 +518,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
const details: Record<string, unknown> = {};
|
||||
const botToken = account.botToken?.trim();
|
||||
const userToken = account.config.userToken?.trim();
|
||||
const botScopes = botToken
|
||||
const { fetchSlackScopes } = await loadSlackScopesModule();
|
||||
const botScopes: SlackScopesResultShape = botToken
|
||||
? await fetchSlackScopes(botToken, timeoutMs)
|
||||
: { ok: false, error: "Slack bot token missing." };
|
||||
lines.push(formatSlackScopeDiagnostic({ tokenType: "bot", result: botScopes }));
|
||||
@@ -567,6 +639,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
replyToId: ctx.replyToId,
|
||||
threadId: ctx.threadId,
|
||||
});
|
||||
const { slackOutbound } = await loadSlackOutboundAdapterModule();
|
||||
return await slackOutbound.sendPayload!({
|
||||
...ctx,
|
||||
deps: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { ResolvedSlackAccount } from "./accounts.js";
|
||||
import type { ChannelPlugin } from "./channel-api.js";
|
||||
import { collectSlackSecurityAuditFindings } from "./security-audit.js";
|
||||
|
||||
const resolveSlackDmPolicy = createScopedDmSecurityResolver<ResolvedSlackAccount>({
|
||||
channelKey: "slack",
|
||||
@@ -36,8 +36,13 @@ const collectSlackSecurityWarnings =
|
||||
},
|
||||
});
|
||||
|
||||
const loadSlackSecurityAuditModule = createLazyRuntimeModule(() => import("./security-audit.js"));
|
||||
|
||||
export const slackSecurityAdapter = {
|
||||
resolveDmPolicy: resolveSlackDmPolicy,
|
||||
collectWarnings: collectSlackSecurityWarnings,
|
||||
collectAuditFindings: collectSlackSecurityAuditFindings,
|
||||
collectAuditFindings: async (params) => {
|
||||
const { collectSlackSecurityAuditFindings } = await loadSlackSecurityAuditModule();
|
||||
return await collectSlackSecurityAuditFindings(params);
|
||||
},
|
||||
} satisfies NonNullable<ChannelPlugin<ResolvedSlackAccount>["security"]>;
|
||||
|
||||
125
extensions/slack/src/setup-core.lazy-proxy.test.ts
Normal file
125
extensions/slack/src/setup-core.lazy-proxy.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// Unit tests for createSlackSetupWizardProxy. The Slack channel plugin
|
||||
// installs this proxy as setupWizard so the heavy ./setup-surface module is
|
||||
// only imported when wizard methods that actually need it are invoked.
|
||||
//
|
||||
// These tests use a fake loader so the proxy can be tested type-safely
|
||||
// against the wider ChannelSetupWizard contract, without going through the
|
||||
// (narrower) ChannelPluginSetupWizard surface exposed on slackPlugin.
|
||||
|
||||
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSlackSetupWizardProxy } from "./setup-core.js";
|
||||
|
||||
function makeFakeWizard(overrides: Partial<ChannelSetupWizard> = {}): ChannelSetupWizard {
|
||||
return {
|
||||
channel: "slack",
|
||||
status: {
|
||||
resolveConfigured: vi.fn(async () => ({ configured: false })),
|
||||
},
|
||||
credentials: [],
|
||||
...overrides,
|
||||
} as ChannelSetupWizard;
|
||||
}
|
||||
|
||||
describe("createSlackSetupWizardProxy", () => {
|
||||
it("does not load the wizard module just by constructing the proxy", () => {
|
||||
const loader = vi.fn(async () => ({ slackSetupWizard: makeFakeWizard() }));
|
||||
const proxy = createSlackSetupWizardProxy(loader);
|
||||
expect(proxy).toBeDefined();
|
||||
expect(loader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards allowFrom.resolveEntries to the lazily loaded wizard and propagates its result", async () => {
|
||||
const sentinel = [{ input: "U123", resolved: true, id: "U123" }];
|
||||
const resolveEntries = vi.fn(async () => sentinel);
|
||||
// The full ChannelSetupWizardAllowFrom type carries many UI-only fields
|
||||
// (placeholder, parseId, etc.) that are irrelevant to the proxy's
|
||||
// delegation contract. Build a minimal stub and cast through unknown so
|
||||
// the assertion stays focused on resolveEntries forwarding.
|
||||
const fakeWizard = makeFakeWizard({
|
||||
allowFrom: {
|
||||
resolveEntries,
|
||||
} as unknown as ChannelSetupWizard["allowFrom"],
|
||||
});
|
||||
const loader = vi.fn(async () => ({ slackSetupWizard: fakeWizard }));
|
||||
const proxy = createSlackSetupWizardProxy(loader);
|
||||
|
||||
const cfg = { channels: { slack: {} } } as never;
|
||||
const result = await proxy.allowFrom!.resolveEntries({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
credentialValues: { botToken: "xoxb-bot" },
|
||||
entries: ["U123"],
|
||||
});
|
||||
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
expect(resolveEntries).toHaveBeenCalledTimes(1);
|
||||
expect(resolveEntries).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
credentialValues: { botToken: "xoxb-bot" },
|
||||
entries: ["U123"],
|
||||
});
|
||||
expect(result).toBe(sentinel);
|
||||
});
|
||||
|
||||
it("returns unresolved entries without invoking the lazy wizard when allowFrom is absent on the loaded wizard", async () => {
|
||||
const fakeWizard = makeFakeWizard();
|
||||
const loader = vi.fn(async () => ({ slackSetupWizard: fakeWizard }));
|
||||
const proxy = createSlackSetupWizardProxy(loader);
|
||||
|
||||
const result = await proxy.allowFrom!.resolveEntries({
|
||||
cfg: { channels: { slack: {} } } as never,
|
||||
accountId: "default",
|
||||
credentialValues: {},
|
||||
entries: ["U1", "U2"],
|
||||
});
|
||||
|
||||
// The proxy still loads the wizard once to inspect its allowFrom shape,
|
||||
// then falls back to a "resolved: false" projection of the inputs.
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual([
|
||||
{ input: "U1", resolved: false, id: null },
|
||||
{ input: "U2", resolved: false, id: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards groupAccess.resolveAllowlist when present and uses the configured fallback otherwise", async () => {
|
||||
// First: with groupAccess present, the lazy wizard handles resolution.
|
||||
const groupResolved = ["G1-resolved"];
|
||||
const resolveAllowlist = vi.fn(async () => groupResolved);
|
||||
const fakeWithGroupAccess = makeFakeWizard({
|
||||
groupAccess: {
|
||||
resolveAllowlist,
|
||||
} as unknown as ChannelSetupWizard["groupAccess"],
|
||||
});
|
||||
const loaderA = vi.fn(async () => ({ slackSetupWizard: fakeWithGroupAccess }));
|
||||
const proxyA = createSlackSetupWizardProxy(loaderA);
|
||||
|
||||
const cfg = { channels: { slack: {} } } as never;
|
||||
const a = await proxyA.groupAccess!.resolveAllowlist!({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
credentialValues: {},
|
||||
entries: ["G1"],
|
||||
prompter: undefined as never,
|
||||
});
|
||||
expect(resolveAllowlist).toHaveBeenCalledTimes(1);
|
||||
expect(a).toBe(groupResolved);
|
||||
|
||||
// Second: without groupAccess, the fallback (entries -> entries) is used.
|
||||
const fakeNoGroupAccess = makeFakeWizard();
|
||||
const loaderB = vi.fn(async () => ({ slackSetupWizard: fakeNoGroupAccess }));
|
||||
const proxyB = createSlackSetupWizardProxy(loaderB);
|
||||
|
||||
const b = await proxyB.groupAccess!.resolveAllowlist!({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
credentialValues: {},
|
||||
entries: ["G1", "G2"],
|
||||
prompter: undefined as never,
|
||||
});
|
||||
// createSlackSetupWizardProxy passes (entries) => entries as the fallback.
|
||||
expect(b).toEqual(["G1", "G2"]);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@
|
||||
"dist/extensions/qqbot/runtime-api.js",
|
||||
"dist/extensions/signal/runtime-api.js",
|
||||
"dist/extensions/slack/runtime-api.js",
|
||||
"dist/extensions/slack/runtime-setter-api.js",
|
||||
"dist/extensions/telegram/runtime-api.js",
|
||||
"dist/extensions/telegram/runtime-setter-api.js",
|
||||
"dist/extensions/tlon/runtime-api.js",
|
||||
|
||||
@@ -165,6 +165,17 @@ describe("bundled plugin metadata", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Slack's narrow runtime-setter sidecar on the bundled public surface", () => {
|
||||
// Regression for #69317: the bundled channel entry now points its
|
||||
// runtime.specifier at runtime-setter-api.js to avoid loading the full
|
||||
// runtime-api barrel during register(). The setter file must therefore
|
||||
// be discoverable as part of Slack's public surface.
|
||||
const slack = listRepoBundledPluginMetadata().find((entry) => entry.dirName === "slack");
|
||||
expectArtifactPresence(slack?.publicSurfaceArtifacts, {
|
||||
contains: ["runtime-setter-api.js"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Telegram's narrow runtime setter on the bundled runtime sidecar surface", () => {
|
||||
const telegram = listRepoBundledPluginMetadata().find((entry) => entry.dirName === "telegram");
|
||||
expectArtifactPresence(telegram?.publicSurfaceArtifacts, {
|
||||
|
||||
@@ -239,6 +239,23 @@ describe("runtime api guardrails", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Slack's narrow runtime-setter entrypoint pinned to a single export", () => {
|
||||
// Regression for #69317. The bundled channel entry's runtime.specifier
|
||||
// now points at runtime-setter-api.ts. The whole point of that file is
|
||||
// to expose ONLY setSlackRuntime so that register() does not pay the
|
||||
// cost of importing the full runtime-api barrel. If a future change
|
||||
// re-broadens this file, this test fails so the perf regression is
|
||||
// surfaced explicitly rather than silently re-introduced.
|
||||
const setterFile = bundledPluginFile({
|
||||
rootDir: ROOT_DIR,
|
||||
pluginId: "slack",
|
||||
relativePath: "runtime-setter-api.ts",
|
||||
});
|
||||
expect(readExportStatements(setterFile)).toEqual([
|
||||
'export { setSlackRuntime } from "./src/runtime.js";',
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps Matrix's narrow runtime-setter entrypoint pinned to a single export", () => {
|
||||
const setterFile = bundledPluginFile({
|
||||
rootDir: ROOT_DIR,
|
||||
|
||||
Reference in New Issue
Block a user