From 3aad6c8bdb32a659e5220ce118573676272bd614 Mon Sep 17 00:00:00 2001
From: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
Date: Sun, 1 Mar 2026 11:29:18 -0400
Subject: [PATCH] fix(slack): guard Socket Mode listeners access during startup
(openclaw#28702) thanks @Glucksberg
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini
Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
---
CHANGELOG.md | 1 +
src/slack/monitor/slash.test.ts | 72 +++++++++++++++++++++++++++++++++
src/slack/monitor/slash.ts | 14 ++++++-
3 files changed, 85 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0119ff0c30d..7d6961e7b12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -93,6 +93,7 @@ Docs: https://docs.openclaw.ai
- File tools/tilde paths: expand `~/...` against the user home directory before workspace-root checks in host file read/write/edit paths, while preserving root-boundary enforcement so outside-root targets remain blocked. (#29779) Thanks @Glucksberg.
- Slack/HTTP mode startup: treat Slack HTTP accounts as configured when `botToken` + `signingSecret` are present (without requiring `appToken`) in channel config/runtime status so webhook mode is not silently skipped. (#30567)
- Slack/Usage footer formatting: wrap session keys in inline code in full response-usage footers so Slack does not parse colon-delimited session segments as emoji shortcodes. (#30258) Thanks @pushkarsingh32.
+- Slack/Socket Mode slash startup: treat `app.options()` registration as best-effort and fall back to static arg menus when listener registration fails, preventing Slack monitor startup crash loops on receiver init edge cases. (#21715)
- Onboarding/Custom providers: raise default custom-provider model context window to the runtime hard minimum (16k) and auto-heal existing custom model entries below that threshold during reconfiguration, preventing immediate `Model context window too small (4096 tokens)` failures. (#21653) Thanks @r4jiv007.
- Web UI/Assistant text: strip internal `...` scaffolding from rendered assistant messages (while preserving code-fence literals), preventing memory-context leakage in chat output for models that echo internal blocks. (#29851) Thanks @Valkster70.
- Dashboard/Sessions: allow authenticated Control UI clients to delete and patch sessions while still blocking regular webchat clients from session mutation RPCs, fixing Dashboard session delete failures. (#21264) Thanks @jskoiz.
diff --git a/src/slack/monitor/slash.test.ts b/src/slack/monitor/slash.test.ts
index 24c533018a9..f6bd4567370 100644
--- a/src/slack/monitor/slash.test.ts
+++ b/src/slack/monitor/slash.test.ts
@@ -435,6 +435,78 @@ describe("Slack native command argument menus", () => {
expect(testHarness.optionsReceiverContexts[0]).toBe(testHarness.app);
});
+ it("falls back to static menus when app.options() throws during registration", async () => {
+ const commands = new Map Promise>();
+ const actions = new Map Promise>();
+ const postEphemeral = vi.fn().mockResolvedValue({ ok: true });
+ const app = {
+ client: { chat: { postEphemeral } },
+ command: (name: string, handler: (args: unknown) => Promise) => {
+ commands.set(name, handler);
+ },
+ action: (id: string, handler: (args: unknown) => Promise) => {
+ actions.set(id, handler);
+ },
+ // Simulate Bolt throwing during options registration (e.g. receiver not initialized)
+ options: () => {
+ throw new Error("Cannot read properties of undefined (reading 'listeners')");
+ },
+ };
+ const ctx = {
+ cfg: { commands: { native: true, nativeSkills: false } },
+ runtime: {},
+ botToken: "bot-token",
+ botUserId: "bot",
+ teamId: "T1",
+ allowFrom: ["*"],
+ dmEnabled: true,
+ dmPolicy: "open",
+ groupDmEnabled: false,
+ groupDmChannels: [],
+ defaultRequireMention: true,
+ groupPolicy: "open",
+ useAccessGroups: false,
+ channelsConfig: undefined,
+ slashCommand: {
+ enabled: true,
+ name: "openclaw",
+ ephemeral: true,
+ sessionPrefix: "slack:slash",
+ },
+ textLimit: 4000,
+ app,
+ isChannelAllowed: () => true,
+ resolveChannelName: async () => ({ name: "dm", type: "im" }),
+ resolveUserName: async () => ({ name: "Ada" }),
+ } as unknown;
+ const account = {
+ accountId: "acct",
+ config: { commands: { native: true, nativeSkills: false } },
+ } as unknown;
+
+ // Registration should not throw despite app.options() throwing
+ await registerCommands(ctx, account);
+ expect(commands.size).toBeGreaterThan(0);
+ expect(actions.has("openclaw_cmdarg")).toBe(true);
+
+ // The /reportexternal command (140 choices) should fall back to static_select
+ // instead of external_select since options registration failed
+ const handler = commands.get("/reportexternal");
+ expect(handler).toBeDefined();
+ const respond = vi.fn().mockResolvedValue(undefined);
+ const ack = vi.fn().mockResolvedValue(undefined);
+ await handler!({
+ command: createSlashCommand(),
+ ack,
+ respond,
+ });
+ expect(respond).toHaveBeenCalledTimes(1);
+ const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> };
+ const actionsBlock = findFirstActionsBlock(payload);
+ // Should be static_select (fallback) not external_select
+ expect(actionsBlock?.elements?.[0]?.type).toBe("static_select");
+ });
+
it("shows a button menu when required args are omitted", async () => {
const { respond } = await runCommandHandler(usageHandler);
const actions = expectArgMenuLayout(respond);
diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts
index c494a3696e5..292e608d403 100644
--- a/src/slack/monitor/slash.ts
+++ b/src/slack/monitor/slash.ts
@@ -274,7 +274,7 @@ export async function registerSlackMonitorSlashCommands(params: {
const supportsInteractiveArgMenus =
typeof (ctx.app as { action?: unknown }).action === "function";
- const supportsExternalArgMenus = typeof (ctx.app as { options?: unknown }).options === "function";
+ let supportsExternalArgMenus = typeof (ctx.app as { options?: unknown }).options === "function";
const slashCommand = resolveSlackSlashCommandConfig(
ctx.slashCommand ?? account.config.slashCommand,
@@ -758,7 +758,17 @@ export async function registerSlackMonitorSlashCommands(params: {
await ack({ options });
});
};
- registerArgOptions();
+ // Treat external arg-menu registration as best-effort: if Bolt's app.options()
+ // throws (e.g. from receiver init issues), disable external selects and fall back
+ // to static_select/button menus instead of crashing the entire provider startup.
+ try {
+ registerArgOptions();
+ } catch (err) {
+ supportsExternalArgMenus = false;
+ logVerbose(
+ `slack: external arg-menu registration failed, falling back to static menus: ${String(err)}`,
+ );
+ }
const registerArgAction = (actionId: string) => {
(