From ebf05be7420c79533d51cc0e7fd248c8b6a03544 Mon Sep 17 00:00:00 2001 From: "clawsweeper[bot]" <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:25:38 -0700 Subject: [PATCH] fix(slack): preserve mixed interactive blocks Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com> --- extensions/slack/src/blocks-render.ts | 49 ++++++++++++++++-- .../slack/src/message-action-dispatch.test.ts | 51 +++++++++++++++++++ .../slack/src/message-action-dispatch.ts | 13 ++++- extensions/slack/src/outbound-adapter.ts | 16 +++--- 4 files changed, 117 insertions(+), 12 deletions(-) diff --git a/extensions/slack/src/blocks-render.ts b/extensions/slack/src/blocks-render.ts index 26f54afd561..879071ef03a 100644 --- a/extensions/slack/src/blocks-render.ts +++ b/extensions/slack/src/blocks-render.ts @@ -21,6 +21,11 @@ const SLACK_ACTION_BLOCK_ELEMENTS_MAX = 25; export type SlackBlock = Block | KnownBlock; +export type SlackInteractiveBlockRenderOptions = { + buttonIndexOffset?: number; + selectIndexOffset?: number; +}; + function buildSlackReplyButtonActionId(buttonIndex: number, choiceIndex: number): string { return `${SLACK_REPLY_BUTTON_ACTION_ID}:${String(buttonIndex)}:${String(choiceIndex + 1)}`; } @@ -45,11 +50,49 @@ function isWithinSlackLimit(value: string, maxLength: number): boolean { return value.length <= maxLength; } -export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): SlackBlock[] { +function readSlackBlockId(block: SlackBlock): string | undefined { + const value = (block as { block_id?: unknown }).block_id; + return typeof value === "string" ? value : undefined; +} + +function readSlackOpenClawBlockIndex(blockId: string, prefix: string): number | undefined { + if (!blockId.startsWith(prefix)) { + return undefined; + } + const value = Number.parseInt(blockId.slice(prefix.length), 10); + return Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +export function resolveSlackInteractiveBlockOffsets( + blocks?: readonly SlackBlock[], +): SlackInteractiveBlockRenderOptions { + let buttonIndexOffset = 0; + let selectIndexOffset = 0; + for (const block of blocks ?? []) { + const blockId = readSlackBlockId(block); + if (!blockId) { + continue; + } + buttonIndexOffset = Math.max( + buttonIndexOffset, + readSlackOpenClawBlockIndex(blockId, "openclaw_reply_buttons_") ?? 0, + ); + selectIndexOffset = Math.max( + selectIndexOffset, + readSlackOpenClawBlockIndex(blockId, "openclaw_reply_select_") ?? 0, + ); + } + return { buttonIndexOffset, selectIndexOffset }; +} + +export function buildSlackInteractiveBlocks( + interactive?: InteractiveReply, + options: SlackInteractiveBlockRenderOptions = {}, +): SlackBlock[] { const initialState = { blocks: [] as SlackBlock[], - buttonIndex: 0, - selectIndex: 0, + buttonIndex: options.buttonIndexOffset ?? 0, + selectIndex: options.selectIndexOffset ?? 0, }; return reduceInteractiveReply(interactive, initialState, (state, block) => { if (block.type === "text") { diff --git a/extensions/slack/src/message-action-dispatch.test.ts b/extensions/slack/src/message-action-dispatch.test.ts index 5de2854ff06..8d95c5cccc2 100644 --- a/extensions/slack/src/message-action-dispatch.test.ts +++ b/extensions/slack/src/message-action-dispatch.test.ts @@ -48,6 +48,57 @@ describe("handleSlackMessageAction", () => { ]); }); + it("keeps generated Slack control ids unique when presentation and interactive controls are merged", async () => { + const invoke = createInvokeSpy(); + + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "send", + cfg: {}, + params: { + to: "channel:C1", + message: "Deploy?", + presentation: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Stage", value: "stage" }], + }, + ], + }, + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Approve", value: "approve" }], + }, + ], + }, + }, + } as never, + invoke: invoke as never, + }); + + const action = invoke.mock.calls[0]?.[0] as { + blocks?: Array<{ + block_id?: string; + elements?: Array<{ action_id?: string; value?: string }>; + }>; + }; + + expect(action.blocks).toEqual([ + expect.objectContaining({ + block_id: "openclaw_reply_buttons_1", + elements: [expect.objectContaining({ action_id: "openclaw:reply_button:1:1" })], + }), + expect.objectContaining({ + block_id: "openclaw_reply_buttons_2", + elements: [expect.objectContaining({ action_id: "openclaw:reply_button:2:1" })], + }), + ]); + }); + it("maps upload-file to the internal uploadFile action", async () => { const invoke = createInvokeSpy(); diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 51bc9be8cb2..81a8e8ef53d 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -5,7 +5,11 @@ import { normalizeMessagePresentation, } from "openclaw/plugin-sdk/interactive-runtime"; import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/param-readers"; -import { buildSlackInteractiveBlocks, buildSlackPresentationBlocks } from "./blocks-render.js"; +import { + buildSlackInteractiveBlocks, + buildSlackPresentationBlocks, + resolveSlackInteractiveBlockOffsets, +} from "./blocks-render.js"; type SlackActionInvoke = ( action: Record, @@ -40,10 +44,15 @@ export async function handleSlackMessageAction(params: { const mediaUrl = readStringParam(actionParams, "media", { trim: false }); const presentation = normalizeMessagePresentation(actionParams.presentation); const interactive = normalizeInteractiveReply(actionParams.interactive); - const interactiveBlocks = interactive ? buildSlackInteractiveBlocks(interactive) : undefined; const presentationBlocks = presentation ? buildSlackPresentationBlocks(presentation) : undefined; + const interactiveBlocks = interactive + ? buildSlackInteractiveBlocks( + interactive, + resolveSlackInteractiveBlockOffsets(presentationBlocks), + ) + : undefined; const mergedBlocks = [...(presentationBlocks ?? []), ...(interactiveBlocks ?? [])]; const blocks = mergedBlocks.length > 0 ? mergedBlocks : undefined; if (!content && !mediaUrl && !blocks) { diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index e5298767ecc..5d53ee4b1bf 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -20,6 +20,7 @@ import { parseSlackBlocksInput } from "./blocks-input.js"; import { buildSlackInteractiveBlocks, buildSlackPresentationBlocks, + resolveSlackInteractiveBlockOffsets, type SlackBlock, } from "./blocks-render.js"; import { compileSlackInteractiveReplies } from "./interactive-replies.js"; @@ -39,11 +40,15 @@ async function loadSlackSendRuntime() { function resolveRenderedInteractiveBlocks( interactive?: InteractiveReply, + previousBlocks?: readonly SlackBlock[], ): SlackBlock[] | undefined { if (!interactive) { return undefined; } - const blocks = buildSlackInteractiveBlocks(interactive); + const blocks = buildSlackInteractiveBlocks( + interactive, + resolveSlackInteractiveBlockOffsets(previousBlocks), + ); return blocks.length > 0 ? blocks : undefined; } @@ -116,12 +121,9 @@ function resolveSlackBlocks(payload: { const nativeBlocks = parseSlackBlocksInput(slackData?.blocks) as SlackBlock[] | undefined; const renderedPresentation = slackData?.presentationBlocks ?? buildSlackPresentationBlocks(payload.presentation); - const renderedInteractive = resolveRenderedInteractiveBlocks(payload.interactive); - const mergedBlocks = [ - ...(nativeBlocks ?? []), - ...renderedPresentation, - ...(renderedInteractive ?? []), - ]; + const previousBlocks = [...(nativeBlocks ?? []), ...renderedPresentation]; + const renderedInteractive = resolveRenderedInteractiveBlocks(payload.interactive, previousBlocks); + const mergedBlocks = [...previousBlocks, ...(renderedInteractive ?? [])]; if (mergedBlocks.length === 0) { return undefined; }