From 80bef826f863668c9465f49d757295f0d7302536 Mon Sep 17 00:00:00 2001 From: Yauheni Shauchenka Date: Mon, 16 Mar 2026 15:49:24 +0300 Subject: [PATCH] fix(slack): harden bolt import interop (#45953) * fix(slack): harden bolt import interop * fix(slack): simplify bolt interop resolver * fix(slack): harden startup bolt interop * fix(slack): place changelog entry at section end --------- Co-authored-by: Ubuntu Co-authored-by: Altay --- CHANGELOG.md | 1 + .../src/monitor/provider.interop.test.ts | 94 +++++++++++++++++++ extensions/slack/src/monitor/provider.ts | 80 ++++++++++++++-- 3 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 extensions/slack/src/monitor/provider.interop.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a2873ccd64..2c2f0cc487a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ Docs: https://docs.openclaw.ai - Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc. - Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411) - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. +- Slack/startup: harden `@slack/bolt` import interop across current bundled runtime shapes so Slack monitors no longer crash with `App is not a constructor` after plugin-sdk bundling changes. (#45953) thanks @merc1305. ## 2026.3.13 diff --git a/extensions/slack/src/monitor/provider.interop.test.ts b/extensions/slack/src/monitor/provider.interop.test.ts new file mode 100644 index 00000000000..3e761cb45f1 --- /dev/null +++ b/extensions/slack/src/monitor/provider.interop.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./provider.js"; + +describe("resolveSlackBoltInterop", () => { + class FakeApp {} + class FakeHTTPReceiver {} + + it("uses the default import when it already exposes named exports", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: { + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }, + namespaceImport: {}, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("uses nested default export when the default import is a wrapper object", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: { + default: { + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }, + }, + namespaceImport: {}, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("uses the namespace receiver when the default import is the App constructor itself", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: FakeApp, + namespaceImport: { + HTTPReceiver: FakeHTTPReceiver, + }, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("uses namespace.default when it exposes named exports", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: undefined, + namespaceImport: { + default: { + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }, + }, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("falls back to the namespace import when it exposes named exports", () => { + const resolved = __testing.resolveSlackBoltInterop({ + defaultImport: undefined, + namespaceImport: { + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }, + }); + + expect(resolved).toEqual({ + App: FakeApp, + HTTPReceiver: FakeHTTPReceiver, + }); + }); + + it("throws when the module cannot be resolved", () => { + expect(() => + __testing.resolveSlackBoltInterop({ + defaultImport: null, + namespaceImport: {}, + }), + ).toThrow("Unable to resolve @slack/bolt App/HTTPReceiver exports"); + }); +}); diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 149d33bbf15..2104a5355cf 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import SlackBolt from "@slack/bolt"; +import SlackBolt, * as SlackBoltNamespace from "@slack/bolt"; import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../../../src/auto-reply/reply/history.js"; import { @@ -46,14 +46,77 @@ import { import { registerSlackMonitorSlashCommands } from "./slash.js"; import type { MonitorSlackOpts } from "./types.js"; -const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & { - default?: typeof import("@slack/bolt"); +type SlackAppConstructor = typeof import("@slack/bolt").App; +type SlackHttpReceiverConstructor = typeof import("@slack/bolt").HTTPReceiver; +type SlackBoltResolvedExports = { + App: SlackAppConstructor; + HTTPReceiver: SlackHttpReceiverConstructor; }; -// Bun allows named imports from CJS; Node ESM doesn't. Use default+fallback for compatibility. -// Fix: Check if module has App property directly (Node 25.x ESM/CJS compat issue) -const slackBolt = - (slackBoltModule.App ? slackBoltModule : slackBoltModule.default) ?? slackBoltModule; -const { App, HTTPReceiver } = slackBolt; +type Constructor = abstract new (...args: never[]) => unknown; + +function isConstructorFunction(value: unknown): value is T { + return typeof value === "function"; +} + +function resolveSlackBoltModule(value: unknown): SlackBoltResolvedExports | null { + if (!value || typeof value !== "object") { + return null; + } + const app = Reflect.get(value, "App"); + const httpReceiver = Reflect.get(value, "HTTPReceiver"); + if ( + !isConstructorFunction(app) || + !isConstructorFunction(httpReceiver) + ) { + return null; + } + return { + App: app, + HTTPReceiver: httpReceiver, + }; +} + +function resolveSlackBoltInterop(params: { + defaultImport: unknown; + namespaceImport: unknown; +}): SlackBoltResolvedExports { + const { defaultImport, namespaceImport } = params; + const nestedDefault = + defaultImport && typeof defaultImport === "object" + ? Reflect.get(defaultImport, "default") + : undefined; + const namespaceDefault = + namespaceImport && typeof namespaceImport === "object" + ? Reflect.get(namespaceImport, "default") + : undefined; + const namespaceReceiver = + namespaceImport && typeof namespaceImport === "object" + ? Reflect.get(namespaceImport, "HTTPReceiver") + : undefined; + const directModule = + resolveSlackBoltModule(defaultImport) ?? + resolveSlackBoltModule(nestedDefault) ?? + resolveSlackBoltModule(namespaceDefault) ?? + resolveSlackBoltModule(namespaceImport); + if (directModule) { + return directModule; + } + if ( + isConstructorFunction(defaultImport) && + isConstructorFunction(namespaceReceiver) + ) { + return { + App: defaultImport, + HTTPReceiver: namespaceReceiver, + }; + } + throw new TypeError("Unable to resolve @slack/bolt App/HTTPReceiver exports"); +} + +const { App, HTTPReceiver } = resolveSlackBoltInterop({ + defaultImport: SlackBolt, + namespaceImport: SlackBoltNamespace, +}); const SLACK_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; const SLACK_WEBHOOK_BODY_TIMEOUT_MS = 30_000; @@ -515,6 +578,7 @@ export const __testing = { publishSlackDisconnectedStatus, resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, + resolveSlackBoltInterop, getSocketEmitter, waitForSlackSocketDisconnect, };