fix(slack): preserve mixed interactive blocks

Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-04-29 22:25:38 -07:00
committed by GitHub
parent c6c518e6e9
commit ebf05be742
4 changed files with 117 additions and 12 deletions

View File

@@ -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") {

View File

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

View File

@@ -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<string, unknown>,
@@ -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) {

View File

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