refactor(slack): reuse canonical interactive block renderer (#128760)

This commit is contained in:
Peter Steinberger
2026-08-24 07:55:00 -07:00
committed by GitHub
parent a8722e74ec
commit ec091478fc
3 changed files with 52 additions and 115 deletions
+1 -1
View File
@@ -1164,7 +1164,7 @@ extensions/slack/src/approval-native-gates.ts 1
extensions/slack/src/approval-native.ts 2
extensions/slack/src/blocks-fallback.ts 2
extensions/slack/src/blocks-input.ts 2
extensions/slack/src/blocks-render.ts 2
extensions/slack/src/blocks-render.ts 1
extensions/slack/src/channel-actions.ts 1
extensions/slack/src/channel-type.ts 1
extensions/slack/src/channel.ts 7
+5 -114
View File
@@ -2,7 +2,7 @@
import type { Block, KnownBlock } from "@slack/web-api";
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
import {
reduceLegacyInteractiveReply,
legacyInteractiveReplyToPresentation,
resolveMessagePresentationButtonAction,
resolveMessagePresentationOptionAction,
} from "openclaw/plugin-sdk/interactive-runtime";
@@ -248,119 +248,10 @@ export function buildSlackInteractiveBlocks(
interactive?: LegacyInteractiveReply,
options: SlackBlockRenderOptions = {},
): SlackBlock[] {
const initialState = {
blocks: [] as SlackBlock[],
buttonIndex: options.buttonIndexOffset ?? 0,
selectIndex: options.selectIndexOffset ?? 0,
};
return reduceLegacyInteractiveReply(interactive, initialState, (state, block) => {
if (block.type === "text") {
const trimmed = block.text.trim();
if (!trimmed) {
return state;
}
state.blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: truncateSlackText(trimmed, SLACK_SECTION_TEXT_MAX),
},
});
return state;
}
if (block.type === "buttons") {
// Index is position in the question's options; core emits one buttons block in option order.
const elements = block.buttons
.flatMap((button, choiceIndex) => {
const target = resolveSlackButtonTarget(button, choiceIndex);
if (
!target ||
(target.kind === "link"
? !isWithinSlackLimit(target.url, SLACK_BUTTON_URL_MAX)
: !isWithinSlackLimit(target.value, SLACK_BUTTON_VALUE_MAX))
) {
return [];
}
const style = resolveSlackButtonStyle(button.style);
return [
{
type: "button" as const,
// Slack emits block_actions even for URL buttons; link-only actions must be ignored.
action_id:
target.kind === "link"
? buildSlackReplyLinkActionId(state.buttonIndex + 1, choiceIndex)
: target.kind === "approval"
? buildSlackApprovalButtonActionId(state.buttonIndex + 1, choiceIndex)
: target.kind === "callback"
? buildSlackCallbackButtonActionId(state.buttonIndex + 1, choiceIndex)
: target.kind === "question"
? buildSlackQuestionButtonActionId(state.buttonIndex + 1, choiceIndex)
: buildSlackReplyButtonActionId(state.buttonIndex + 1, choiceIndex),
text: {
type: "plain_text" as const,
text: truncateSlackText(button.label, SLACK_ACTION_LABEL_MAX),
emoji: true,
},
...(target.kind === "link" ? { url: target.url } : { value: target.value }),
...(style ? { style } : {}),
},
];
})
.slice(0, SLACK_ACTION_BLOCK_ELEMENTS_MAX);
if (elements.length === 0) {
return state;
}
state.blocks.push({
type: "actions",
block_id: `openclaw_reply_buttons_${++state.buttonIndex}`,
elements,
});
return state;
}
const optionsLocal = block.options
.flatMap((option) => {
const target = resolveSlackOptionTarget(option);
return target ? [{ label: option.label, ...target }] : [];
})
.filter(isRenderableSlackOption)
.slice(0, SLACK_STATIC_SELECT_OPTIONS_MAX);
const optionKinds = new Set(optionsLocal.map((option) => option.kind));
if (optionsLocal.length === 0 || optionKinds.size !== 1) {
return state;
}
state.blocks.push({
type: "actions",
block_id: `openclaw_reply_select_${++state.selectIndex}`,
elements: [
{
type: "static_select",
action_id:
optionsLocal[0]?.kind === "approval"
? buildSlackApprovalSelectActionId(state.selectIndex)
: optionsLocal[0]?.kind === "callback"
? buildSlackCallbackSelectActionId(state.selectIndex)
: buildSlackReplySelectActionId(state.selectIndex),
placeholder: {
type: "plain_text",
text: truncateSlackText(
normalizeOptionalString(block.placeholder) ?? "Choose an option",
SLACK_ACTION_LABEL_MAX,
),
emoji: true,
},
options: optionsLocal.map((option, _choiceIndex) => ({
text: {
type: "plain_text",
text: truncateSlackText(option.label, SLACK_ACTION_LABEL_MAX),
emoji: true,
},
value: option.value,
})),
},
],
});
return state;
}).blocks;
return buildSlackPresentationBlocks(
interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined,
options,
);
}
/** Render portable presentation blocks as Slack Block Kit blocks. */
@@ -339,6 +339,52 @@ describe("buildSlackInteractiveBlocks", () => {
expect(buttonBlock.elements?.[2]?.style).toBe("primary");
expect(buttonBlock.elements?.[3]).not.toHaveProperty("style");
});
it.each([
{
name: "approval",
action: {
type: "approval" as const,
approvalId: "request-1",
approvalKind: "exec" as const,
decision: "allow-once" as const,
},
actionId: "openclaw:approval_button:5:1",
value:
'openclaw:approval:v1:{"approvalId":"request-1","approvalKind":"exec","decision":"allow-once"}',
},
{
name: "callback",
action: { type: "callback" as const, value: "plugin:opaque|value" },
actionId: "openclaw:callback_button:5:1",
value: "plugin:opaque|value",
},
{
name: "question",
action: {
type: "question" as const,
questionId: "ask_0123456789abcdef0123456789abcdef",
optionValue: "Production",
},
actionId: "openclaw:question_button:5:1",
value: "slq1:ask_0123456789abcdef0123456789abcdef:0",
},
])(
"preserves typed $name authority through the legacy renderer",
({ action, actionId, value }) => {
expect(
buildSlackInteractiveBlocks(
{ blocks: [{ type: "buttons", buttons: [{ label: "Continue", action }] }] },
{ buttonIndexOffset: 4 },
),
).toMatchObject([
{
block_id: "openclaw_reply_buttons_5",
elements: [{ action_id: actionId, value }],
},
]);
},
);
});
describe("buildSlackPresentationBlocks", () => {