refactor: split slack block action handling

This commit is contained in:
Peter Steinberger 2026-03-16 05:21:52 +00:00
parent 2580b81bd2
commit 9cd9c7a488
2 changed files with 781 additions and 599 deletions

View File

@ -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<InteractionSummary, "actionId" | "blockId">;
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<SlackActionMiddlewareArgs["respond"]>;
type ParsedSlackBlockAction = {
typedBody: SlackBlockActionBody;
typedAction: Record<string, unknown>;
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<string>();
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<string, unknown>;
}
export function summarizeAction(action: Record<string, unknown>): 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<void> {
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<void> {
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<boolean> {
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<boolean> {
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, unknown>) => 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<void> {
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, unknown>) => string;
}): Promise<void> {
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, unknown>) => 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,
});
});
}

View File

@ -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<ModalInputSummary>;
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<string, unknown>): 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<string>();
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<string, unknown>;
}
function summarizeAction(
action: Record<string, unknown>,
): Omit<InteractionSummary, "actionId" | "blockId"> {
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<InteractionSummary, "actionId" | "blockId">;
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<InteractionSummary, "actionId" | "blockId">;
}): 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<InteractionSummary, "actionId" | "blockId">;
}): 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") {