mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 20:21:23 +00:00
Slack: add external select flow for large arg menus
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user