fix(slack): fix slash commands with button arg menu errors

Co-authored-by: Wang Siyuan <wsy0227@sjtu.edu.cn>
This commit is contained in:
@zimeg
2026-04-14 11:02:08 -07:00
parent bd288e7683
commit 1f14c8d96b
3 changed files with 41 additions and 22 deletions

View File

@@ -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

View File

@@ -220,7 +220,7 @@ function createDeferred<T>() {
function createArgMenusHarness() {
const commands = new Map<string, (args: unknown) => Promise<void>>();
const actions = new Map<string, (args: unknown) => Promise<void>>();
const actions = new Map<string | RegExp, (args: unknown) => Promise<void>>();
const options = new Map<string, (args: unknown) => Promise<void>>();
const optionsReceiverContexts: unknown[] = [];
@@ -230,7 +230,7 @@ function createArgMenusHarness() {
command: (name: string, handler: (args: unknown) => Promise<void>) => {
commands.set(name, handler);
},
action: (id: string, handler: (args: unknown) => Promise<void>) => {
action: (id: string | RegExp, handler: (args: unknown) => Promise<void>) => {
actions.set(id, handler);
},
options: function (this: unknown, id: string, handler: (args: unknown) => Promise<void>) {
@@ -285,11 +285,16 @@ function createArgMenusHarness() {
}
function requireHandler(
handlers: Map<string, (args: unknown) => Promise<void>>,
key: string,
handlers: Map<string | RegExp, (args: unknown) => Promise<void>>,
key: string | RegExp,
label: string,
): (args: unknown) => Promise<void> {
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<string, (args: unknown) => Promise<void>>();
const actions = new Map<string, (args: unknown) => Promise<void>>();
const actions = new Map<string | RegExp, (args: unknown) => Promise<void>>();
const postEphemeral = vi.fn().mockResolvedValue({ ok: true });
const app = {
client: { chat: { postEphemeral } },
command: (name: string, handler: (args: unknown) => Promise<void>) => {
commands.set(name, handler);
},
action: (id: string, handler: (args: unknown) => Promise<void>) => {
action: (id: string | RegExp, handler: (args: unknown) => Promise<void>) => {
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();
});

View File

@@ -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);
}