mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 22:54:33 +00:00
Slack: use static_select for large slash arg menus
This commit is contained in:
@@ -3,6 +3,7 @@ import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.j
|
|||||||
|
|
||||||
vi.mock("../../auto-reply/commands-registry.js", () => {
|
vi.mock("../../auto-reply/commands-registry.js", () => {
|
||||||
const usageCommand = { key: "usage", nativeName: "usage" };
|
const usageCommand = { key: "usage", nativeName: "usage" };
|
||||||
|
const reportCommand = { key: "report", nativeName: "report" };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buildCommandTextFromArgs: (
|
buildCommandTextFromArgs: (
|
||||||
@@ -10,11 +11,26 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
|
|||||||
args?: { values?: Record<string, unknown> },
|
args?: { values?: Record<string, unknown> },
|
||||||
) => {
|
) => {
|
||||||
const name = cmd.nativeName ?? cmd.key;
|
const name = cmd.nativeName ?? cmd.key;
|
||||||
const mode = args?.values?.mode;
|
const values = args?.values ?? {};
|
||||||
return typeof mode === "string" && mode.trim() ? `/${name} ${mode.trim()}` : `/${name}`;
|
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) => {
|
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: () => [
|
listNativeCommandSpecsForConfig: () => [
|
||||||
{
|
{
|
||||||
@@ -23,12 +39,38 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
|
|||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
args: [],
|
args: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "report",
|
||||||
|
description: "Report",
|
||||||
|
acceptsArgs: true,
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
parseCommandArgs: () => ({ values: {} }),
|
parseCommandArgs: () => ({ values: {} }),
|
||||||
resolveCommandArgMenu: (params: {
|
resolveCommandArgMenu: (params: {
|
||||||
command?: { key?: string };
|
command?: { key?: string };
|
||||||
args?: { values?: unknown };
|
args?: { values?: unknown };
|
||||||
}) => {
|
}) => {
|
||||||
|
if (params.command?.key !== "usage") {
|
||||||
|
if (params.command?.key !== "report") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const values = (params.args?.values ?? {}) as Record<string, unknown>;
|
||||||
|
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") {
|
if (params.command?.key !== "usage") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -130,6 +172,7 @@ function createArgMenusHarness() {
|
|||||||
describe("Slack native command argument menus", () => {
|
describe("Slack native command argument menus", () => {
|
||||||
let harness: ReturnType<typeof createArgMenusHarness>;
|
let harness: ReturnType<typeof createArgMenusHarness>;
|
||||||
let usageHandler: (args: unknown) => Promise<void>;
|
let usageHandler: (args: unknown) => Promise<void>;
|
||||||
|
let reportHandler: (args: unknown) => Promise<void>;
|
||||||
let argMenuHandler: (args: unknown) => Promise<void>;
|
let argMenuHandler: (args: unknown) => Promise<void>;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -141,6 +184,11 @@ describe("Slack native command argument menus", () => {
|
|||||||
throw new Error("Missing /usage handler");
|
throw new Error("Missing /usage handler");
|
||||||
}
|
}
|
||||||
usageHandler = usage;
|
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");
|
const argMenu = harness.actions.get("openclaw_cmdarg");
|
||||||
if (!argMenu) {
|
if (!argMenu) {
|
||||||
@@ -174,6 +222,37 @@ describe("Slack native command argument menus", () => {
|
|||||||
const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> };
|
const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> };
|
||||||
expect(payload.blocks?.[0]?.type).toBe("section");
|
expect(payload.blocks?.[0]?.type).toBe("section");
|
||||||
expect(payload.blocks?.[1]?.type).toBe("actions");
|
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 () => {
|
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");
|
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 () => {
|
it("rejects menu clicks from other users", async () => {
|
||||||
const respond = vi.fn().mockResolvedValue(undefined);
|
const respond = vi.fn().mockResolvedValue(undefined);
|
||||||
await argMenuHandler({
|
await argMenuHandler({
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type SlackBlock = { type: string; [key: string]: unknown };
|
|||||||
|
|
||||||
const SLACK_COMMAND_ARG_ACTION_ID = "openclaw_cmdarg";
|
const SLACK_COMMAND_ARG_ACTION_ID = "openclaw_cmdarg";
|
||||||
const SLACK_COMMAND_ARG_VALUE_PREFIX = "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");
|
type CommandsRegistry = typeof import("../../auto-reply/commands-registry.js");
|
||||||
let commandsRegistry: CommandsRegistry | undefined;
|
let commandsRegistry: CommandsRegistry | undefined;
|
||||||
@@ -100,20 +102,43 @@ function buildSlackCommandArgMenuBlocks(params: {
|
|||||||
choices: Array<{ value: string; label: string }>;
|
choices: Array<{ value: string; label: string }>;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
const rows = chunkItems(params.choices, 5).map((choices) => ({
|
const encodedChoices = params.choices.map((choice) => ({
|
||||||
type: "actions",
|
label: choice.label,
|
||||||
elements: choices.map((choice) => ({
|
value: encodeSlackCommandArgValue({
|
||||||
type: "button",
|
command: params.command,
|
||||||
action_id: SLACK_COMMAND_ARG_ACTION_ID,
|
arg: params.arg,
|
||||||
text: { type: "plain_text", text: choice.label },
|
value: choice.value,
|
||||||
value: encodeSlackCommandArgValue({
|
userId: params.userId,
|
||||||
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 [
|
return [
|
||||||
{
|
{
|
||||||
type: "section",
|
type: "section",
|
||||||
@@ -568,7 +593,7 @@ export async function registerSlackMonitorSlashCommands(params: {
|
|||||||
}
|
}
|
||||||
).action(actionId, async (args: SlackActionMiddlewareArgs) => {
|
).action(actionId, async (args: SlackActionMiddlewareArgs) => {
|
||||||
const { ack, body, respond } = args;
|
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();
|
await ack();
|
||||||
const respondFn =
|
const respondFn =
|
||||||
respond ??
|
respond ??
|
||||||
@@ -584,7 +609,8 @@ export async function registerSlackMonitorSlashCommands(params: {
|
|||||||
blocks: payload.blocks,
|
blocks: payload.blocks,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const parsed = parseSlackCommandArgValue(action?.value);
|
const actionValue = action?.value ?? action?.selected_option?.value;
|
||||||
|
const parsed = parseSlackCommandArgValue(actionValue);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
await respondFn({
|
await respondFn({
|
||||||
text: "Sorry, that button is no longer valid.",
|
text: "Sorry, that button is no longer valid.",
|
||||||
|
|||||||
Reference in New Issue
Block a user