diff --git a/CHANGELOG.md b/CHANGELOG.md index d61f7050ca1..468aaa06970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/slack/src/blocks-render.ts b/extensions/slack/src/blocks-render.ts index 35dc74856e2..f7fb5344d37 100644 --- a/extensions/slack/src/blocks-render.ts +++ b/extensions/slack/src/blocks-render.ts @@ -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), diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index 60b5a884905..8ebb9f9fe77 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -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(); }); diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index 817fde8dfe2..8769da337f0 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -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 | null = diff --git a/extensions/slack/src/shared-interactive.test.ts b/extensions/slack/src/shared-interactive.test.ts index e09c3cf1065..e8af5d69015 100644 --- a/extensions/slack/src/shared-interactive.test.ts +++ b/extensions/slack/src/shared-interactive.test.ts @@ -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: [