Slack: add external select flow for large arg menus

This commit is contained in:
Colin
2026-02-16 14:21:58 -05:00
committed by Peter Steinberger
parent 7a4efbb030
commit 1faf8e8e9d
2 changed files with 258 additions and 29 deletions

View File

@@ -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<string, (args: unknown) => Promise<void>>();
const actions = new Map<string, (args: unknown) => Promise<void>>();
const options = new Map<string, (args: unknown) => Promise<void>>();
const postEphemeral = vi.fn().mockResolvedValue({ ok: true });
const app = {
@@ -205,6 +225,9 @@ function createArgMenusHarness() {
action: (id: string, handler: (args: unknown) => Promise<void>) => {
actions.set(id, handler);
},
options: (id: string, handler: (args: unknown) => Promise<void>) => {
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<void>;
let reportHandler: (args: unknown) => Promise<void>;
let reportCompactHandler: (args: unknown) => Promise<void>;
let reportExternalHandler: (args: unknown) => Promise<void>;
let reportLongHandler: (args: unknown) => Promise<void>;
let unsafeConfirmHandler: (args: unknown) => Promise<void>;
let argMenuHandler: (args: unknown) => Promise<void>;
let argMenuOptionsHandler: (args: unknown) => Promise<void>;
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({

View File

@@ -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<CommandsRegistry> {
@@ -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<void>;
body: unknown;
}) => Promise<void>,
) => 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 {