mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
fix: harden slack interactive blocks
This commit is contained in:
@@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Google Meet: block managed Chrome intro/test speech until browser health proves the participant is in-call, and expose `speechReady` diagnostics so login, admission, permission, and audio-bridge blockers no longer look like successful speech. Refs #72478. Thanks @DougButdorf.
|
||||
- Slack/commands: keep native command argument menus on select controls for encoded choice values up to Slack's option limit and truncate fallback button labels to Slack's button-text limit, so long valid choices no longer render invalid Slack blocks. Thanks @slackapi.
|
||||
- Agents/Codex: flush accepted debounced steering messages before normal app-server turn cleanup, so inbound follow-ups acknowledged as queued are not dropped when the turn completes before the debounce fires. Thanks @vincentkoc.
|
||||
- Slack/interactive replies: keep rendered buttons and selects within Slack Block Kit value and count limits, and align command argument select values with Slack's option limit, so overlong agent-authored choices no longer make Slack reject the whole block payload. Thanks @slackapi.
|
||||
- CLI/update: scope packaged Node compile caches by OpenClaw version and install metadata, so global installs no longer reuse stale compiled chunks after package updates. Thanks @pashpashpash.
|
||||
- Channels/Voice call: keep pre-auth webhook in-flight limiting active when socket remote address metadata is missing, so slow-body requests from stripped-IP proxy paths still share the fallback bucket. (#74453) Thanks @davidangularme.
|
||||
- Plugin SDK/testing: lazy-load TypeScript from the plugin test-contract runtime and add release checks for critical SDK contract entrypoint imports and bundle size, so published packages fail preflight before shipping ESM-incompatible or oversized contract helpers. Thanks @vincentkoc.
|
||||
|
||||
@@ -13,6 +13,10 @@ import { truncateSlackText } from "./truncate.js";
|
||||
|
||||
const SLACK_SECTION_TEXT_MAX = 3000;
|
||||
const SLACK_PLAIN_TEXT_MAX = 75;
|
||||
const SLACK_OPTION_VALUE_MAX = 75;
|
||||
const SLACK_BUTTON_VALUE_MAX = 2000;
|
||||
const SLACK_STATIC_SELECT_OPTIONS_MAX = 100;
|
||||
const SLACK_ACTION_BLOCK_ELEMENTS_MAX = 25;
|
||||
|
||||
export type SlackBlock = Block | KnownBlock;
|
||||
|
||||
@@ -36,6 +40,10 @@ function resolveSlackButtonStyle(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isWithinSlackLimit(value: string, maxLength: number): boolean {
|
||||
return value.length <= maxLength;
|
||||
}
|
||||
|
||||
export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): SlackBlock[] {
|
||||
const initialState = {
|
||||
blocks: [] as SlackBlock[],
|
||||
@@ -58,26 +66,32 @@ export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): Sla
|
||||
return state;
|
||||
}
|
||||
if (block.type === "buttons") {
|
||||
const elements = block.buttons.flatMap((button, choiceIndex) => {
|
||||
if (!button.value && !button.url) {
|
||||
return [];
|
||||
}
|
||||
const style = resolveSlackButtonStyle(button.style);
|
||||
return [
|
||||
{
|
||||
type: "button" as const,
|
||||
action_id: buildSlackReplyButtonActionId(state.buttonIndex + 1, choiceIndex),
|
||||
text: {
|
||||
type: "plain_text" as const,
|
||||
text: truncateSlackText(button.label, SLACK_PLAIN_TEXT_MAX),
|
||||
emoji: true,
|
||||
const elements = block.buttons
|
||||
.flatMap((button, choiceIndex) => {
|
||||
const value =
|
||||
button.value && isWithinSlackLimit(button.value, SLACK_BUTTON_VALUE_MAX)
|
||||
? button.value
|
||||
: undefined;
|
||||
if (!value && !button.url) {
|
||||
return [];
|
||||
}
|
||||
const style = resolveSlackButtonStyle(button.style);
|
||||
return [
|
||||
{
|
||||
type: "button" as const,
|
||||
action_id: buildSlackReplyButtonActionId(state.buttonIndex + 1, choiceIndex),
|
||||
text: {
|
||||
type: "plain_text" as const,
|
||||
text: truncateSlackText(button.label, SLACK_PLAIN_TEXT_MAX),
|
||||
emoji: true,
|
||||
},
|
||||
...(value ? { value } : {}),
|
||||
...(button.url ? { url: button.url } : {}),
|
||||
...(style ? { style } : {}),
|
||||
},
|
||||
...(button.value ? { value: button.value } : {}),
|
||||
...(button.url ? { url: button.url } : {}),
|
||||
...(style ? { style } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
];
|
||||
})
|
||||
.slice(0, SLACK_ACTION_BLOCK_ELEMENTS_MAX);
|
||||
if (elements.length === 0) {
|
||||
return state;
|
||||
}
|
||||
@@ -88,7 +102,10 @@ export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): Sla
|
||||
});
|
||||
return state;
|
||||
}
|
||||
if (block.options.length === 0) {
|
||||
const options = block.options
|
||||
.filter((option) => isWithinSlackLimit(option.value, SLACK_OPTION_VALUE_MAX))
|
||||
.slice(0, SLACK_STATIC_SELECT_OPTIONS_MAX);
|
||||
if (options.length === 0) {
|
||||
return state;
|
||||
}
|
||||
state.blocks.push({
|
||||
@@ -106,7 +123,7 @@ export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): Sla
|
||||
),
|
||||
emoji: true,
|
||||
},
|
||||
options: block.options.map((option, _choiceIndex) => ({
|
||||
options: options.map((option, _choiceIndex) => ({
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: truncateSlackText(option.label, SLACK_PLAIN_TEXT_MAX),
|
||||
|
||||
@@ -147,7 +147,7 @@ vi.mock("./slash-commands.runtime.js", () => {
|
||||
if (params.command?.key === "reportlong") {
|
||||
return resolvePeriodMenu(params, [
|
||||
...fullReportPeriodChoices,
|
||||
{ value: "x".repeat(90), label: "long" },
|
||||
{ value: "x".repeat(45), label: "long" },
|
||||
]);
|
||||
}
|
||||
if (params.command?.key === "reportlongbutton") {
|
||||
@@ -576,7 +576,7 @@ describe("Slack native command argument menus", () => {
|
||||
expect(firstElement?.type).toBe("button");
|
||||
expect(firstElement?.text?.text).toHaveLength(75);
|
||||
expect(firstElement?.text?.text?.endsWith("…")).toBe(true);
|
||||
expect(firstElement?.value?.length).toBeGreaterThan(150);
|
||||
expect(firstElement?.value?.length).toBeGreaterThan(75);
|
||||
expect(firstElement?.confirm).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ 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_TEXT_MAX = 75;
|
||||
const SLACK_COMMAND_ARG_SELECT_OPTION_VALUE_MAX = 150;
|
||||
const SLACK_COMMAND_ARG_SELECT_OPTION_VALUE_MAX = 75;
|
||||
const SLACK_COMMAND_ARG_BUTTON_TEXT_MAX = 75;
|
||||
const SLACK_HEADER_TEXT_MAX = 150;
|
||||
let slashCommandsRuntimePromise: Promise<typeof import("./slash-commands.runtime.js")> | null =
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("buildSlackInteractiveBlocks", () => {
|
||||
const blocks = buildSlackInteractiveBlocks({
|
||||
blocks: [
|
||||
{ type: "text", text: "y".repeat(3100) },
|
||||
{ type: "select", placeholder: long, options: [{ label: long, value: long }] },
|
||||
{ type: "select", placeholder: long, options: [{ label: long, value: "valid" }] },
|
||||
{ type: "buttons", buttons: [{ label: long, value: long }] },
|
||||
],
|
||||
});
|
||||
@@ -83,6 +83,108 @@ describe("buildSlackInteractiveBlocks", () => {
|
||||
expect(selectBlock.elements?.[0]?.options?.[0]?.value).toBe("codex:approve:thread-1");
|
||||
});
|
||||
|
||||
it("drops Slack select options with values beyond Block Kit limits", () => {
|
||||
const blocks = buildSlackInteractiveBlocks({
|
||||
blocks: [
|
||||
{
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Allowed", value: "a".repeat(75) },
|
||||
{ label: "Too long", value: "b".repeat(76) },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const selectBlock = blocks[0] as {
|
||||
elements?: Array<{ options?: Array<{ value?: string }> }>;
|
||||
};
|
||||
|
||||
expect(selectBlock.elements?.[0]?.options).toHaveLength(1);
|
||||
expect(selectBlock.elements?.[0]?.options?.[0]?.value).toBe("a".repeat(75));
|
||||
});
|
||||
|
||||
it("omits Slack select blocks when every option value exceeds Block Kit limits", () => {
|
||||
expect(
|
||||
buildSlackInteractiveBlocks({
|
||||
blocks: [
|
||||
{
|
||||
type: "select",
|
||||
options: [{ label: "Too long", value: "x".repeat(76) }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps Slack static selects at the Block Kit option limit", () => {
|
||||
const blocks = buildSlackInteractiveBlocks({
|
||||
blocks: [
|
||||
{
|
||||
type: "select",
|
||||
options: Array.from({ length: 101 }, (_entry, index) => ({
|
||||
label: `Option ${index + 1}`,
|
||||
value: `v${index + 1}`,
|
||||
})),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const selectBlock = blocks[0] as {
|
||||
elements?: Array<{ options?: Array<{ value?: string }> }>;
|
||||
};
|
||||
|
||||
expect(selectBlock.elements?.[0]?.options).toHaveLength(100);
|
||||
expect(selectBlock.elements?.[0]?.options?.at(-1)?.value).toBe("v100");
|
||||
});
|
||||
|
||||
it("drops value-only Slack buttons with values beyond Block Kit limits", () => {
|
||||
const blocks = buildSlackInteractiveBlocks({
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{ label: "Allowed", value: "a".repeat(2000) },
|
||||
{ label: "Too long", value: "b".repeat(2001) },
|
||||
{ label: "Docs", value: "c".repeat(2001), url: "https://example.com/docs" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const buttonBlock = blocks[0] as {
|
||||
elements?: Array<{ value?: string; url?: string }>;
|
||||
};
|
||||
|
||||
expect(buttonBlock.elements).toHaveLength(2);
|
||||
expect(buttonBlock.elements?.[0]?.value).toBe("a".repeat(2000));
|
||||
expect(buttonBlock.elements?.[1]).toEqual(
|
||||
expect.objectContaining({ url: "https://example.com/docs" }),
|
||||
);
|
||||
expect(buttonBlock.elements?.[1]).not.toHaveProperty("value");
|
||||
});
|
||||
|
||||
it("caps Slack actions blocks at the Block Kit element limit", () => {
|
||||
const blocks = buildSlackInteractiveBlocks({
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: Array.from({ length: 26 }, (_entry, index) => ({
|
||||
label: `Option ${index + 1}`,
|
||||
value: `v${index + 1}`,
|
||||
})),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const buttonBlock = blocks[0] as {
|
||||
elements?: Array<{ value?: string }>;
|
||||
};
|
||||
|
||||
expect(buttonBlock.elements).toHaveLength(25);
|
||||
expect(buttonBlock.elements?.at(-1)?.value).toBe("v25");
|
||||
});
|
||||
|
||||
it("preserves URL-only buttons as Slack link buttons", () => {
|
||||
const blocks = buildSlackInteractiveBlocks({
|
||||
blocks: [
|
||||
|
||||
Reference in New Issue
Block a user