diff --git a/src/slack/monitor/slash.test.ts b/src/slack/monitor/slash.test.ts index ee5580b75..b172b1377 100644 --- a/src/slack/monitor/slash.test.ts +++ b/src/slack/monitor/slash.test.ts @@ -3,6 +3,7 @@ import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.j vi.mock("../../auto-reply/commands-registry.js", () => { const usageCommand = { key: "usage", nativeName: "usage" }; + const reportCommand = { key: "report", nativeName: "report" }; return { buildCommandTextFromArgs: ( @@ -10,11 +11,26 @@ vi.mock("../../auto-reply/commands-registry.js", () => { args?: { values?: Record }, ) => { const name = cmd.nativeName ?? cmd.key; - const mode = args?.values?.mode; - return typeof mode === "string" && mode.trim() ? `/${name} ${mode.trim()}` : `/${name}`; + const values = args?.values ?? {}; + const mode = values.mode; + const period = values.period; + const selected = + typeof mode === "string" && mode.trim() + ? mode.trim() + : typeof period === "string" && period.trim() + ? period.trim() + : ""; + return selected ? `/${name} ${selected}` : `/${name}`; }, findCommandByNativeName: (name: string) => { - return name.trim().toLowerCase() === "usage" ? usageCommand : undefined; + const normalized = name.trim().toLowerCase(); + if (normalized === "usage") { + return usageCommand; + } + if (normalized === "report") { + return reportCommand; + } + return undefined; }, listNativeCommandSpecsForConfig: () => [ { @@ -23,12 +39,38 @@ vi.mock("../../auto-reply/commands-registry.js", () => { acceptsArgs: true, args: [], }, + { + name: "report", + description: "Report", + acceptsArgs: true, + args: [], + }, ], parseCommandArgs: () => ({ values: {} }), resolveCommandArgMenu: (params: { command?: { key?: string }; args?: { values?: unknown }; }) => { + if (params.command?.key !== "usage") { + if (params.command?.key !== "report") { + return null; + } + const values = (params.args?.values ?? {}) as Record; + if (typeof values.period === "string" && values.period.trim()) { + return null; + } + return { + arg: { name: "period", description: "period" }, + choices: [ + { value: "day", label: "day" }, + { value: "week", label: "week" }, + { value: "month", label: "month" }, + { value: "quarter", label: "quarter" }, + { value: "year", label: "year" }, + { value: "all", label: "all" }, + ], + }; + } if (params.command?.key !== "usage") { return null; } @@ -130,6 +172,7 @@ function createArgMenusHarness() { describe("Slack native command argument menus", () => { let harness: ReturnType; let usageHandler: (args: unknown) => Promise; + let reportHandler: (args: unknown) => Promise; let argMenuHandler: (args: unknown) => Promise; beforeAll(async () => { @@ -141,6 +184,11 @@ describe("Slack native command argument menus", () => { throw new Error("Missing /usage handler"); } usageHandler = usage; + const report = harness.commands.get("/report"); + if (!report) { + throw new Error("Missing /report handler"); + } + reportHandler = report; const argMenu = harness.actions.get("openclaw_cmdarg"); if (!argMenu) { @@ -174,6 +222,37 @@ describe("Slack native command argument menus", () => { const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> }; expect(payload.blocks?.[0]?.type).toBe("section"); expect(payload.blocks?.[1]?.type).toBe("actions"); + const elementType = (payload.blocks?.[1] as { elements?: Array<{ type?: string }> } | undefined) + ?.elements?.[0]?.type; + expect(elementType).toBe("button"); + }); + + it("shows a static_select menu when choices exceed button row size", async () => { + const respond = vi.fn().mockResolvedValue(undefined); + const ack = vi.fn().mockResolvedValue(undefined); + + await reportHandler({ + command: { + user_id: "U1", + user_name: "Ada", + channel_id: "C1", + channel_name: "directmessage", + text: "", + trigger_id: "t1", + }, + ack, + respond, + }); + + expect(respond).toHaveBeenCalledTimes(1); + const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> }; + expect(payload.blocks?.[0]?.type).toBe("section"); + expect(payload.blocks?.[1]?.type).toBe("actions"); + const element = ( + payload.blocks?.[1] as { elements?: Array<{ type?: string; action_id?: string }> } | undefined + )?.elements?.[0]; + expect(element?.type).toBe("static_select"); + expect(element?.action_id).toBe("openclaw_cmdarg"); }); it("dispatches the command when a menu button is clicked", async () => { @@ -196,6 +275,28 @@ describe("Slack native command argument menus", () => { expect(call.ctx?.Body).toBe("/usage tokens"); }); + it("dispatches the command when a static_select option is chosen", async () => { + const respond = vi.fn().mockResolvedValue(undefined); + await argMenuHandler({ + ack: vi.fn().mockResolvedValue(undefined), + action: { + selected_option: { + value: encodeValue({ command: "report", arg: "period", value: "month", userId: "U1" }), + }, + }, + body: { + user: { id: "U1", name: "Ada" }, + channel: { id: "C1", name: "directmessage" }, + trigger_id: "t1", + }, + respond, + }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } }; + expect(call.ctx?.Body).toBe("/report month"); + }); + it("rejects menu clicks from other users", async () => { const respond = vi.fn().mockResolvedValue(undefined); await argMenuHandler({ diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index 4e14986d4..9fa6b7f09 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -29,6 +29,8 @@ type SlackBlock = { type: string; [key: string]: unknown }; const SLACK_COMMAND_ARG_ACTION_ID = "openclaw_cmdarg"; const SLACK_COMMAND_ARG_VALUE_PREFIX = "cmdarg"; +const SLACK_COMMAND_ARG_BUTTON_ROW_SIZE = 5; +const SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX = 100; type CommandsRegistry = typeof import("../../auto-reply/commands-registry.js"); let commandsRegistry: CommandsRegistry | undefined; @@ -100,20 +102,43 @@ function buildSlackCommandArgMenuBlocks(params: { choices: Array<{ value: string; label: string }>; userId: string; }) { - const rows = chunkItems(params.choices, 5).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: encodeSlackCommandArgValue({ - command: params.command, - arg: params.arg, - value: choice.value, - userId: params.userId, - }), - })), + const encodedChoices = params.choices.map((choice) => ({ + label: choice.label, + value: encodeSlackCommandArgValue({ + command: params.command, + arg: params.arg, + value: choice.value, + userId: params.userId, + }), })); + const rows = + encodedChoices.length <= SLACK_COMMAND_ARG_BUTTON_ROW_SIZE + ? 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, + })), + })) + : chunkItems(encodedChoices, SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX).map((choices, index) => ({ + type: "actions", + elements: [ + { + type: "static_select", + action_id: SLACK_COMMAND_ARG_ACTION_ID, + placeholder: { + type: "plain_text", + text: index === 0 ? `Choose ${params.arg}` : `Choose ${params.arg} (${index + 1})`, + }, + options: choices.map((choice) => ({ + text: { type: "plain_text", text: choice.label.slice(0, 75) }, + value: choice.value, + })), + }, + ], + })); return [ { type: "section", @@ -568,7 +593,7 @@ export async function registerSlackMonitorSlashCommands(params: { } ).action(actionId, async (args: SlackActionMiddlewareArgs) => { const { ack, body, respond } = args; - const action = args.action as { value?: string }; + const action = args.action as { value?: string; selected_option?: { value?: string } }; await ack(); const respondFn = respond ?? @@ -584,7 +609,8 @@ export async function registerSlackMonitorSlashCommands(params: { blocks: payload.blocks, }); }); - const parsed = parseSlackCommandArgValue(action?.value); + const actionValue = action?.value ?? action?.selected_option?.value; + const parsed = parseSlackCommandArgValue(actionValue); if (!parsed) { await respondFn({ text: "Sorry, that button is no longer valid.",