mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
1cb914d6bf
Make ask_user use native Telegram controls when one single-select question can be represented directly, while keeping multi-select and batch questions on the text fallback. Add native Other-to-ForceReply input, preserve Gateway-owned option ordering across Telegram, Slack, and Discord, and prevent later progress from overwriting the delivered question. Co-authored-by: Ayaan Zaidi <hi@obviy.us>
579 lines
20 KiB
TypeScript
579 lines
20 KiB
TypeScript
// Slack plugin module implements blocks render behavior.
|
|
import type { Block, KnownBlock } from "@slack/web-api";
|
|
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
|
|
import {
|
|
legacyInteractiveReplyToPresentation,
|
|
resolveMessagePresentationButtonAction,
|
|
resolveMessagePresentationOptionAction,
|
|
} from "openclaw/plugin-sdk/interactive-runtime";
|
|
import type {
|
|
LegacyInteractiveReply,
|
|
MessagePresentation,
|
|
MessagePresentationAction,
|
|
MessagePresentationButtonsBlock,
|
|
MessagePresentationChartBlock,
|
|
MessagePresentationSelectBlock,
|
|
} from "openclaw/plugin-sdk/interactive-runtime";
|
|
import {
|
|
resolveAskUserQuestionOptionIndex,
|
|
type AskUserQuestionOptionIndices,
|
|
} from "openclaw/plugin-sdk/reply-payload";
|
|
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
|
import { encodeSlackApprovalAction } from "./approval-actions.js";
|
|
import {
|
|
buildSlackDataTableBlock,
|
|
countSlackDataTableBlocksCellCharacters,
|
|
countSlackDataTableCellCharacters,
|
|
SLACK_DATA_TABLE_AGGREGATE_CELL_CHARACTERS_MAX,
|
|
} from "./data-table.js";
|
|
import {
|
|
buildSlackDataVisualizationBlock,
|
|
canRenderSlackDataVisualization,
|
|
hasSlackDataVisualizationBlock,
|
|
SLACK_DATA_VISUALIZATION_BLOCKS_MAX,
|
|
} from "./data-visualization.js";
|
|
import { chunkSlackMrkdwnText } from "./format.js";
|
|
import { renderSlackMessagePresentationChartFallbackText } from "./presentation-fallback.js";
|
|
import {
|
|
SLACK_ACTION_BLOCK_ELEMENTS_MAX,
|
|
SLACK_ACTION_LABEL_MAX,
|
|
SLACK_BUTTON_VALUE_MAX,
|
|
SLACK_HEADER_TEXT_MAX,
|
|
SLACK_OPTION_VALUE_MAX,
|
|
SLACK_SECTION_TEXT_MAX,
|
|
SLACK_STATIC_SELECT_OPTIONS_MAX,
|
|
} from "./presentation.js";
|
|
import { encodeSlackQuestionAction } from "./question-actions.js";
|
|
import {
|
|
SLACK_APPROVAL_BUTTON_ACTION_ID,
|
|
SLACK_APPROVAL_SELECT_ACTION_ID,
|
|
SLACK_CALLBACK_BUTTON_ACTION_ID,
|
|
SLACK_CALLBACK_SELECT_ACTION_ID,
|
|
SLACK_REPLY_BUTTON_ACTION_ID,
|
|
SLACK_REPLY_LINK_ACTION_ID,
|
|
SLACK_REPLY_SELECT_ACTION_ID,
|
|
SLACK_QUESTION_BUTTON_ACTION_ID,
|
|
} from "./reply-action-ids.js";
|
|
import { truncateSlackText } from "./truncate.js";
|
|
|
|
const SLACK_BUTTON_URL_MAX = 3000;
|
|
|
|
export type SlackBlock = Block | KnownBlock;
|
|
|
|
export type SlackBlockRenderOptions = {
|
|
buttonIndexOffset?: number;
|
|
dataTableCellCharacterCountOffset?: number;
|
|
dataVisualizationCountOffset?: number;
|
|
questionOptionIndices?: AskUserQuestionOptionIndices;
|
|
selectIndexOffset?: number;
|
|
};
|
|
|
|
function buildSlackReplyButtonActionId(buttonIndex: number, choiceIndex: number): string {
|
|
return `${SLACK_REPLY_BUTTON_ACTION_ID}:${String(buttonIndex)}:${String(choiceIndex + 1)}`;
|
|
}
|
|
|
|
function buildSlackReplyLinkActionId(buttonIndex: number, choiceIndex: number): string {
|
|
return `${SLACK_REPLY_LINK_ACTION_ID}:${String(buttonIndex)}:${String(choiceIndex + 1)}`;
|
|
}
|
|
|
|
function buildSlackReplySelectActionId(selectIndex: number): string {
|
|
return `${SLACK_REPLY_SELECT_ACTION_ID}:${String(selectIndex)}`;
|
|
}
|
|
|
|
function buildSlackApprovalButtonActionId(buttonIndex: number, choiceIndex: number): string {
|
|
return `${SLACK_APPROVAL_BUTTON_ACTION_ID}:${String(buttonIndex)}:${String(choiceIndex + 1)}`;
|
|
}
|
|
|
|
function buildSlackApprovalSelectActionId(selectIndex: number): string {
|
|
return `${SLACK_APPROVAL_SELECT_ACTION_ID}:${String(selectIndex)}`;
|
|
}
|
|
|
|
function buildSlackCallbackButtonActionId(buttonIndex: number, choiceIndex: number): string {
|
|
return `${SLACK_CALLBACK_BUTTON_ACTION_ID}:${String(buttonIndex)}:${String(choiceIndex + 1)}`;
|
|
}
|
|
|
|
function buildSlackCallbackSelectActionId(selectIndex: number): string {
|
|
return `${SLACK_CALLBACK_SELECT_ACTION_ID}:${String(selectIndex)}`;
|
|
}
|
|
|
|
function buildSlackQuestionButtonActionId(buttonIndex: number, choiceIndex: number): string {
|
|
return `${SLACK_QUESTION_BUTTON_ACTION_ID}:${String(buttonIndex)}:${String(choiceIndex + 1)}`;
|
|
}
|
|
|
|
function resolveSlackButtonStyle(
|
|
style: "primary" | "secondary" | "success" | "danger" | undefined,
|
|
) {
|
|
if (style === "primary" || style === "danger") {
|
|
return style;
|
|
}
|
|
if (style === "success") {
|
|
return "primary";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
type SlackActionTarget =
|
|
| { kind: "approval"; value: string }
|
|
| { kind: "callback"; value: string }
|
|
| { kind: "link"; url: string }
|
|
| { kind: "question"; value: string }
|
|
| { kind: "reply"; value: string };
|
|
|
|
function resolveSlackActionTarget(
|
|
action: MessagePresentationAction | undefined,
|
|
questionOptionIndices?: AskUserQuestionOptionIndices,
|
|
): SlackActionTarget | undefined {
|
|
if (!action) {
|
|
return undefined;
|
|
}
|
|
if (action.type === "approval") {
|
|
return { kind: "approval", value: encodeSlackApprovalAction(action) };
|
|
}
|
|
if (action.type === "question") {
|
|
if ("intent" in action) {
|
|
return undefined;
|
|
}
|
|
const optionIndex = resolveAskUserQuestionOptionIndex({
|
|
questionOptionIndices,
|
|
questionId: action.questionId,
|
|
optionValue: action.optionValue,
|
|
});
|
|
const value =
|
|
optionIndex === undefined
|
|
? undefined
|
|
: encodeSlackQuestionAction({ questionId: action.questionId, optionIndex });
|
|
return value ? { kind: "question", value } : undefined;
|
|
}
|
|
if (action.type === "url" || action.type === "web-app") {
|
|
const url = normalizeOptionalString(action.url);
|
|
return url ? { kind: "link", url } : undefined;
|
|
}
|
|
if (action.type === "callback") {
|
|
const value = normalizeOptionalString(action.value);
|
|
return value ? { kind: "callback", value } : undefined;
|
|
}
|
|
const command = normalizeOptionalString(action.command);
|
|
// Command-backed approvals are a shipped legacy input with no trustworthy
|
|
// owner field. Keep them on the kind-specific compatibility resolver.
|
|
return command && parseExecApprovalCommandText(command)
|
|
? { kind: "reply", value: command }
|
|
: undefined;
|
|
}
|
|
|
|
function resolveSlackButtonTarget(
|
|
button: MessagePresentationButtonsBlock["buttons"][number],
|
|
questionOptionIndices?: AskUserQuestionOptionIndices,
|
|
): SlackActionTarget | undefined {
|
|
if (button.action !== undefined) {
|
|
const action = resolveMessagePresentationButtonAction(button);
|
|
return action ? resolveSlackActionTarget(action, questionOptionIndices) : undefined;
|
|
}
|
|
|
|
// Legacy buttons could carry both a URL and callback fallback. Preserve the
|
|
// callback when Slack cannot accept the URL; typed actions stay authoritative.
|
|
const legacyUrl = normalizeOptionalString(
|
|
button.url ?? button.webApp?.url ?? button.web_app?.url,
|
|
);
|
|
if (legacyUrl && isWithinSlackLimit(legacyUrl, SLACK_BUTTON_URL_MAX)) {
|
|
return { kind: "link", url: legacyUrl };
|
|
}
|
|
const legacyValue = normalizeOptionalString(button.value);
|
|
if (legacyValue) {
|
|
return { kind: "reply", value: legacyValue };
|
|
}
|
|
return legacyUrl ? { kind: "link", url: legacyUrl } : undefined;
|
|
}
|
|
|
|
function isSlackTextFallbackButton(
|
|
button: MessagePresentationButtonsBlock["buttons"][number],
|
|
): boolean {
|
|
// ask_user already names the free-text path in visible copy. Omitting only
|
|
// this action keeps declared choices native without a composer hook.
|
|
const action = resolveMessagePresentationButtonAction(button);
|
|
return action?.type === "question" && "intent" in action && action.intent === "custom-input";
|
|
}
|
|
|
|
function resolveSlackOptionTarget(
|
|
option: MessagePresentationSelectBlock["options"][number],
|
|
): Exclude<SlackActionTarget, { kind: "link" } | { kind: "question" }> | undefined {
|
|
if (option.action !== undefined) {
|
|
const action = resolveMessagePresentationOptionAction(option);
|
|
const target = action ? resolveSlackActionTarget(action) : undefined;
|
|
return target?.kind === "link" || target?.kind === "question" ? undefined : target;
|
|
}
|
|
const value = normalizeOptionalString(option.value);
|
|
return value ? { kind: "reply", value } : undefined;
|
|
}
|
|
|
|
function isWithinSlackLimit(value: string, maxLength: number): boolean {
|
|
return value.length <= maxLength;
|
|
}
|
|
|
|
function isRenderableSlackOption(option: {
|
|
kind: "approval" | "callback" | "reply";
|
|
label: string;
|
|
value: string;
|
|
}): boolean {
|
|
return isWithinSlackLimit(option.value, SLACK_OPTION_VALUE_MAX);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/** Resolve existing Block Kit indexes and native-data budgets before appending portable blocks. */
|
|
export function resolveSlackBlockOffsets(blocks?: readonly SlackBlock[]): SlackBlockRenderOptions {
|
|
let buttonIndexOffset = 0;
|
|
const dataTableCellCharacterCountOffset =
|
|
countSlackDataTableBlocksCellCharacters(blocks) ??
|
|
SLACK_DATA_TABLE_AGGREGATE_CELL_CHARACTERS_MAX + 1;
|
|
let dataVisualizationCountOffset = 0;
|
|
let selectIndexOffset = 0;
|
|
for (const block of blocks ?? []) {
|
|
if (hasSlackDataVisualizationBlock([block])) {
|
|
dataVisualizationCountOffset += 1;
|
|
}
|
|
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,
|
|
dataTableCellCharacterCountOffset,
|
|
dataVisualizationCountOffset,
|
|
selectIndexOffset,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use buildSlackPresentationBlocks with MessagePresentation.
|
|
*/
|
|
export function buildSlackInteractiveBlocks(
|
|
interactive?: LegacyInteractiveReply,
|
|
options: SlackBlockRenderOptions = {},
|
|
): SlackBlock[] {
|
|
return buildSlackPresentationBlocks(
|
|
interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined,
|
|
options,
|
|
);
|
|
}
|
|
|
|
/** Render portable presentation blocks as Slack Block Kit blocks. */
|
|
export function buildSlackPresentationBlocks(
|
|
presentation?: MessagePresentation,
|
|
options: SlackBlockRenderOptions = {},
|
|
): SlackBlock[] {
|
|
if (!presentation) {
|
|
return [];
|
|
}
|
|
const renderTablesNatively = canRenderSlackPresentationTables(presentation, options);
|
|
const blocks: SlackBlock[] = [];
|
|
if (presentation.title) {
|
|
blocks.push({
|
|
type: "header",
|
|
text: {
|
|
type: "plain_text",
|
|
text: truncateSlackText(presentation.title, SLACK_HEADER_TEXT_MAX),
|
|
emoji: true,
|
|
},
|
|
});
|
|
}
|
|
let buttonIndex = options.buttonIndexOffset ?? 0;
|
|
let dataTableCellCharacterCount = options.dataTableCellCharacterCountOffset ?? 0;
|
|
let dataVisualizationCount = options.dataVisualizationCountOffset ?? 0;
|
|
let selectIndex = options.selectIndexOffset ?? 0;
|
|
for (const block of presentation.blocks) {
|
|
if (block.type === "text" || block.type === "context") {
|
|
const text = block.text.trim();
|
|
if (!text) {
|
|
continue;
|
|
}
|
|
for (const chunk of chunkSlackMrkdwnText(text, SLACK_SECTION_TEXT_MAX)) {
|
|
blocks.push(
|
|
block.type === "context"
|
|
? { type: "context", elements: [{ type: "mrkdwn", text: chunk, verbatim: true }] }
|
|
: { type: "section", text: { type: "mrkdwn", text: chunk } },
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if (block.type === "divider") {
|
|
blocks.push({ type: "divider" });
|
|
continue;
|
|
}
|
|
if (block.type === "buttons") {
|
|
const rendered = buildSlackPresentationButtonBlock(
|
|
block,
|
|
buttonIndex + 1,
|
|
options.questionOptionIndices,
|
|
);
|
|
if (rendered) {
|
|
buttonIndex += 1;
|
|
blocks.push(rendered);
|
|
}
|
|
continue;
|
|
}
|
|
if (block.type === "chart") {
|
|
const rendered =
|
|
dataVisualizationCount < SLACK_DATA_VISUALIZATION_BLOCKS_MAX
|
|
? buildSlackPresentationChartBlock(block)
|
|
: undefined;
|
|
if (rendered) {
|
|
dataVisualizationCount += 1;
|
|
blocks.push(rendered);
|
|
} else {
|
|
const fallback = renderSlackMessagePresentationChartFallbackText(block);
|
|
blocks.push(
|
|
...chunkTextForOutbound(fallback, SLACK_SECTION_TEXT_MAX).map(
|
|
(text): SlackBlock => ({
|
|
type: "context",
|
|
elements: [{ type: "mrkdwn", text, verbatim: true }],
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if (block.type === "table") {
|
|
if (!renderTablesNatively) {
|
|
continue;
|
|
}
|
|
const rendered = buildSlackDataTableBlock(block, {
|
|
cellCharacterCountOffset: dataTableCellCharacterCount,
|
|
});
|
|
if (rendered) {
|
|
dataTableCellCharacterCount += countSlackDataTableCellCharacters(rendered);
|
|
blocks.push(rendered);
|
|
}
|
|
continue;
|
|
}
|
|
if (block.type === "select") {
|
|
const rendered = buildSlackPresentationSelectBlock(block, selectIndex + 1);
|
|
if (rendered) {
|
|
selectIndex += 1;
|
|
blocks.push(rendered);
|
|
}
|
|
}
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
function buildSlackPresentationChartBlock(
|
|
block: MessagePresentationChartBlock,
|
|
): SlackBlock | undefined {
|
|
return buildSlackDataVisualizationBlock(block);
|
|
}
|
|
|
|
function buildSlackPresentationButtonBlock(
|
|
block: MessagePresentationButtonsBlock,
|
|
buttonIndex: number,
|
|
questionOptionIndices?: AskUserQuestionOptionIndices,
|
|
): SlackBlock | undefined {
|
|
const elements = block.buttons
|
|
.flatMap((button, choiceIndex) => {
|
|
const target = resolveSlackButtonTarget(button, questionOptionIndices);
|
|
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(buttonIndex, choiceIndex)
|
|
: target.kind === "approval"
|
|
? buildSlackApprovalButtonActionId(buttonIndex, choiceIndex)
|
|
: target.kind === "callback"
|
|
? buildSlackCallbackButtonActionId(buttonIndex, choiceIndex)
|
|
: target.kind === "question"
|
|
? buildSlackQuestionButtonActionId(buttonIndex, choiceIndex)
|
|
: buildSlackReplyButtonActionId(buttonIndex, 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);
|
|
return elements.length > 0
|
|
? {
|
|
type: "actions",
|
|
block_id: `openclaw_reply_buttons_${buttonIndex}`,
|
|
elements,
|
|
}
|
|
: undefined;
|
|
}
|
|
|
|
/** True when every portable table fits Slack's native per-message table budget. */
|
|
function canRenderSlackPresentationTables(
|
|
presentation: MessagePresentation,
|
|
options: SlackBlockRenderOptions = {},
|
|
): boolean {
|
|
let cellCharacterCount = options.dataTableCellCharacterCountOffset ?? 0;
|
|
for (const block of presentation.blocks) {
|
|
if (block.type !== "table") {
|
|
continue;
|
|
}
|
|
const rendered = buildSlackDataTableBlock(block, {
|
|
cellCharacterCountOffset: cellCharacterCount,
|
|
});
|
|
if (!rendered) {
|
|
return false;
|
|
}
|
|
cellCharacterCount += countSlackDataTableCellCharacters(rendered);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** True when native Slack rendering preserves every portable control. */
|
|
export function canRenderSlackPresentation(
|
|
presentation: MessagePresentation,
|
|
options: SlackBlockRenderOptions = {},
|
|
): boolean {
|
|
if (presentation.title && !isWithinSlackLimit(presentation.title.trim(), SLACK_HEADER_TEXT_MAX)) {
|
|
return false;
|
|
}
|
|
if (!canRenderSlackPresentationTables(presentation, options)) {
|
|
return false;
|
|
}
|
|
let dataVisualizationCount = options.dataVisualizationCountOffset ?? 0;
|
|
for (const block of presentation.blocks) {
|
|
if (block.type === "text" || block.type === "context") {
|
|
continue;
|
|
}
|
|
if (block.type === "buttons") {
|
|
let nativeButtonCount = 0;
|
|
const allButtonsRenderable =
|
|
block.buttons.every((button) => {
|
|
if (isSlackTextFallbackButton(button)) {
|
|
return true;
|
|
}
|
|
nativeButtonCount += 1;
|
|
if (!isWithinSlackLimit(button.label, SLACK_ACTION_LABEL_MAX)) {
|
|
return false;
|
|
}
|
|
const target = resolveSlackButtonTarget(button, options.questionOptionIndices);
|
|
return target
|
|
? target.kind === "link"
|
|
? isWithinSlackLimit(target.url, SLACK_BUTTON_URL_MAX)
|
|
: isWithinSlackLimit(target.value, SLACK_BUTTON_VALUE_MAX)
|
|
: false;
|
|
}) && nativeButtonCount <= SLACK_ACTION_BLOCK_ELEMENTS_MAX;
|
|
if (!allButtonsRenderable) {
|
|
return false;
|
|
}
|
|
continue;
|
|
}
|
|
if (block.type === "select") {
|
|
const placeholder = normalizeOptionalString(block.placeholder) ?? "Choose an option";
|
|
const allOptionsRenderable =
|
|
isWithinSlackLimit(placeholder, SLACK_ACTION_LABEL_MAX) &&
|
|
block.options.length <= SLACK_STATIC_SELECT_OPTIONS_MAX &&
|
|
(!block.placeholder || isWithinSlackLimit(block.placeholder, SLACK_ACTION_LABEL_MAX)) &&
|
|
block.options.every((option) => {
|
|
if (!isWithinSlackLimit(option.label, SLACK_ACTION_LABEL_MAX)) {
|
|
return false;
|
|
}
|
|
const target = resolveSlackOptionTarget(option);
|
|
return target ? isRenderableSlackOption({ label: option.label, ...target }) : false;
|
|
}) &&
|
|
new Set(block.options.map((option) => resolveSlackOptionTarget(option)?.kind)).size === 1;
|
|
if (!allOptionsRenderable) {
|
|
return false;
|
|
}
|
|
continue;
|
|
}
|
|
if (block.type === "chart") {
|
|
if (
|
|
dataVisualizationCount >= SLACK_DATA_VISUALIZATION_BLOCKS_MAX ||
|
|
!canRenderSlackDataVisualization(block)
|
|
) {
|
|
return false;
|
|
}
|
|
dataVisualizationCount += 1;
|
|
continue;
|
|
}
|
|
if (block.type === "table") {
|
|
continue;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function buildSlackPresentationSelectBlock(
|
|
block: MessagePresentationSelectBlock,
|
|
selectIndex: number,
|
|
): SlackBlock | undefined {
|
|
const options = 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(options.map((option) => option.kind));
|
|
return options.length > 0 && optionKinds.size === 1
|
|
? {
|
|
type: "actions",
|
|
block_id: `openclaw_reply_select_${selectIndex}`,
|
|
elements: [
|
|
{
|
|
type: "static_select",
|
|
action_id:
|
|
options[0]?.kind === "approval"
|
|
? buildSlackApprovalSelectActionId(selectIndex)
|
|
: options[0]?.kind === "callback"
|
|
? buildSlackCallbackSelectActionId(selectIndex)
|
|
: buildSlackReplySelectActionId(selectIndex),
|
|
placeholder: {
|
|
type: "plain_text",
|
|
text: truncateSlackText(
|
|
normalizeOptionalString(block.placeholder) ?? "Choose an option",
|
|
SLACK_ACTION_LABEL_MAX,
|
|
),
|
|
emoji: true,
|
|
},
|
|
options: options.map((option) => ({
|
|
text: {
|
|
type: "plain_text",
|
|
text: truncateSlackText(option.label, SLACK_ACTION_LABEL_MAX),
|
|
emoji: true,
|
|
},
|
|
value: option.value,
|
|
})),
|
|
},
|
|
],
|
|
}
|
|
: undefined;
|
|
}
|