diff --git a/src/slack/monitor/events/interactions.test.ts b/src/slack/monitor/events/interactions.test.ts index 7b5f0d24f02..be47f6ac8a7 100644 --- a/src/slack/monitor/events/interactions.test.ts +++ b/src/slack/monitor/events/interactions.test.ts @@ -214,8 +214,8 @@ describe("registerSlackInteractionEvents", () => { value: "approved", userId: "U123", teamId: "T9", - triggerId: "123.trigger", - responseUrl: "https://hooks.slack.test/response", + triggerId: "[redacted]", + responseUrl: "[redacted]", channelId: "C1", messageTs: "100.200", threadTs: "100.100", @@ -885,7 +885,7 @@ describe("registerSlackInteractionEvents", () => { }; expect(payload).toMatchObject({ actionType: "workflow_button", - workflowTriggerUrl: "https://slack.com/workflows/triggers/T420/12345", + workflowTriggerUrl: "[redacted]", workflowId: "Wf12345", teamId: "T420", channelId: "C420", @@ -979,7 +979,7 @@ describe("registerSlackInteractionEvents", () => { rootViewId: "VROOT", previousViewId: "VPREV", externalId: "deploy-ext-1", - viewHash: "view-hash-1", + viewHash: "[redacted]", isStackedView: true, }); expect(payload.inputs).toEqual( @@ -1378,14 +1378,11 @@ describe("registerSlackInteractionEvents", () => { viewId: "V900", userId: "U900", isCleared: true, - privateMetadata: JSON.stringify({ - sessionKey: "agent:main:slack:channel:C99", - userId: "U900", - }), + privateMetadata: "[redacted]", rootViewId: "VROOT900", previousViewId: "VPREV900", externalId: "deploy-ext-900", - viewHash: "view-hash-900", + viewHash: "[redacted]", isStackedView: true, }); expect(payload.inputs).toEqual( @@ -1426,5 +1423,64 @@ describe("registerSlackInteractionEvents", () => { expect(payload.interactionType).toBe("view_closed"); expect(payload.isCleared).toBe(false); }); + + it("caps oversized interaction payloads with compact summaries", async () => { + enqueueSystemEventMock.mockClear(); + const { ctx, getViewHandler } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const viewHandler = getViewHandler(); + expect(viewHandler).toBeTruthy(); + + const richTextValue = { + type: "rich_text", + elements: Array.from({ length: 20 }, (_, index) => ({ + type: "rich_text_section", + elements: [{ type: "text", text: `chunk-${index}-${"x".repeat(400)}` }], + })), + }; + const values: Record> = {}; + for (let index = 0; index < 20; index += 1) { + values[`block_${index}`] = { + [`input_${index}`]: { + type: "rich_text_input", + rich_text_value: richTextValue, + }, + }; + } + + const ack = vi.fn().mockResolvedValue(undefined); + await viewHandler!({ + ack, + body: { + user: { id: "U915" }, + team: { id: "T1" }, + view: { + id: "V915", + callback_id: "openclaw:oversize", + private_metadata: JSON.stringify({ + channelId: "D915", + channelType: "im", + userId: "U915", + }), + state: { + values, + }, + }, + }, + } as never); + + expect(ack).toHaveBeenCalled(); + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string]; + expect(eventText.length).toBeLessThanOrEqual(2400); + const payload = JSON.parse(eventText.replace("Slack interaction: ", "")) as { + payloadTruncated?: boolean; + inputs?: unknown[]; + inputsOmitted?: number; + }; + expect(payload.payloadTruncated).toBe(true); + expect(Array.isArray(payload.inputs) ? payload.inputs.length : 0).toBeLessThanOrEqual(3); + expect((payload.inputsOmitted ?? 0) >= 1).toBe(true); + }); }); const selectedDateTimeEpoch = 1_771_632_300; diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts index 54a9b0c1475..5f371dae2cd 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/src/slack/monitor/events/interactions.ts @@ -8,6 +8,19 @@ import { escapeSlackMrkdwn } from "../mrkdwn.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", +]); type InteractionMessageBlock = { type?: string; @@ -107,6 +120,145 @@ type RegisterSlackModalHandler = ( handler: (args: SlackModalEventHandlerArgs) => Promise, ) => void; +function truncateInteractionString( + value: string, + max = SLACK_INTERACTION_STRING_MAX_CHARS, +): string { + const trimmed = value.trim(); + if (trimmed.length <= max) { + return trimmed; + } + return `${trimmed.slice(0, max - 1)}…`; +} + +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 truncateInteractionString(value); + } + 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 = {}; + for (const [entryKey, entryValue] of Object.entries(value as Record)) { + 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, +): Record { + 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; + 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 { + const toEventText = (value: Record): string => + `${SLACK_INTERACTION_EVENT_PREFIX}${JSON.stringify(value)}`; + + const sanitizedPayload = + (sanitizeSlackInteractionPayloadValue(payload) as Record | undefined) ?? {}; + let eventText = toEventText(sanitizedPayload); + if (eventText.length <= SLACK_INTERACTION_EVENT_MAX_CHARS) { + return eventText; + } + + const compactPayload = sanitizeSlackInteractionPayloadValue( + buildCompactSlackInteractionPayload(sanitizedPayload), + ) as Record; + 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 readOptionValues(options: unknown): string[] | undefined { if (!Array.isArray(options)) { return undefined; @@ -512,7 +664,7 @@ async function emitSlackModalLifecycleEvent(params: { return; } - enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, { + enqueueSystemEvent(formatSlackInteractionSystemEvent(eventPayload), { sessionKey: sessionRouting.sessionKey, contextKey: [params.contextPrefix, callbackId, viewId, userId].filter(Boolean).join(":"), }); @@ -649,7 +801,7 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex const contextParts = ["slack:interaction", channelId, messageTs, actionId].filter(Boolean); const contextKey = contextParts.join(":"); - enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, { + enqueueSystemEvent(formatSlackInteractionSystemEvent(eventPayload), { sessionKey, contextKey, });