mirror of https://github.com/openclaw/openclaw.git
refactor: split slack block action handling
This commit is contained in:
parent
2580b81bd2
commit
9cd9c7a488
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue