Slack: redact and cap interaction system events (#28982)

This commit is contained in:
Jin Kim 2026-03-01 09:24:43 -08:00 committed by GitHub
parent e0571399ac
commit 746688ddc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 219 additions and 11 deletions

View File

@ -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<string, Record<string, unknown>> = {};
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;

View File

@ -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>,
) => 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<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 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,
});