fix: harden slack interactive blocks

This commit is contained in:
Peter Steinberger
2026-04-30 03:19:34 +01:00
parent 8aed80d2fa
commit fc8fafbd2f
5 changed files with 145 additions and 25 deletions

View File

@@ -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.

View File

@@ -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),

View File

@@ -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();
});

View File

@@ -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 =

View File

@@ -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: [