mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
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:
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user