diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index 7bb4332cb8a..b46cedc59aa 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -782,6 +782,82 @@ describe("parseSlackDirectives", () => { }, ]); }); + + it("truncates Slack interactive reply strings to safe Block Kit limits", () => { + const long = "x".repeat(120); + const result = parseSlackDirectives({ + text: `${"y".repeat(3100)} [[slack_select: ${long} | ${long}:${long}]] [[slack_buttons: ${long}:${long}]]`, + }); + + const blocks = getSlackData(result).blocks as Array>; + expect(blocks).toHaveLength(3); + expect(((blocks[0]?.text as { text?: string })?.text ?? "").length).toBeLessThanOrEqual(3000); + expect( + ( + ( + (blocks[1]?.elements as Array>)?.[0]?.placeholder as { + text?: string; + } + )?.text ?? "" + ).length, + ).toBeLessThanOrEqual(75); + expect( + ( + ( + ( + (blocks[1]?.elements as Array>)?.[0]?.options as Array< + Record + > + )?.[0]?.text as { text?: string } + )?.text ?? "" + ).length, + ).toBeLessThanOrEqual(75); + expect( + ( + (( + (blocks[1]?.elements as Array>)?.[0]?.options as Array< + Record + > + )?.[0]?.value as string | undefined) ?? "" + ).length, + ).toBeLessThanOrEqual(75); + expect( + ( + ( + (blocks[2]?.elements as Array>)?.[0]?.text as { + text?: string; + } + )?.text ?? "" + ).length, + ).toBeLessThanOrEqual(75); + expect( + ( + ((blocks[2]?.elements as Array>)?.[0]?.value as + | string + | undefined) ?? "" + ).length, + ).toBeLessThanOrEqual(75); + }); + + it("falls back to the original payload when generated blocks would exceed Slack limits", () => { + const result = parseSlackDirectives({ + text: "Choose [[slack_buttons: Retry:retry]]", + channelData: { + slack: { + blocks: Array.from({ length: 49 }, () => ({ type: "divider" })), + }, + }, + }); + + expect(result).toEqual({ + text: "Choose [[slack_buttons: Retry:retry]]", + channelData: { + slack: { + blocks: Array.from({ length: 49 }, () => ({ type: "divider" })), + }, + }, + }); + }); }); function createDeferred() { diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 9b5d432149a..d5ecbdd6f2f 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -201,6 +201,35 @@ describe("routeReply", () => { ); }); + it("routes directive-only Slack replies when interactive replies are enabled", async () => { + mocks.sendMessageSlack.mockClear(); + const cfg = { + channels: { + slack: { + capabilities: { interactiveReplies: true }, + }, + }, + } as unknown as OpenClawConfig; + await routeReply({ + payload: { text: "[[slack_select: Choose one | Alpha:alpha]]" }, + channel: "slack", + to: "channel:C123", + cfg, + }); + expect(mocks.sendMessageSlack).toHaveBeenCalledWith( + "channel:C123", + "", + expect.objectContaining({ + blocks: [ + expect.objectContaining({ + type: "actions", + block_id: "openclaw_reply_select_1", + }), + ], + }), + ); + }); + it("does not derive responsePrefix from agent identity when routing", async () => { mocks.sendMessageSlack.mockClear(); const cfg = { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 981d45d0d9b..e915308f39a 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -109,9 +109,17 @@ export async function routeReply(params: RouteReplyParams): Promise; @@ -17,6 +21,17 @@ type SlackChoice = { value: string; }; +function truncateSlackText(value: string, max: number): string { + const trimmed = value.trim(); + if (trimmed.length <= max) { + return trimmed; + } + if (max <= 1) { + return trimmed.slice(0, max); + } + return `${trimmed.slice(0, max - 1)}…`; +} + function parseChoice(raw: string): SlackChoice | null { const trimmed = raw.trim(); if (!trimmed) { @@ -54,7 +69,7 @@ function buildSectionBlock(text: string): SlackBlock | null { type: "section", text: { type: "mrkdwn", - text: trimmed, + text: truncateSlackText(trimmed, SLACK_SECTION_TEXT_MAX), }, }; } @@ -72,10 +87,10 @@ function buildButtonsBlock(raw: string, index: number): SlackBlock | null { action_id: SLACK_REPLY_BUTTON_ACTION_ID, text: { type: "plain_text", - text: choice.label, + text: truncateSlackText(choice.label, SLACK_PLAIN_TEXT_MAX), emoji: true, }, - value: choice.value, + value: truncateSlackText(choice.value, SLACK_OPTION_VALUE_MAX), })), }; } @@ -103,16 +118,16 @@ function buildSelectBlock(raw: string, index: number): SlackBlock | null { action_id: SLACK_REPLY_SELECT_ACTION_ID, placeholder: { type: "plain_text", - text: placeholder, + text: truncateSlackText(placeholder, SLACK_PLAIN_TEXT_MAX), emoji: true, }, options: choices.map((choice) => ({ text: { type: "plain_text", - text: choice.label, + text: truncateSlackText(choice.label, SLACK_PLAIN_TEXT_MAX), emoji: true, }, - value: choice.value, + value: truncateSlackText(choice.value, SLACK_OPTION_VALUE_MAX), })), }, ], @@ -181,6 +196,9 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload { } const existingBlocks = readExistingSlackBlocks(payload); + if (existingBlocks.length + generatedBlocks.length > SLACK_MAX_BLOCKS) { + return payload; + } const nextBlocks = [...existingBlocks, ...generatedBlocks]; return {