2026-03-16 05:53:35 +00:00

225 lines
7.2 KiB
TypeScript

import { truncateSlackText } from "../../truncate.js";
import type { SlackMonitorContext } from "../context.js";
import {
registerSlackBlockActionHandler,
summarizeAction,
type InteractionSummary,
} from "./interactions.block-actions.js";
import {
registerModalLifecycleHandler,
type ModalInputSummary,
type RegisterSlackModalHandler,
} from "./interactions.modal.js";
// Prefix for OpenClaw-generated action IDs to scope our handler
const OPENCLAW_ACTION_PREFIX = "openclaw:";
const SLACK_INTERACTION_EVENT_PREFIX = "Slack interaction: ";
const REDACTED_INTERACTION_VALUE = "[redacted]";
const SLACK_INTERACTION_EVENT_MAX_CHARS = 2400;
const SLACK_INTERACTION_STRING_MAX_CHARS = 160;
const SLACK_INTERACTION_ARRAY_MAX_ITEMS = 64;
const SLACK_INTERACTION_COMPACT_INPUTS_MAX_ITEMS = 3;
const SLACK_INTERACTION_REDACTED_KEYS = new Set([
"triggerId",
"responseUrl",
"workflowTriggerUrl",
"privateMetadata",
"viewHash",
]);
function sanitizeSlackInteractionPayloadValue(value: unknown, key?: string): unknown {
if (value === undefined) {
return undefined;
}
if (key && SLACK_INTERACTION_REDACTED_KEYS.has(key)) {
if (typeof value !== "string" || value.trim().length === 0) {
return undefined;
}
return REDACTED_INTERACTION_VALUE;
}
if (typeof value === "string") {
return truncateSlackText(value, SLACK_INTERACTION_STRING_MAX_CHARS);
}
if (Array.isArray(value)) {
const sanitized = value
.slice(0, SLACK_INTERACTION_ARRAY_MAX_ITEMS)
.map((entry) => sanitizeSlackInteractionPayloadValue(entry))
.filter((entry) => entry !== undefined);
if (value.length > SLACK_INTERACTION_ARRAY_MAX_ITEMS) {
sanitized.push(`…+${value.length - SLACK_INTERACTION_ARRAY_MAX_ITEMS} more`);
}
return sanitized;
}
if (!value || typeof value !== "object") {
return value;
}
const output: Record<string, unknown> = {};
for (const [entryKey, entryValue] of Object.entries(value as Record<string, unknown>)) {
const sanitized = sanitizeSlackInteractionPayloadValue(entryValue, entryKey);
if (sanitized === undefined) {
continue;
}
if (typeof sanitized === "string" && sanitized.length === 0) {
continue;
}
if (Array.isArray(sanitized) && sanitized.length === 0) {
continue;
}
output[entryKey] = sanitized;
}
return output;
}
function buildCompactSlackInteractionPayload(
payload: Record<string, unknown>,
): Record<string, unknown> {
const rawInputs = Array.isArray(payload.inputs) ? payload.inputs : [];
const compactInputs = rawInputs
.slice(0, SLACK_INTERACTION_COMPACT_INPUTS_MAX_ITEMS)
.flatMap((entry) => {
if (!entry || typeof entry !== "object") {
return [];
}
const typed = entry as Record<string, unknown>;
return [
{
actionId: typed.actionId,
blockId: typed.blockId,
actionType: typed.actionType,
inputKind: typed.inputKind,
selectedValues: typed.selectedValues,
selectedLabels: typed.selectedLabels,
inputValue: typed.inputValue,
inputNumber: typed.inputNumber,
selectedDate: typed.selectedDate,
selectedTime: typed.selectedTime,
selectedDateTime: typed.selectedDateTime,
richTextPreview: typed.richTextPreview,
},
];
});
return {
interactionType: payload.interactionType,
actionId: payload.actionId,
callbackId: payload.callbackId,
actionType: payload.actionType,
userId: payload.userId,
teamId: payload.teamId,
channelId: payload.channelId ?? payload.routedChannelId,
messageTs: payload.messageTs,
threadTs: payload.threadTs,
viewId: payload.viewId,
isCleared: payload.isCleared,
selectedValues: payload.selectedValues,
selectedLabels: payload.selectedLabels,
selectedDate: payload.selectedDate,
selectedTime: payload.selectedTime,
selectedDateTime: payload.selectedDateTime,
workflowId: payload.workflowId,
routedChannelType: payload.routedChannelType,
inputs: compactInputs.length > 0 ? compactInputs : undefined,
inputsOmitted:
rawInputs.length > SLACK_INTERACTION_COMPACT_INPUTS_MAX_ITEMS
? rawInputs.length - SLACK_INTERACTION_COMPACT_INPUTS_MAX_ITEMS
: undefined,
payloadTruncated: true,
};
}
function formatSlackInteractionSystemEvent(payload: Record<string, unknown>): string {
const toEventText = (value: Record<string, unknown>): string =>
`${SLACK_INTERACTION_EVENT_PREFIX}${JSON.stringify(value)}`;
const sanitizedPayload =
(sanitizeSlackInteractionPayloadValue(payload) as Record<string, unknown> | undefined) ?? {};
let eventText = toEventText(sanitizedPayload);
if (eventText.length <= SLACK_INTERACTION_EVENT_MAX_CHARS) {
return eventText;
}
const compactPayload = sanitizeSlackInteractionPayloadValue(
buildCompactSlackInteractionPayload(sanitizedPayload),
) as Record<string, unknown>;
eventText = toEventText(compactPayload);
if (eventText.length <= SLACK_INTERACTION_EVENT_MAX_CHARS) {
return eventText;
}
return toEventText({
interactionType: sanitizedPayload.interactionType,
actionId: sanitizedPayload.actionId ?? "unknown",
userId: sanitizedPayload.userId,
channelId: sanitizedPayload.channelId ?? sanitizedPayload.routedChannelId,
payloadTruncated: true,
});
}
function summarizeViewState(values: unknown): ModalInputSummary[] {
if (!values || typeof values !== "object") {
return [];
}
const entries: ModalInputSummary[] = [];
for (const [blockId, blockValue] of Object.entries(values as Record<string, unknown>)) {
if (!blockValue || typeof blockValue !== "object") {
continue;
}
for (const [actionId, rawAction] of Object.entries(blockValue as Record<string, unknown>)) {
if (!rawAction || typeof rawAction !== "object") {
continue;
}
const actionSummary = summarizeAction(rawAction as Record<string, unknown>);
entries.push({
blockId,
actionId,
...actionSummary,
});
}
}
return entries;
}
export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContext }) {
const { ctx } = params;
registerSlackBlockActionHandler({
ctx,
formatSystemEvent: formatSlackInteractionSystemEvent,
});
if (typeof ctx.app.view !== "function") {
return;
}
const modalMatcher = new RegExp(`^${OPENCLAW_ACTION_PREFIX}`);
// Handle OpenClaw modal submissions with callback_ids scoped by our prefix.
registerModalLifecycleHandler({
register: (matcher, handler) => ctx.app.view(matcher, handler),
matcher: modalMatcher,
ctx,
interactionType: "view_submission",
contextPrefix: "slack:interaction:view",
summarizeViewState,
formatSystemEvent: formatSlackInteractionSystemEvent,
});
const viewClosed = (
ctx.app as unknown as {
viewClosed?: RegisterSlackModalHandler;
}
).viewClosed;
if (typeof viewClosed !== "function") {
return;
}
// Handle modal close events so agent workflows can react to cancelled forms.
registerModalLifecycleHandler({
register: viewClosed,
matcher: modalMatcher,
ctx,
interactionType: "view_closed",
contextPrefix: "slack:interaction:view-closed",
summarizeViewState,
formatSystemEvent: formatSlackInteractionSystemEvent,
});
}