mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 19:54:57 +00:00
Slack: redact and cap interaction system events (#28982)
This commit is contained in:
@@ -214,8 +214,8 @@ describe("registerSlackInteractionEvents", () => {
|
|||||||
value: "approved",
|
value: "approved",
|
||||||
userId: "U123",
|
userId: "U123",
|
||||||
teamId: "T9",
|
teamId: "T9",
|
||||||
triggerId: "123.trigger",
|
triggerId: "[redacted]",
|
||||||
responseUrl: "https://hooks.slack.test/response",
|
responseUrl: "[redacted]",
|
||||||
channelId: "C1",
|
channelId: "C1",
|
||||||
messageTs: "100.200",
|
messageTs: "100.200",
|
||||||
threadTs: "100.100",
|
threadTs: "100.100",
|
||||||
@@ -885,7 +885,7 @@ describe("registerSlackInteractionEvents", () => {
|
|||||||
};
|
};
|
||||||
expect(payload).toMatchObject({
|
expect(payload).toMatchObject({
|
||||||
actionType: "workflow_button",
|
actionType: "workflow_button",
|
||||||
workflowTriggerUrl: "https://slack.com/workflows/triggers/T420/12345",
|
workflowTriggerUrl: "[redacted]",
|
||||||
workflowId: "Wf12345",
|
workflowId: "Wf12345",
|
||||||
teamId: "T420",
|
teamId: "T420",
|
||||||
channelId: "C420",
|
channelId: "C420",
|
||||||
@@ -979,7 +979,7 @@ describe("registerSlackInteractionEvents", () => {
|
|||||||
rootViewId: "VROOT",
|
rootViewId: "VROOT",
|
||||||
previousViewId: "VPREV",
|
previousViewId: "VPREV",
|
||||||
externalId: "deploy-ext-1",
|
externalId: "deploy-ext-1",
|
||||||
viewHash: "view-hash-1",
|
viewHash: "[redacted]",
|
||||||
isStackedView: true,
|
isStackedView: true,
|
||||||
});
|
});
|
||||||
expect(payload.inputs).toEqual(
|
expect(payload.inputs).toEqual(
|
||||||
@@ -1378,14 +1378,11 @@ describe("registerSlackInteractionEvents", () => {
|
|||||||
viewId: "V900",
|
viewId: "V900",
|
||||||
userId: "U900",
|
userId: "U900",
|
||||||
isCleared: true,
|
isCleared: true,
|
||||||
privateMetadata: JSON.stringify({
|
privateMetadata: "[redacted]",
|
||||||
sessionKey: "agent:main:slack:channel:C99",
|
|
||||||
userId: "U900",
|
|
||||||
}),
|
|
||||||
rootViewId: "VROOT900",
|
rootViewId: "VROOT900",
|
||||||
previousViewId: "VPREV900",
|
previousViewId: "VPREV900",
|
||||||
externalId: "deploy-ext-900",
|
externalId: "deploy-ext-900",
|
||||||
viewHash: "view-hash-900",
|
viewHash: "[redacted]",
|
||||||
isStackedView: true,
|
isStackedView: true,
|
||||||
});
|
});
|
||||||
expect(payload.inputs).toEqual(
|
expect(payload.inputs).toEqual(
|
||||||
@@ -1426,5 +1423,64 @@ describe("registerSlackInteractionEvents", () => {
|
|||||||
expect(payload.interactionType).toBe("view_closed");
|
expect(payload.interactionType).toBe("view_closed");
|
||||||
expect(payload.isCleared).toBe(false);
|
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;
|
const selectedDateTimeEpoch = 1_771_632_300;
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ import { escapeSlackMrkdwn } from "../mrkdwn.js";
|
|||||||
|
|
||||||
// Prefix for OpenClaw-generated action IDs to scope our handler
|
// Prefix for OpenClaw-generated action IDs to scope our handler
|
||||||
const OPENCLAW_ACTION_PREFIX = "openclaw:";
|
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 InteractionMessageBlock = {
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -107,6 +120,145 @@ type RegisterSlackModalHandler = (
|
|||||||
handler: (args: SlackModalEventHandlerArgs) => Promise<void>,
|
handler: (args: SlackModalEventHandlerArgs) => Promise<void>,
|
||||||
) => 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 {
|
function readOptionValues(options: unknown): string[] | undefined {
|
||||||
if (!Array.isArray(options)) {
|
if (!Array.isArray(options)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -512,7 +664,7 @@ async function emitSlackModalLifecycleEvent(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, {
|
enqueueSystemEvent(formatSlackInteractionSystemEvent(eventPayload), {
|
||||||
sessionKey: sessionRouting.sessionKey,
|
sessionKey: sessionRouting.sessionKey,
|
||||||
contextKey: [params.contextPrefix, callbackId, viewId, userId].filter(Boolean).join(":"),
|
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 contextParts = ["slack:interaction", channelId, messageTs, actionId].filter(Boolean);
|
||||||
const contextKey = contextParts.join(":");
|
const contextKey = contextParts.join(":");
|
||||||
|
|
||||||
enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, {
|
enqueueSystemEvent(formatSlackInteractionSystemEvent(eventPayload), {
|
||||||
sessionKey,
|
sessionKey,
|
||||||
contextKey,
|
contextKey,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user