From 9cd9c7a4884177ecfbd4e040e2487701a2d1bb66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:21:52 +0000 Subject: [PATCH] refactor: split slack block action handling --- .../events/interactions.block-actions.ts | 773 ++++++++++++++++++ .../slack/src/monitor/events/interactions.ts | 607 +------------- 2 files changed, 781 insertions(+), 599 deletions(-) create mode 100644 extensions/slack/src/monitor/events/interactions.block-actions.ts diff --git a/extensions/slack/src/monitor/events/interactions.block-actions.ts b/extensions/slack/src/monitor/events/interactions.block-actions.ts new file mode 100644 index 00000000000..1f54df45a5d --- /dev/null +++ b/extensions/slack/src/monitor/events/interactions.block-actions.ts @@ -0,0 +1,773 @@ +import type { SlackActionMiddlewareArgs } from "@slack/bolt"; +import type { Block, KnownBlock } from "@slack/web-api"; +import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; +import { + buildPluginBindingResolvedText, + parsePluginBindingApprovalCustomId, + resolvePluginConversationBindingApproval, +} from "../../../../../src/plugins/conversation-binding.js"; +import { dispatchPluginInteractiveHandler } from "../../../../../src/plugins/interactive.js"; +import { SLACK_REPLY_BUTTON_ACTION_ID, SLACK_REPLY_SELECT_ACTION_ID } from "../../blocks-render.js"; +import { authorizeSlackSystemEventSender } from "../auth.js"; +import type { SlackMonitorContext } from "../context.js"; +import { escapeSlackMrkdwn } from "../mrkdwn.js"; + +type InteractionMessageBlock = { + type?: string; + block_id?: string; + elements?: Array<{ action_id?: string }>; +}; + +type SelectOption = { + value?: string; + text?: { text?: string }; +}; + +type InteractionSelectionFields = { + blockId?: string; + callbackId?: string; + value?: string; + inputKind?: "number" | "text" | "url" | "email" | "rich_text"; + inputValue?: string; + inputNumber?: number; + inputEmail?: string; + inputUrl?: string; + richTextValue?: unknown; + richTextPreview?: string; + selectedValues?: string[]; + selectedUsers?: string[]; + selectedChannels?: string[]; + selectedConversations?: string[]; + selectedLabels?: string[]; + selectedDate?: string; + selectedTime?: string; + selectedDateTime?: number; + actionType?: string; + viewId?: string; + privateMetadata?: string; + viewHash?: string; + inputs?: unknown[]; + isCleared?: boolean; + routedChannelType?: string; + routedChannelId?: string; +}; + +export type InteractionSummary = InteractionSelectionFields & { + interactionType?: "block_action" | "view_submission" | "view_closed"; + actionId: string; + userId?: string; + teamId?: string; + triggerId?: string; + responseUrl?: string; + workflowTriggerUrl?: string; + workflowId?: string; + channelId?: string; + messageTs?: string; + threadTs?: string; +}; + +type SlackActionSummary = Omit; + +type SlackBlockActionBody = { + user?: { id?: string }; + team?: { id?: string }; + trigger_id?: string; + response_url?: string; + channel?: { id?: string }; + container?: { channel_id?: string; message_ts?: string; thread_ts?: string }; + message?: { ts?: string; text?: string; blocks?: unknown[] }; +}; + +type SlackBlockActionRespond = NonNullable; + +type ParsedSlackBlockAction = { + typedBody: SlackBlockActionBody; + typedAction: Record; + typedActionWithText: { + action_id?: string; + block_id?: string; + type?: string; + text?: { text?: string }; + }; + actionId: string; + blockId?: string; + userId: string; + channelId?: string; + messageTs?: string; + threadTs?: string; + actionSummary: SlackActionSummary; +}; + +function readOptionValues(options: unknown): string[] | undefined { + if (!Array.isArray(options)) { + return undefined; + } + const values = options + .map((option) => (option && typeof option === "object" ? (option as SelectOption).value : null)) + .filter((value): value is string => typeof value === "string" && value.trim().length > 0); + return values.length > 0 ? values : undefined; +} + +function readOptionLabels(options: unknown): string[] | undefined { + if (!Array.isArray(options)) { + return undefined; + } + const labels = options + .map((option) => + option && typeof option === "object" ? ((option as SelectOption).text?.text ?? null) : null, + ) + .filter((label): label is string => typeof label === "string" && label.trim().length > 0); + return labels.length > 0 ? labels : undefined; +} + +function uniqueNonEmptyStrings(values: string[]): string[] { + const unique: string[] = []; + const seen = new Set(); + for (const entry of values) { + if (typeof entry !== "string") { + continue; + } + const trimmed = entry.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + unique.push(trimmed); + } + return unique; +} + +function collectRichTextFragments(value: unknown, out: string[]): void { + if (!value || typeof value !== "object") { + return; + } + const typed = value as { text?: unknown; elements?: unknown }; + if (typeof typed.text === "string" && typed.text.trim().length > 0) { + out.push(typed.text.trim()); + } + if (Array.isArray(typed.elements)) { + for (const child of typed.elements) { + collectRichTextFragments(child, out); + } + } +} + +function summarizeRichTextPreview(value: unknown): string | undefined { + const fragments: string[] = []; + collectRichTextFragments(value, fragments); + if (fragments.length === 0) { + return undefined; + } + const joined = fragments.join(" ").replace(/\s+/g, " ").trim(); + if (!joined) { + return undefined; + } + const max = 120; + return joined.length <= max ? joined : `${joined.slice(0, max - 1)}…`; +} + +function readInteractionAction(raw: unknown) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + return raw as Record; +} + +export function summarizeAction(action: Record): SlackActionSummary { + const typed = action as { + type?: string; + selected_option?: SelectOption; + selected_options?: SelectOption[]; + selected_user?: string; + selected_users?: string[]; + selected_channel?: string; + selected_channels?: string[]; + selected_conversation?: string; + selected_conversations?: string[]; + selected_date?: string; + selected_time?: string; + selected_date_time?: number; + value?: string; + rich_text_value?: unknown; + workflow?: { + trigger_url?: string; + workflow_id?: string; + }; + }; + const actionType = typed.type; + const selectedUsers = uniqueNonEmptyStrings([ + ...(typed.selected_user ? [typed.selected_user] : []), + ...(Array.isArray(typed.selected_users) ? typed.selected_users : []), + ]); + const selectedChannels = uniqueNonEmptyStrings([ + ...(typed.selected_channel ? [typed.selected_channel] : []), + ...(Array.isArray(typed.selected_channels) ? typed.selected_channels : []), + ]); + const selectedConversations = uniqueNonEmptyStrings([ + ...(typed.selected_conversation ? [typed.selected_conversation] : []), + ...(Array.isArray(typed.selected_conversations) ? typed.selected_conversations : []), + ]); + const selectedValues = uniqueNonEmptyStrings([ + ...(typed.selected_option?.value ? [typed.selected_option.value] : []), + ...(readOptionValues(typed.selected_options) ?? []), + ...selectedUsers, + ...selectedChannels, + ...selectedConversations, + ]); + const selectedLabels = uniqueNonEmptyStrings([ + ...(typed.selected_option?.text?.text ? [typed.selected_option.text.text] : []), + ...(readOptionLabels(typed.selected_options) ?? []), + ]); + const inputValue = typeof typed.value === "string" ? typed.value : undefined; + const inputNumber = + actionType === "number_input" && inputValue != null ? Number.parseFloat(inputValue) : undefined; + const parsedNumber = Number.isFinite(inputNumber) ? inputNumber : undefined; + const inputEmail = + actionType === "email_text_input" && inputValue?.includes("@") ? inputValue : undefined; + let inputUrl: string | undefined; + if (actionType === "url_text_input" && inputValue) { + try { + inputUrl = new URL(inputValue).toString(); + } catch { + inputUrl = undefined; + } + } + const richTextValue = actionType === "rich_text_input" ? typed.rich_text_value : undefined; + const richTextPreview = summarizeRichTextPreview(richTextValue); + const inputKind = + actionType === "number_input" + ? "number" + : actionType === "email_text_input" + ? "email" + : actionType === "url_text_input" + ? "url" + : actionType === "rich_text_input" + ? "rich_text" + : inputValue != null + ? "text" + : undefined; + + return { + actionType, + inputKind, + value: typed.value, + selectedValues: selectedValues.length > 0 ? selectedValues : undefined, + selectedUsers: selectedUsers.length > 0 ? selectedUsers : undefined, + selectedChannels: selectedChannels.length > 0 ? selectedChannels : undefined, + selectedConversations: selectedConversations.length > 0 ? selectedConversations : undefined, + selectedLabels: selectedLabels.length > 0 ? selectedLabels : undefined, + selectedDate: typed.selected_date, + selectedTime: typed.selected_time, + selectedDateTime: + typeof typed.selected_date_time === "number" ? typed.selected_date_time : undefined, + inputValue, + inputNumber: parsedNumber, + inputEmail, + inputUrl, + richTextValue, + richTextPreview, + workflowTriggerUrl: typed.workflow?.trigger_url, + workflowId: typed.workflow?.workflow_id, + }; +} + +function isBulkActionsBlock(block: InteractionMessageBlock): boolean { + return ( + block.type === "actions" && + Array.isArray(block.elements) && + block.elements.length > 0 && + block.elements.every((el) => typeof el.action_id === "string" && el.action_id.includes("_all_")) + ); +} + +function formatInteractionSelectionLabel(params: { + actionId: string; + summary: SlackActionSummary; + buttonText?: string; +}): string { + if (params.summary.actionType === "button" && params.buttonText?.trim()) { + return params.buttonText.trim(); + } + if (params.summary.selectedLabels?.length) { + if (params.summary.selectedLabels.length <= 3) { + return params.summary.selectedLabels.join(", "); + } + return `${params.summary.selectedLabels.slice(0, 3).join(", ")} +${ + params.summary.selectedLabels.length - 3 + }`; + } + if (params.summary.selectedValues?.length) { + if (params.summary.selectedValues.length <= 3) { + return params.summary.selectedValues.join(", "); + } + return `${params.summary.selectedValues.slice(0, 3).join(", ")} +${ + params.summary.selectedValues.length - 3 + }`; + } + if (params.summary.selectedDate) { + return params.summary.selectedDate; + } + if (params.summary.selectedTime) { + return params.summary.selectedTime; + } + if (typeof params.summary.selectedDateTime === "number") { + return new Date(params.summary.selectedDateTime * 1000).toISOString(); + } + if (params.summary.richTextPreview) { + return params.summary.richTextPreview; + } + if (params.summary.value?.trim()) { + return params.summary.value.trim(); + } + return params.actionId; +} + +function formatInteractionConfirmationText(params: { + selectedLabel: string; + userId?: string; +}): string { + const actor = params.userId?.trim() ? ` by <@${params.userId.trim()}>` : ""; + return `:white_check_mark: *${escapeSlackMrkdwn(params.selectedLabel)}* selected${actor}`; +} + +function buildSlackPluginInteractionData(params: { + actionId: string; + summary: SlackActionSummary; +}): string | null { + const actionId = params.actionId.trim(); + if (!actionId) { + return null; + } + const payload = + params.summary.value?.trim() || + params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) || + ""; + if (actionId === SLACK_REPLY_BUTTON_ACTION_ID || actionId === SLACK_REPLY_SELECT_ACTION_ID) { + return payload || null; + } + return payload ? `${actionId}:${payload}` : actionId; +} + +function buildSlackPluginInteractionId(params: { + userId?: string; + channelId?: string; + messageTs?: string; + triggerId?: string; + actionId: string; + summary: SlackActionSummary; +}): string { + const primaryValue = + params.summary.value?.trim() || + params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) || + ""; + return [ + params.userId?.trim() || "", + params.channelId?.trim() || "", + params.messageTs?.trim() || "", + params.triggerId?.trim() || "", + params.actionId.trim(), + primaryValue, + ].join(":"); +} + +function parseSlackBlockAction(params: { + body: unknown; + action: unknown; + log?: (message: string) => void; +}): ParsedSlackBlockAction | null { + const typedBody = params.body as SlackBlockActionBody; + const typedAction = readInteractionAction(params.action); + if (!typedAction) { + params.log?.( + `slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${ + typedBody.user?.id ?? "unknown" + }`, + ); + return null; + } + const typedActionWithText = typedAction as { + action_id?: string; + block_id?: string; + type?: string; + text?: { text?: string }; + }; + return { + typedBody, + typedAction, + typedActionWithText, + actionId: + typeof typedActionWithText.action_id === "string" ? typedActionWithText.action_id : "unknown", + blockId: typedActionWithText.block_id, + userId: typedBody.user?.id ?? "unknown", + channelId: typedBody.channel?.id ?? typedBody.container?.channel_id, + messageTs: typedBody.message?.ts ?? typedBody.container?.message_ts, + threadTs: typedBody.container?.thread_ts, + actionSummary: summarizeAction(typedAction), + }; +} + +async function respondEphemeral( + respond: SlackBlockActionRespond | undefined, + text: string, +): Promise { + if (!respond) { + return; + } + try { + await respond({ + text, + response_type: "ephemeral", + }); + } catch { + // Best-effort feedback only. + } +} + +async function updateSlackInteractionMessage(params: { + ctx: SlackMonitorContext; + channelId?: string; + messageTs?: string; + text: string; + blocks?: (Block | KnownBlock)[]; +}): Promise { + if (!params.channelId || !params.messageTs) { + return; + } + await params.ctx.app.client.chat.update({ + channel: params.channelId, + ts: params.messageTs, + text: params.text, + ...(params.blocks ? { blocks: params.blocks } : {}), + }); +} + +async function authorizeSlackBlockAction(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + respond?: SlackBlockActionRespond; +}): Promise< + | { + allowed: true; + channelType?: "im" | "mpim" | "channel" | "group"; + } + | { allowed: false } +> { + const auth = await authorizeSlackSystemEventSender({ + ctx: params.ctx, + senderId: params.parsed.userId, + channelId: params.parsed.channelId, + }); + if (auth.allowed) { + return auth; + } + params.ctx.runtime.log?.( + `slack:interaction drop action=${params.parsed.actionId} user=${params.parsed.userId} channel=${params.parsed.channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, + ); + await respondEphemeral(params.respond, "You are not authorized to use this control."); + return { allowed: false }; +} + +async function handleSlackPluginBindingApproval(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + pluginInteractionData: string; + respond?: SlackBlockActionRespond; +}): Promise { + const pluginBindingApproval = parsePluginBindingApprovalCustomId(params.pluginInteractionData); + if (!pluginBindingApproval) { + return false; + } + const resolved = await resolvePluginConversationBindingApproval({ + approvalId: pluginBindingApproval.approvalId, + decision: pluginBindingApproval.decision, + senderId: params.parsed.userId, + }); + try { + await updateSlackInteractionMessage({ + ctx: params.ctx, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + text: params.parsed.typedBody.message?.text ?? "", + blocks: [], + }); + } catch { + // Best-effort cleanup only; continue with follow-up feedback. + } + await respondEphemeral(params.respond, buildPluginBindingResolvedText(resolved)); + return true; +} + +async function dispatchSlackPluginInteraction(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + pluginInteractionData: string; + auth: { isAuthorizedSender: boolean }; + respond?: SlackBlockActionRespond; +}): Promise { + const pluginInteractionId = buildSlackPluginInteractionId({ + userId: params.parsed.userId, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + triggerId: params.parsed.typedBody.trigger_id, + actionId: params.parsed.actionId, + summary: params.parsed.actionSummary, + }); + if ( + await handleSlackPluginBindingApproval({ + ctx: params.ctx, + parsed: params.parsed, + pluginInteractionData: params.pluginInteractionData, + respond: params.respond, + }) + ) { + return true; + } + const pluginResult = await dispatchPluginInteractiveHandler({ + channel: "slack", + data: params.pluginInteractionData, + interactionId: pluginInteractionId, + ctx: { + accountId: params.ctx.accountId, + interactionId: pluginInteractionId, + conversationId: params.parsed.channelId ?? "", + parentConversationId: undefined, + threadId: params.parsed.threadTs, + senderId: params.parsed.userId, + senderUsername: undefined, + auth: params.auth, + interaction: { + kind: params.parsed.actionSummary.actionType === "button" ? "button" : "select", + actionId: params.parsed.actionId, + blockId: params.parsed.blockId, + messageTs: params.parsed.messageTs, + threadTs: params.parsed.threadTs, + value: params.parsed.actionSummary.value, + selectedValues: params.parsed.actionSummary.selectedValues, + selectedLabels: params.parsed.actionSummary.selectedLabels, + triggerId: params.parsed.typedBody.trigger_id, + responseUrl: params.parsed.typedBody.response_url, + }, + }, + respond: { + acknowledge: async () => {}, + reply: async ({ text, responseType }) => { + if (!text) { + return; + } + await params.respond?.({ + text, + response_type: responseType ?? "ephemeral", + }); + }, + followUp: async ({ text, responseType }) => { + if (!text) { + return; + } + await params.respond?.({ + text, + response_type: responseType ?? "ephemeral", + }); + }, + editMessage: async ({ text, blocks }) => { + await updateSlackInteractionMessage({ + ctx: params.ctx, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + text: text ?? params.parsed.typedBody.message?.text ?? "", + blocks: Array.isArray(blocks) ? (blocks as (Block | KnownBlock)[]) : undefined, + }); + }, + }, + }); + return pluginResult.matched && pluginResult.handled; +} + +function enqueueSlackBlockActionEvent(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + auth: { channelType?: "im" | "mpim" | "channel" | "group" }; + formatSystemEvent: (payload: Record) => string; +}): void { + const eventPayload: InteractionSummary = { + interactionType: "block_action", + actionId: params.parsed.actionId, + blockId: params.parsed.blockId, + ...params.parsed.actionSummary, + userId: params.parsed.userId, + teamId: params.parsed.typedBody.team?.id, + triggerId: params.parsed.typedBody.trigger_id, + responseUrl: params.parsed.typedBody.response_url, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + threadTs: params.parsed.threadTs, + }; + params.ctx.runtime.log?.( + `slack:interaction action=${params.parsed.actionId} type=${params.parsed.actionSummary.actionType ?? "unknown"} user=${params.parsed.userId} channel=${params.parsed.channelId}`, + ); + const sessionKey = params.ctx.resolveSlackSystemEventSessionKey({ + channelId: params.parsed.channelId, + channelType: params.auth.channelType, + senderId: params.parsed.userId, + }); + const contextParts = [ + "slack:interaction", + params.parsed.channelId, + params.parsed.messageTs, + params.parsed.actionId, + ].filter(Boolean); + enqueueSystemEvent(params.formatSystemEvent(eventPayload), { + sessionKey, + contextKey: contextParts.join(":"), + }); +} + +function buildSlackConfirmationBlocks(params: { + parsed: ParsedSlackBlockAction; + originalBlocks: unknown[]; +}): (Block | KnownBlock)[] { + const selectedLabel = formatInteractionSelectionLabel({ + actionId: params.parsed.actionId, + summary: params.parsed.actionSummary, + buttonText: params.parsed.typedActionWithText.text?.text, + }); + let updatedBlocks = params.originalBlocks.map((block) => { + const typedBlock = block as InteractionMessageBlock; + if (typedBlock.type === "actions" && typedBlock.block_id === params.parsed.blockId) { + return { + type: "context", + elements: [ + { + type: "mrkdwn", + text: formatInteractionConfirmationText({ + selectedLabel, + userId: params.parsed.userId, + }), + }, + ], + }; + } + return block; + }); + const hasRemainingIndividualActionRows = updatedBlocks.some((block) => { + const typedBlock = block as InteractionMessageBlock; + return typedBlock.type === "actions" && !isBulkActionsBlock(typedBlock); + }); + if (!hasRemainingIndividualActionRows) { + updatedBlocks = updatedBlocks.filter((block, index) => { + const typedBlock = block as InteractionMessageBlock; + if (isBulkActionsBlock(typedBlock)) { + return false; + } + if (typedBlock.type !== "divider") { + return true; + } + const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined; + return !next || !isBulkActionsBlock(next); + }); + } + return updatedBlocks as (Block | KnownBlock)[]; +} + +async function updateSlackLegacyBlockAction(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + respond?: SlackBlockActionRespond; +}): Promise { + const originalBlocks = params.parsed.typedBody.message?.blocks; + if ( + !Array.isArray(originalBlocks) || + !params.parsed.channelId || + !params.parsed.messageTs || + !params.parsed.blockId + ) { + return; + } + try { + await updateSlackInteractionMessage({ + ctx: params.ctx, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + text: params.parsed.typedBody.message?.text ?? "", + blocks: buildSlackConfirmationBlocks({ + parsed: params.parsed, + originalBlocks, + }), + }); + } catch { + await respondEphemeral(params.respond, `Button "${params.parsed.actionId}" clicked!`); + } +} + +async function handleSlackBlockAction(params: { + ctx: SlackMonitorContext; + args: SlackActionMiddlewareArgs; + formatSystemEvent: (payload: Record) => string; +}): Promise { + const { ack, body, action, respond } = params.args; + await ack(); + if (params.ctx.shouldDropMismatchedSlackEvent?.(body)) { + params.ctx.runtime.log?.("slack:interaction drop block action payload (mismatched app/team)"); + return; + } + const parsed = parseSlackBlockAction({ + body, + action, + log: params.ctx.runtime.log, + }); + if (!parsed) { + return; + } + const auth = await authorizeSlackBlockAction({ + ctx: params.ctx, + parsed, + respond, + }); + if (!auth.allowed) { + return; + } + const pluginInteractionData = buildSlackPluginInteractionData({ + actionId: parsed.actionId, + summary: parsed.actionSummary, + }); + if (pluginInteractionData) { + const handled = await dispatchSlackPluginInteraction({ + ctx: params.ctx, + parsed, + pluginInteractionData, + auth: { + isAuthorizedSender: true, + }, + respond, + }); + if (handled) { + return; + } + } + enqueueSlackBlockActionEvent({ + ctx: params.ctx, + parsed, + auth, + formatSystemEvent: params.formatSystemEvent, + }); + await updateSlackLegacyBlockAction({ + ctx: params.ctx, + parsed, + respond, + }); +} + +export function registerSlackBlockActionHandler(params: { + ctx: SlackMonitorContext; + formatSystemEvent: (payload: Record) => string; +}): void { + if (typeof params.ctx.app.action !== "function") { + return; + } + params.ctx.app.action(/.+/, async (args: SlackActionMiddlewareArgs) => { + await handleSlackBlockAction({ + ctx: params.ctx, + args, + formatSystemEvent: params.formatSystemEvent, + }); + }); +} diff --git a/extensions/slack/src/monitor/events/interactions.ts b/extensions/slack/src/monitor/events/interactions.ts index 1ebb55d090e..384498ac5fe 100644 --- a/extensions/slack/src/monitor/events/interactions.ts +++ b/extensions/slack/src/monitor/events/interactions.ts @@ -1,17 +1,10 @@ -import type { SlackActionMiddlewareArgs } from "@slack/bolt"; -import type { Block, KnownBlock } from "@slack/web-api"; -import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; -import { - buildPluginBindingResolvedText, - parsePluginBindingApprovalCustomId, - resolvePluginConversationBindingApproval, -} from "../../../../../src/plugins/conversation-binding.js"; -import { dispatchPluginInteractiveHandler } from "../../../../../src/plugins/interactive.js"; -import { SLACK_REPLY_BUTTON_ACTION_ID, SLACK_REPLY_SELECT_ACTION_ID } from "../../blocks-render.js"; import { truncateSlackText } from "../../truncate.js"; -import { authorizeSlackSystemEventSender } from "../auth.js"; import type { SlackMonitorContext } from "../context.js"; -import { escapeSlackMrkdwn } from "../mrkdwn.js"; +import { + registerSlackBlockActionHandler, + summarizeAction, + type InteractionSummary, +} from "./interactions.block-actions.js"; import { registerModalLifecycleHandler, type ModalInputSummary, @@ -34,33 +27,6 @@ const SLACK_INTERACTION_REDACTED_KEYS = new Set([ "viewHash", ]); -type InteractionMessageBlock = { - type?: string; - block_id?: string; - elements?: Array<{ action_id?: string }>; -}; - -type SelectOption = { - value?: string; - text?: { text?: string }; -}; - -type InteractionSelectionFields = Partial; - -type InteractionSummary = InteractionSelectionFields & { - interactionType?: "block_action" | "view_submission" | "view_closed"; - actionId: string; - userId?: string; - teamId?: string; - triggerId?: string; - responseUrl?: string; - workflowTriggerUrl?: string; - workflowId?: string; - channelId?: string; - messageTs?: string; - threadTs?: string; -}; - function sanitizeSlackInteractionPayloadValue(value: unknown, key?: string): unknown { if (value === undefined) { return undefined; @@ -189,281 +155,6 @@ function formatSlackInteractionSystemEvent(payload: Record): st }); } -function readOptionValues(options: unknown): string[] | undefined { - if (!Array.isArray(options)) { - return undefined; - } - const values = options - .map((option) => (option && typeof option === "object" ? (option as SelectOption).value : null)) - .filter((value): value is string => typeof value === "string" && value.trim().length > 0); - return values.length > 0 ? values : undefined; -} - -function readOptionLabels(options: unknown): string[] | undefined { - if (!Array.isArray(options)) { - return undefined; - } - const labels = options - .map((option) => - option && typeof option === "object" ? ((option as SelectOption).text?.text ?? null) : null, - ) - .filter((label): label is string => typeof label === "string" && label.trim().length > 0); - return labels.length > 0 ? labels : undefined; -} - -function uniqueNonEmptyStrings(values: string[]): string[] { - const unique: string[] = []; - const seen = new Set(); - for (const entry of values) { - if (typeof entry !== "string") { - continue; - } - const trimmed = entry.trim(); - if (!trimmed || seen.has(trimmed)) { - continue; - } - seen.add(trimmed); - unique.push(trimmed); - } - return unique; -} - -function collectRichTextFragments(value: unknown, out: string[]): void { - if (!value || typeof value !== "object") { - return; - } - const typed = value as { text?: unknown; elements?: unknown }; - if (typeof typed.text === "string" && typed.text.trim().length > 0) { - out.push(typed.text.trim()); - } - if (Array.isArray(typed.elements)) { - for (const child of typed.elements) { - collectRichTextFragments(child, out); - } - } -} - -function summarizeRichTextPreview(value: unknown): string | undefined { - const fragments: string[] = []; - collectRichTextFragments(value, fragments); - if (fragments.length === 0) { - return undefined; - } - const joined = fragments.join(" ").replace(/\s+/g, " ").trim(); - if (!joined) { - return undefined; - } - const max = 120; - return joined.length <= max ? joined : `${joined.slice(0, max - 1)}…`; -} - -function readInteractionAction(raw: unknown) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return undefined; - } - return raw as Record; -} - -function summarizeAction( - action: Record, -): Omit { - const typed = action as { - type?: string; - selected_option?: SelectOption; - selected_options?: SelectOption[]; - selected_user?: string; - selected_users?: string[]; - selected_channel?: string; - selected_channels?: string[]; - selected_conversation?: string; - selected_conversations?: string[]; - selected_date?: string; - selected_time?: string; - selected_date_time?: number; - value?: string; - rich_text_value?: unknown; - workflow?: { - trigger_url?: string; - workflow_id?: string; - }; - }; - const actionType = typed.type; - const selectedUsers = uniqueNonEmptyStrings([ - ...(typed.selected_user ? [typed.selected_user] : []), - ...(Array.isArray(typed.selected_users) ? typed.selected_users : []), - ]); - const selectedChannels = uniqueNonEmptyStrings([ - ...(typed.selected_channel ? [typed.selected_channel] : []), - ...(Array.isArray(typed.selected_channels) ? typed.selected_channels : []), - ]); - const selectedConversations = uniqueNonEmptyStrings([ - ...(typed.selected_conversation ? [typed.selected_conversation] : []), - ...(Array.isArray(typed.selected_conversations) ? typed.selected_conversations : []), - ]); - const selectedValues = uniqueNonEmptyStrings([ - ...(typed.selected_option?.value ? [typed.selected_option.value] : []), - ...(readOptionValues(typed.selected_options) ?? []), - ...selectedUsers, - ...selectedChannels, - ...selectedConversations, - ]); - const selectedLabels = uniqueNonEmptyStrings([ - ...(typed.selected_option?.text?.text ? [typed.selected_option.text.text] : []), - ...(readOptionLabels(typed.selected_options) ?? []), - ]); - const inputValue = typeof typed.value === "string" ? typed.value : undefined; - const inputNumber = - actionType === "number_input" && inputValue != null ? Number.parseFloat(inputValue) : undefined; - const parsedNumber = Number.isFinite(inputNumber) ? inputNumber : undefined; - const inputEmail = - actionType === "email_text_input" && inputValue?.includes("@") ? inputValue : undefined; - let inputUrl: string | undefined; - if (actionType === "url_text_input" && inputValue) { - try { - // Normalize to a canonical URL string so downstream handlers do not need to reparse. - inputUrl = new URL(inputValue).toString(); - } catch { - inputUrl = undefined; - } - } - const richTextValue = actionType === "rich_text_input" ? typed.rich_text_value : undefined; - const richTextPreview = summarizeRichTextPreview(richTextValue); - const inputKind = - actionType === "number_input" - ? "number" - : actionType === "email_text_input" - ? "email" - : actionType === "url_text_input" - ? "url" - : actionType === "rich_text_input" - ? "rich_text" - : inputValue != null - ? "text" - : undefined; - - return { - actionType, - inputKind, - value: typed.value, - selectedValues: selectedValues.length > 0 ? selectedValues : undefined, - selectedUsers: selectedUsers.length > 0 ? selectedUsers : undefined, - selectedChannels: selectedChannels.length > 0 ? selectedChannels : undefined, - selectedConversations: selectedConversations.length > 0 ? selectedConversations : undefined, - selectedLabels: selectedLabels.length > 0 ? selectedLabels : undefined, - selectedDate: typed.selected_date, - selectedTime: typed.selected_time, - selectedDateTime: - typeof typed.selected_date_time === "number" ? typed.selected_date_time : undefined, - inputValue, - inputNumber: parsedNumber, - inputEmail, - inputUrl, - richTextValue, - richTextPreview, - workflowTriggerUrl: typed.workflow?.trigger_url, - workflowId: typed.workflow?.workflow_id, - }; -} - -function isBulkActionsBlock(block: InteractionMessageBlock): boolean { - return ( - block.type === "actions" && - Array.isArray(block.elements) && - block.elements.length > 0 && - block.elements.every((el) => typeof el.action_id === "string" && el.action_id.includes("_all_")) - ); -} - -function formatInteractionSelectionLabel(params: { - actionId: string; - summary: Omit; - buttonText?: string; -}): string { - if (params.summary.actionType === "button" && params.buttonText?.trim()) { - return params.buttonText.trim(); - } - if (params.summary.selectedLabels?.length) { - if (params.summary.selectedLabels.length <= 3) { - return params.summary.selectedLabels.join(", "); - } - return `${params.summary.selectedLabels.slice(0, 3).join(", ")} +${ - params.summary.selectedLabels.length - 3 - }`; - } - if (params.summary.selectedValues?.length) { - if (params.summary.selectedValues.length <= 3) { - return params.summary.selectedValues.join(", "); - } - return `${params.summary.selectedValues.slice(0, 3).join(", ")} +${ - params.summary.selectedValues.length - 3 - }`; - } - if (params.summary.selectedDate) { - return params.summary.selectedDate; - } - if (params.summary.selectedTime) { - return params.summary.selectedTime; - } - if (typeof params.summary.selectedDateTime === "number") { - return new Date(params.summary.selectedDateTime * 1000).toISOString(); - } - if (params.summary.richTextPreview) { - return params.summary.richTextPreview; - } - if (params.summary.value?.trim()) { - return params.summary.value.trim(); - } - return params.actionId; -} - -function formatInteractionConfirmationText(params: { - selectedLabel: string; - userId?: string; -}): string { - const actor = params.userId?.trim() ? ` by <@${params.userId.trim()}>` : ""; - return `:white_check_mark: *${escapeSlackMrkdwn(params.selectedLabel)}* selected${actor}`; -} - -function buildSlackPluginInteractionData(params: { - actionId: string; - summary: Omit; -}): string | null { - const actionId = params.actionId.trim(); - if (!actionId) { - return null; - } - const payload = - params.summary.value?.trim() || - params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) || - ""; - if (actionId === SLACK_REPLY_BUTTON_ACTION_ID || actionId === SLACK_REPLY_SELECT_ACTION_ID) { - return payload || null; - } - return payload ? `${actionId}:${payload}` : actionId; -} - -function buildSlackPluginInteractionId(params: { - userId?: string; - channelId?: string; - messageTs?: string; - triggerId?: string; - actionId: string; - summary: Omit; -}): string { - const primaryValue = - params.summary.value?.trim() || - params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) || - ""; - return [ - params.userId?.trim() || "", - params.channelId?.trim() || "", - params.messageTs?.trim() || "", - params.triggerId?.trim() || "", - params.actionId.trim(), - primaryValue, - ].join(":"); -} - function summarizeViewState(values: unknown): ModalInputSummary[] { if (!values || typeof values !== "object") { return []; @@ -490,291 +181,9 @@ function summarizeViewState(values: unknown): ModalInputSummary[] { export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContext }) { const { ctx } = params; - if (typeof ctx.app.action !== "function") { - return; - } - - // Handle Block Kit actions for this Slack app, including legacy/custom - // action_ids that plugin handlers map into shared interactive namespaces. - ctx.app.action(/.+/, async (args: SlackActionMiddlewareArgs) => { - const { ack, body, action, respond } = args; - const typedBody = body as unknown as { - user?: { id?: string }; - team?: { id?: string }; - trigger_id?: string; - response_url?: string; - channel?: { id?: string }; - container?: { channel_id?: string; message_ts?: string; thread_ts?: string }; - message?: { ts?: string; text?: string; blocks?: unknown[] }; - }; - - // Acknowledge the action immediately to prevent the warning icon - await ack(); - if (ctx.shouldDropMismatchedSlackEvent?.(body)) { - ctx.runtime.log?.("slack:interaction drop block action payload (mismatched app/team)"); - return; - } - - // Extract action details using proper Bolt types - const typedAction = readInteractionAction(action); - if (!typedAction) { - ctx.runtime.log?.( - `slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${ - typedBody.user?.id ?? "unknown" - }`, - ); - return; - } - const typedActionWithText = typedAction as { - action_id?: string; - block_id?: string; - type?: string; - text?: { text?: string }; - }; - const actionId = - typeof typedActionWithText.action_id === "string" ? typedActionWithText.action_id : "unknown"; - const blockId = typedActionWithText.block_id; - const userId = typedBody.user?.id ?? "unknown"; - const channelId = typedBody.channel?.id ?? typedBody.container?.channel_id; - const messageTs = typedBody.message?.ts ?? typedBody.container?.message_ts; - const threadTs = typedBody.container?.thread_ts; - const auth = await authorizeSlackSystemEventSender({ - ctx, - senderId: userId, - channelId, - }); - if (!auth.allowed) { - ctx.runtime.log?.( - `slack:interaction drop action=${actionId} user=${userId} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, - ); - if (respond) { - try { - await respond({ - text: "You are not authorized to use this control.", - response_type: "ephemeral", - }); - } catch { - // Best-effort feedback only. - } - } - return; - } - const actionSummary = summarizeAction(typedAction); - const pluginInteractionData = buildSlackPluginInteractionData({ - actionId, - summary: actionSummary, - }); - if (pluginInteractionData) { - const pluginInteractionId = buildSlackPluginInteractionId({ - userId, - channelId, - messageTs, - triggerId: typedBody.trigger_id, - actionId, - summary: actionSummary, - }); - const pluginBindingApproval = parsePluginBindingApprovalCustomId(pluginInteractionData); - if (pluginBindingApproval) { - const resolved = await resolvePluginConversationBindingApproval({ - approvalId: pluginBindingApproval.approvalId, - decision: pluginBindingApproval.decision, - senderId: userId, - }); - if (channelId && messageTs) { - try { - await ctx.app.client.chat.update({ - channel: channelId, - ts: messageTs, - text: typedBody.message?.text ?? "", - blocks: [], - }); - } catch { - // Best-effort cleanup only; continue with follow-up feedback. - } - } - if (respond) { - try { - await respond({ - text: buildPluginBindingResolvedText(resolved), - response_type: "ephemeral", - }); - } catch { - // Best-effort feedback only. - } - } - return; - } - const pluginResult = await dispatchPluginInteractiveHandler({ - channel: "slack", - data: pluginInteractionData, - interactionId: pluginInteractionId, - ctx: { - accountId: ctx.accountId, - interactionId: pluginInteractionId, - conversationId: channelId ?? "", - parentConversationId: undefined, - threadId: threadTs, - senderId: userId, - senderUsername: undefined, - auth: { - isAuthorizedSender: auth.allowed, - }, - interaction: { - kind: actionSummary.actionType === "button" ? "button" : "select", - actionId, - blockId, - messageTs, - threadTs, - value: actionSummary.value, - selectedValues: actionSummary.selectedValues, - selectedLabels: actionSummary.selectedLabels, - triggerId: typedBody.trigger_id, - responseUrl: typedBody.response_url, - }, - }, - respond: { - acknowledge: async () => {}, - reply: async ({ text, responseType }) => { - if (!respond) { - return; - } - await respond({ - text, - response_type: responseType ?? "ephemeral", - }); - }, - followUp: async ({ text, responseType }) => { - if (!respond) { - return; - } - await respond({ - text, - response_type: responseType ?? "ephemeral", - }); - }, - editMessage: async ({ text, blocks }) => { - if (!channelId || !messageTs) { - return; - } - await ctx.app.client.chat.update({ - channel: channelId, - ts: messageTs, - text: text ?? typedBody.message?.text ?? "", - ...(Array.isArray(blocks) ? { blocks: blocks as (Block | KnownBlock)[] } : {}), - }); - }, - }, - }); - if (pluginResult.matched && pluginResult.handled) { - return; - } - } - const eventPayload: InteractionSummary = { - interactionType: "block_action", - actionId, - blockId, - ...actionSummary, - userId, - teamId: typedBody.team?.id, - triggerId: typedBody.trigger_id, - responseUrl: typedBody.response_url, - channelId, - messageTs, - threadTs, - }; - - // Log the interaction for debugging - ctx.runtime.log?.( - `slack:interaction action=${actionId} type=${actionSummary.actionType ?? "unknown"} user=${userId} channel=${channelId}`, - ); - - // Send a system event to notify the agent about the button click - // Pass undefined (not "unknown") to allow proper main session fallback - const sessionKey = ctx.resolveSlackSystemEventSessionKey({ - channelId: channelId, - channelType: auth.channelType, - senderId: userId, - }); - - // Build context key - only include defined values to avoid "unknown" noise - const contextParts = ["slack:interaction", channelId, messageTs, actionId].filter(Boolean); - const contextKey = contextParts.join(":"); - - enqueueSystemEvent(formatSlackInteractionSystemEvent(eventPayload), { - sessionKey, - contextKey, - }); - - const originalBlocks = typedBody.message?.blocks; - if (!Array.isArray(originalBlocks) || !channelId || !messageTs) { - return; - } - - if (!blockId) { - return; - } - - const selectedLabel = formatInteractionSelectionLabel({ - actionId, - summary: actionSummary, - buttonText: typedActionWithText.text?.text, - }); - let updatedBlocks = originalBlocks.map((block) => { - const typedBlock = block as InteractionMessageBlock; - if (typedBlock.type === "actions" && typedBlock.block_id === blockId) { - return { - type: "context", - elements: [ - { - type: "mrkdwn", - text: formatInteractionConfirmationText({ selectedLabel, userId }), - }, - ], - }; - } - return block; - }); - - const hasRemainingIndividualActionRows = updatedBlocks.some((block) => { - const typedBlock = block as InteractionMessageBlock; - return typedBlock.type === "actions" && !isBulkActionsBlock(typedBlock); - }); - - if (!hasRemainingIndividualActionRows) { - updatedBlocks = updatedBlocks.filter((block, index) => { - const typedBlock = block as InteractionMessageBlock; - if (isBulkActionsBlock(typedBlock)) { - return false; - } - if (typedBlock.type !== "divider") { - return true; - } - const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined; - return !next || !isBulkActionsBlock(next); - }); - } - - try { - await ctx.app.client.chat.update({ - channel: channelId, - ts: messageTs, - text: typedBody.message?.text ?? "", - blocks: updatedBlocks as (Block | KnownBlock)[], - }); - } catch { - // If update fails, fallback to ephemeral confirmation for immediate UX feedback. - if (!respond) { - return; - } - try { - await respond({ - text: `Button "${actionId}" clicked!`, - response_type: "ephemeral", - }); - } catch { - // Action was acknowledged and system event enqueued even when response updates fail. - } - } + registerSlackBlockActionHandler({ + ctx, + formatSystemEvent: formatSlackInteractionSystemEvent, }); if (typeof ctx.app.view !== "function") {