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