From 1f14c8d96b8662e155d5c10ea650da868e1a9e63 Mon Sep 17 00:00:00 2001 From: "@zimeg" Date: Tue, 14 Apr 2026 11:02:08 -0700 Subject: [PATCH] fix(slack): fix slash commands with button arg menu errors Co-authored-by: Wang Siyuan --- CHANGELOG.md | 1 + extensions/slack/src/monitor/slash.test.ts | 35 +++++++++++++++------- extensions/slack/src/monitor/slash.ts | 27 +++++++++-------- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f25bcc358c..649bfbe7ae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Models/probe: surface invalid-model probe failures as `format` instead of `unknown` in `models list --probe`, and lock the invalid-model fallback path in with regression coverage. (#50028) Thanks @xiwuqi. - Agents/failover: classify OpenAI-compatible `finish_reason: network_error` stream failures as timeout so model fallback retries continue instead of stopping with an unknown failover reason. (#61784) thanks @lawrence3699. - Onboarding/channels: normalize channel setup metadata before discovery and validation so malformed or mixed-shape channel plugin metadata no longer breaks setup and onboarding channel lists. (#66706) Thanks @darkamenosa. +- Slack/native commands: fix option menus for slash commands such as `/verbose` when Slack renders native buttons by giving each button a unique action ID while still routing them through the shared `openclaw_cmdarg*` listener. Thanks @Wangmerlyn. ## 2026.4.14 diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index 0b1a9c3e770..ce70282f791 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -220,7 +220,7 @@ function createDeferred() { function createArgMenusHarness() { const commands = new Map Promise>(); - const actions = new Map Promise>(); + const actions = new Map Promise>(); const options = new Map Promise>(); const optionsReceiverContexts: unknown[] = []; @@ -230,7 +230,7 @@ function createArgMenusHarness() { command: (name: string, handler: (args: unknown) => Promise) => { commands.set(name, handler); }, - action: (id: string, handler: (args: unknown) => Promise) => { + action: (id: string | RegExp, handler: (args: unknown) => Promise) => { actions.set(id, handler); }, options: function (this: unknown, id: string, handler: (args: unknown) => Promise) { @@ -285,11 +285,16 @@ function createArgMenusHarness() { } function requireHandler( - handlers: Map Promise>, - key: string, + handlers: Map Promise>, + key: string | RegExp, label: string, ): (args: unknown) => Promise { - const handler = handlers.get(key); + const handler = + key instanceof RegExp + ? Array.from(handlers.entries()).find( + ([candidate]) => candidate instanceof RegExp && String(candidate) === String(key), + )?.[1] + : handlers.get(key); if (!handler) { throw new Error(`Missing ${label} handler`); } @@ -414,7 +419,7 @@ describe("Slack native command argument menus", () => { reportLongHandler = requireHandler(harness.commands, "/reportlong", "/reportlong"); unsafeConfirmHandler = requireHandler(harness.commands, "/unsafeconfirm", "/unsafeconfirm"); agentStatusHandler = requireHandler(harness.commands, "/agentstatus", "/agentstatus"); - argMenuHandler = requireHandler(harness.actions, "openclaw_cmdarg", "arg-menu action"); + argMenuHandler = requireHandler(harness.actions, /^openclaw_cmdarg/, "arg-menu action"); argMenuOptionsHandler = requireHandler(harness.options, "openclaw_cmdarg", "arg-menu options"); }); @@ -426,21 +431,25 @@ describe("Slack native command argument menus", () => { const testHarness = createArgMenusHarness(); await registerCommands(testHarness.ctx, testHarness.account); expect(testHarness.commands.size).toBeGreaterThan(0); - expect(testHarness.actions.has("openclaw_cmdarg")).toBe(true); + expect( + Array.from(testHarness.actions.keys()).some( + (key) => key instanceof RegExp && String(key) === String(/^openclaw_cmdarg/), + ), + ).toBe(true); expect(testHarness.options.has("openclaw_cmdarg")).toBe(true); 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 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) => { + action: (id: string | RegExp, handler: (args: unknown) => Promise) => { actions.set(id, handler); }, // Simulate Bolt throwing during options registration (e.g. receiver not initialized) @@ -483,7 +492,11 @@ describe("Slack native command argument menus", () => { // Registration should not throw despite app.options() throwing await registerCommands(ctx, account); expect(commands.size).toBeGreaterThan(0); - expect(actions.has("openclaw_cmdarg")).toBe(true); + expect( + Array.from(actions.keys()).some( + (key) => key instanceof RegExp && String(key) === String(/^openclaw_cmdarg/), + ), + ).toBe(true); // The /reportexternal command (140 choices) should fall back to static_select // instead of external_select since options registration failed @@ -508,6 +521,8 @@ describe("Slack native command argument menus", () => { const actions = expectArgMenuLayout(respond); const elementType = actions?.elements?.[0]?.type; expect(elementType).toBe("button"); + expect(actions?.elements?.[0]?.action_id).toBe("openclaw_cmdarg_0_0"); + expect(actions?.elements?.[1]?.action_id).toBe("openclaw_cmdarg_0_1"); expect(actions?.elements?.[0]?.confirm).toBeTruthy(); }); diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index aef23711058..5feb6bc35e7 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -34,6 +34,7 @@ import { resolveSlackRoomContextHints } from "./room-context.js"; type SlackBlock = { type: string; [key: string]: unknown }; const SLACK_COMMAND_ARG_ACTION_ID = "openclaw_cmdarg"; +const SLACK_COMMAND_ARG_ACTION_LISTENER = /^openclaw_cmdarg/; const SLACK_COMMAND_ARG_VALUE_PREFIX = "cmdarg"; const SLACK_COMMAND_ARG_BUTTON_ROW_SIZE = 5; const SLACK_COMMAND_ARG_OVERFLOW_MIN = 3; @@ -221,16 +222,18 @@ function buildSlackCommandArgMenuBlocks(params: { }, ] : encodedChoices.length <= SLACK_COMMAND_ARG_BUTTON_ROW_SIZE || !canUseStaticSelect - ? chunkItems(encodedChoices, SLACK_COMMAND_ARG_BUTTON_ROW_SIZE).map((choices) => ({ - type: "actions", - elements: choices.map((choice) => ({ - type: "button", - action_id: SLACK_COMMAND_ARG_ACTION_ID, - text: { type: "plain_text", text: choice.label }, - value: choice.value, - confirm: buildSlackArgMenuConfirm({ command: params.command, arg: params.arg }), - })), - })) + ? chunkItems(encodedChoices, SLACK_COMMAND_ARG_BUTTON_ROW_SIZE).map( + (choices, rowIndex) => ({ + type: "actions", + elements: choices.map((choice, colIndex) => ({ + type: "button", + action_id: `${SLACK_COMMAND_ARG_ACTION_ID}_${rowIndex}_${colIndex}`, + text: { type: "plain_text", text: choice.label }, + value: choice.value, + confirm: buildSlackArgMenuConfirm({ command: params.command, arg: params.arg }), + })), + }), + ) : chunkItems(encodedChoices, SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX).map( (choices, index) => ({ type: "actions", @@ -804,7 +807,7 @@ export async function registerSlackMonitorSlashCommands(params: { ); } - const registerArgAction = (actionId: string) => { + const registerArgAction = (actionId: string | RegExp) => { ( ctx.app as unknown as { action: NonNullable<(typeof ctx.app & { action?: unknown })["action"]>; @@ -882,5 +885,5 @@ export async function registerSlackMonitorSlashCommands(params: { }); }); }; - registerArgAction(SLACK_COMMAND_ARG_ACTION_ID); + registerArgAction(SLACK_COMMAND_ARG_ACTION_LISTENER); }