From 1faf8e8e9d5c50ac8a81b975185c88b53dc9b47d Mon Sep 17 00:00:00 2001 From: Colin Date: Mon, 16 Feb 2026 14:21:58 -0500 Subject: [PATCH] Slack: add external select flow for large arg menus --- src/slack/monitor/slash.test.ts | 108 ++++++++++++++++++- src/slack/monitor/slash.ts | 179 +++++++++++++++++++++++++++----- 2 files changed, 258 insertions(+), 29 deletions(-) diff --git a/src/slack/monitor/slash.test.ts b/src/slack/monitor/slash.test.ts index b2c77507ab6..60271450d79 100644 --- a/src/slack/monitor/slash.test.ts +++ b/src/slack/monitor/slash.test.ts @@ -5,6 +5,7 @@ vi.mock("../../auto-reply/commands-registry.js", () => { const usageCommand = { key: "usage", nativeName: "usage" }; const reportCommand = { key: "report", nativeName: "report" }; const reportCompactCommand = { key: "reportcompact", nativeName: "reportcompact" }; + const reportExternalCommand = { key: "reportexternal", nativeName: "reportexternal" }; const reportLongCommand = { key: "reportlong", nativeName: "reportlong" }; const unsafeConfirmCommand = { key: "unsafeconfirm", nativeName: "unsafeconfirm" }; @@ -36,6 +37,9 @@ vi.mock("../../auto-reply/commands-registry.js", () => { if (normalized === "reportcompact") { return reportCompactCommand; } + if (normalized === "reportexternal") { + return reportExternalCommand; + } if (normalized === "reportlong") { return reportLongCommand; } @@ -63,6 +67,12 @@ vi.mock("../../auto-reply/commands-registry.js", () => { acceptsArgs: true, args: [], }, + { + name: "reportexternal", + description: "ReportExternal", + acceptsArgs: true, + args: [], + }, { name: "reportlong", description: "ReportLong", @@ -130,6 +140,15 @@ vi.mock("../../auto-reply/commands-registry.js", () => { ], }; } + if (params.command?.key === "reportexternal") { + return { + arg: { name: "period", description: "period" }, + choices: Array.from({ length: 140 }, (_v, i) => ({ + value: `period-${i + 1}`, + label: `Period ${i + 1}`, + })), + }; + } if (params.command?.key === "unsafeconfirm") { return { arg: { name: "mode_*`~<&>", description: "mode" }, @@ -195,6 +214,7 @@ function findFirstActionsBlock(payload: { blocks?: Array<{ type: string }> }) { function createArgMenusHarness() { const commands = new Map Promise>(); const actions = new Map Promise>(); + const options = new Map Promise>(); const postEphemeral = vi.fn().mockResolvedValue({ ok: true }); const app = { @@ -205,6 +225,9 @@ function createArgMenusHarness() { action: (id: string, handler: (args: unknown) => Promise) => { actions.set(id, handler); }, + options: (id: string, handler: (args: unknown) => Promise) => { + options.set(id, handler); + }, }; const ctx = { @@ -240,7 +263,7 @@ function createArgMenusHarness() { config: { commands: { native: true, nativeSkills: false } }, } as unknown; - return { commands, actions, postEphemeral, ctx, account }; + return { commands, actions, options, postEphemeral, ctx, account }; } describe("Slack native command argument menus", () => { @@ -248,9 +271,11 @@ describe("Slack native command argument menus", () => { let usageHandler: (args: unknown) => Promise; let reportHandler: (args: unknown) => Promise; let reportCompactHandler: (args: unknown) => Promise; + let reportExternalHandler: (args: unknown) => Promise; let reportLongHandler: (args: unknown) => Promise; let unsafeConfirmHandler: (args: unknown) => Promise; let argMenuHandler: (args: unknown) => Promise; + let argMenuOptionsHandler: (args: unknown) => Promise; beforeAll(async () => { harness = createArgMenusHarness(); @@ -271,6 +296,11 @@ describe("Slack native command argument menus", () => { throw new Error("Missing /reportcompact handler"); } reportCompactHandler = reportCompact; + const reportExternal = harness.commands.get("/reportexternal"); + if (!reportExternal) { + throw new Error("Missing /reportexternal handler"); + } + reportExternalHandler = reportExternal; const reportLong = harness.commands.get("/reportlong"); if (!reportLong) { throw new Error("Missing /reportlong handler"); @@ -287,6 +317,11 @@ describe("Slack native command argument menus", () => { throw new Error("Missing arg-menu action handler"); } argMenuHandler = argMenu; + const argMenuOptions = harness.options.get("openclaw_cmdarg"); + if (!argMenuOptions) { + throw new Error("Missing arg-menu options handler"); + } + argMenuOptionsHandler = argMenuOptions; }); beforeEach(() => { @@ -498,6 +533,77 @@ describe("Slack native command argument menus", () => { expect(call.ctx?.Body).toBe("/reportcompact quarter"); }); + it("shows an external_select menu when choices exceed static_select options max", async () => { + const respond = vi.fn().mockResolvedValue(undefined); + const ack = vi.fn().mockResolvedValue(undefined); + + await reportExternalHandler({ + 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; block_id?: string }>; + }; + const actions = findFirstActionsBlock(payload); + const element = actions?.elements?.[0]; + expect(element?.type).toBe("external_select"); + expect(element?.action_id).toBe("openclaw_cmdarg"); + expect(payload.blocks?.find((block) => block.type === "actions")?.block_id).toContain( + "openclaw_cmdarg_ext:", + ); + }); + + it("serves filtered options for external_select menus", async () => { + const respond = vi.fn().mockResolvedValue(undefined); + const ack = vi.fn().mockResolvedValue(undefined); + + await reportExternalHandler({ + command: { + user_id: "U1", + user_name: "Ada", + channel_id: "C1", + channel_name: "directmessage", + text: "", + trigger_id: "t1", + }, + ack, + respond, + }); + + const payload = respond.mock.calls[0]?.[0] as { + blocks?: Array<{ type: string; block_id?: string }>; + }; + const blockId = payload.blocks?.find((block) => block.type === "actions")?.block_id; + expect(blockId).toContain("openclaw_cmdarg_ext:"); + + const ackOptions = vi.fn().mockResolvedValue(undefined); + await argMenuOptionsHandler({ + ack: ackOptions, + body: { + user: { id: "U1" }, + value: "period 12", + actions: [{ block_id: blockId }], + }, + }); + + expect(ackOptions).toHaveBeenCalledTimes(1); + const optionsPayload = ackOptions.mock.calls[0]?.[0] as { + options?: Array<{ text?: { text?: string }; value?: string }>; + }; + const optionTexts = (optionsPayload.options ?? []).map((option) => option.text?.text ?? ""); + expect(optionTexts.some((text) => text.includes("Period 12"))).toBe(true); + }); + 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 cc59684e0b4..3edda895c5c 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -34,8 +34,16 @@ const SLACK_COMMAND_ARG_OVERFLOW_MIN = 3; const SLACK_COMMAND_ARG_OVERFLOW_MAX = 5; const SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX = 100; const SLACK_COMMAND_ARG_SELECT_OPTION_VALUE_MAX = 75; +const SLACK_COMMAND_ARG_EXTERNAL_PREFIX = "openclaw_cmdarg_ext:"; +const SLACK_COMMAND_ARG_EXTERNAL_TTL_MS = 10 * 60 * 1000; const SLACK_HEADER_TEXT_MAX = 150; +type EncodedMenuChoice = { label: string; value: string }; +const slackExternalArgMenuStore = new Map< + string, + { choices: EncodedMenuChoice[]; userId: string; expiresAt: number } +>(); + function truncatePlainText(value: string, max: number): string { const trimmed = value.trim(); if (trimmed.length <= max) { @@ -70,6 +78,36 @@ function buildSlackArgMenuConfirm(params: { command: string; arg: string }) { }; } +function pruneSlackExternalArgMenuStore(now = Date.now()) { + for (const [token, entry] of slackExternalArgMenuStore.entries()) { + if (entry.expiresAt <= now) { + slackExternalArgMenuStore.delete(token); + } + } +} + +function storeSlackExternalArgMenu(params: { + choices: EncodedMenuChoice[]; + userId: string; +}): string { + pruneSlackExternalArgMenuStore(); + const token = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; + slackExternalArgMenuStore.set(token, { + choices: params.choices, + userId: params.userId, + expiresAt: Date.now() + SLACK_COMMAND_ARG_EXTERNAL_TTL_MS, + }); + return token; +} + +function readSlackExternalArgMenuToken(raw: unknown): string | undefined { + if (typeof raw !== "string" || !raw.startsWith(SLACK_COMMAND_ARG_EXTERNAL_PREFIX)) { + return undefined; + } + const token = raw.slice(SLACK_COMMAND_ARG_EXTERNAL_PREFIX.length).trim(); + return token.length > 0 ? token : undefined; +} + type CommandsRegistry = typeof import("../../auto-reply/commands-registry.js"); let commandsRegistry: CommandsRegistry | undefined; async function getCommandsRegistry(): Promise { @@ -139,6 +177,8 @@ function buildSlackCommandArgMenuBlocks(params: { arg: string; choices: Array<{ value: string; label: string }>; userId: string; + supportsExternalSelect: boolean; + createExternalMenuToken: (choices: EncodedMenuChoice[]) => string; }) { const encodedChoices = params.choices.map((choice) => ({ label: choice.label, @@ -156,6 +196,10 @@ function buildSlackCommandArgMenuBlocks(params: { canUseStaticSelect && encodedChoices.length >= SLACK_COMMAND_ARG_OVERFLOW_MIN && encodedChoices.length <= SLACK_COMMAND_ARG_OVERFLOW_MAX; + const canUseExternalSelect = + params.supportsExternalSelect && + canUseStaticSelect && + encodedChoices.length > SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX; const rows = canUseOverflow ? [ { @@ -173,35 +217,59 @@ 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_SELECT_OPTIONS_MAX).map((choices, index) => ({ - type: "actions", - elements: [ - { - type: "static_select", - action_id: SLACK_COMMAND_ARG_ACTION_ID, - confirm: buildSlackArgMenuConfirm({ command: params.command, arg: params.arg }), - placeholder: { - type: "plain_text", - text: index === 0 ? `Choose ${params.arg}` : `Choose ${params.arg} (${index + 1})`, + : canUseExternalSelect + ? [ + { + type: "actions", + block_id: `${SLACK_COMMAND_ARG_EXTERNAL_PREFIX}${params.createExternalMenuToken( + encodedChoices, + )}`, + elements: [ + { + type: "external_select", + action_id: SLACK_COMMAND_ARG_ACTION_ID, + confirm: buildSlackArgMenuConfirm({ command: params.command, arg: params.arg }), + min_query_length: 0, + placeholder: { + type: "plain_text", + text: `Search ${params.arg}`, + }, }, - options: choices.map((choice) => ({ - text: { type: "plain_text", text: choice.label.slice(0, 75) }, - value: choice.value, - })), - }, - ], - })); + ], + }, + ] + : 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_SELECT_OPTIONS_MAX).map( + (choices, index) => ({ + type: "actions", + elements: [ + { + type: "static_select", + action_id: SLACK_COMMAND_ARG_ACTION_ID, + confirm: buildSlackArgMenuConfirm({ command: params.command, arg: params.arg }), + 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, + })), + }, + ], + }), + ); const headerText = truncatePlainText( `/${params.command}: choose ${params.arg}`, SLACK_HEADER_TEXT_MAX, @@ -238,6 +306,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"; const slashCommand = resolveSlackSlashCommandConfig( ctx.slashCommand ?? account.config.slashCommand, @@ -454,6 +523,9 @@ export async function registerSlackMonitorSlashCommands(params: { arg: menu.arg.name, choices: menu.choices, userId: command.user_id, + supportsExternalSelect: supportsExternalArgMenus, + createExternalMenuToken: (choices) => + storeSlackExternalArgMenu({ choices, userId: command.user_id }), }); await respond({ text: title, @@ -666,6 +738,57 @@ export async function registerSlackMonitorSlashCommands(params: { return; } + const registerArgOptions = () => { + const optionsHandler = ( + ctx.app as unknown as { + options?: ( + actionId: string, + handler: (args: { + ack: (payload: { options: unknown[] }) => Promise; + body: unknown; + }) => Promise, + ) => void; + } + ).options; + if (typeof optionsHandler !== "function") { + return; + } + optionsHandler(SLACK_COMMAND_ARG_ACTION_ID, async ({ ack, body }) => { + const typedBody = body as { + value?: string; + user?: { id?: string }; + actions?: Array<{ block_id?: string }>; + block_id?: string; + }; + pruneSlackExternalArgMenuStore(); + const blockId = typedBody.actions?.[0]?.block_id ?? typedBody.block_id; + const token = readSlackExternalArgMenuToken(blockId); + if (!token) { + await ack({ options: [] }); + return; + } + const entry = slackExternalArgMenuStore.get(token); + if (!entry) { + await ack({ options: [] }); + return; + } + if (typedBody.user?.id && typedBody.user.id !== entry.userId) { + await ack({ options: [] }); + return; + } + const query = typedBody.value?.trim().toLowerCase() ?? ""; + const options = entry.choices + .filter((choice) => !query || choice.label.toLowerCase().includes(query)) + .slice(0, SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX) + .map((choice) => ({ + text: { type: "plain_text", text: choice.label.slice(0, 75) }, + value: choice.value, + })); + await ack({ options }); + }); + }; + registerArgOptions(); + const registerArgAction = (actionId: string) => { ( ctx.app as unknown as {