Slack: harden interactive reply routing and limits

This commit is contained in:
Vincent Koc 2026-03-13 11:06:26 -07:00
parent 357fa94893
commit b7271de633
4 changed files with 138 additions and 7 deletions

View File

@ -782,6 +782,82 @@ describe("parseSlackDirectives", () => {
},
]);
});
it("truncates Slack interactive reply strings to safe Block Kit limits", () => {
const long = "x".repeat(120);
const result = parseSlackDirectives({
text: `${"y".repeat(3100)} [[slack_select: ${long} | ${long}:${long}]] [[slack_buttons: ${long}:${long}]]`,
});
const blocks = getSlackData(result).blocks as Array<Record<string, unknown>>;
expect(blocks).toHaveLength(3);
expect(((blocks[0]?.text as { text?: string })?.text ?? "").length).toBeLessThanOrEqual(3000);
expect(
(
(
(blocks[1]?.elements as Array<Record<string, unknown>>)?.[0]?.placeholder as {
text?: string;
}
)?.text ?? ""
).length,
).toBeLessThanOrEqual(75);
expect(
(
(
(
(blocks[1]?.elements as Array<Record<string, unknown>>)?.[0]?.options as Array<
Record<string, unknown>
>
)?.[0]?.text as { text?: string }
)?.text ?? ""
).length,
).toBeLessThanOrEqual(75);
expect(
(
((
(blocks[1]?.elements as Array<Record<string, unknown>>)?.[0]?.options as Array<
Record<string, unknown>
>
)?.[0]?.value as string | undefined) ?? ""
).length,
).toBeLessThanOrEqual(75);
expect(
(
(
(blocks[2]?.elements as Array<Record<string, unknown>>)?.[0]?.text as {
text?: string;
}
)?.text ?? ""
).length,
).toBeLessThanOrEqual(75);
expect(
(
((blocks[2]?.elements as Array<Record<string, unknown>>)?.[0]?.value as
| string
| undefined) ?? ""
).length,
).toBeLessThanOrEqual(75);
});
it("falls back to the original payload when generated blocks would exceed Slack limits", () => {
const result = parseSlackDirectives({
text: "Choose [[slack_buttons: Retry:retry]]",
channelData: {
slack: {
blocks: Array.from({ length: 49 }, () => ({ type: "divider" })),
},
},
});
expect(result).toEqual({
text: "Choose [[slack_buttons: Retry:retry]]",
channelData: {
slack: {
blocks: Array.from({ length: 49 }, () => ({ type: "divider" })),
},
},
});
});
});
function createDeferred<T>() {

View File

@ -201,6 +201,35 @@ describe("routeReply", () => {
);
});
it("routes directive-only Slack replies when interactive replies are enabled", async () => {
mocks.sendMessageSlack.mockClear();
const cfg = {
channels: {
slack: {
capabilities: { interactiveReplies: true },
},
},
} as unknown as OpenClawConfig;
await routeReply({
payload: { text: "[[slack_select: Choose one | Alpha:alpha]]" },
channel: "slack",
to: "channel:C123",
cfg,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"",
expect.objectContaining({
blocks: [
expect.objectContaining({
type: "actions",
block_id: "openclaw_reply_select_1",
}),
],
}),
);
});
it("does not derive responsePrefix from agent identity when routing", async () => {
mocks.sendMessageSlack.mockClear();
const cfg = {

View File

@ -109,9 +109,17 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
? [normalized.mediaUrl]
: [];
const replyToId = normalized.replyToId;
const hasSlackBlocks =
channel === "slack" &&
Boolean(
normalized.channelData?.slack &&
typeof normalized.channelData.slack === "object" &&
!Array.isArray(normalized.channelData.slack) &&
(normalized.channelData.slack as { blocks?: unknown }).blocks,
);
// Skip empty replies.
if (!text.trim() && mediaUrls.length === 0) {
if (!text.trim() && mediaUrls.length === 0 && !hasSlackBlocks) {
return { ok: true };
}

View File

@ -3,8 +3,12 @@ import type { ReplyPayload } from "../types.js";
const SLACK_REPLY_BUTTON_ACTION_ID = "openclaw:reply_button";
const SLACK_REPLY_SELECT_ACTION_ID = "openclaw:reply_select";
const SLACK_MAX_BLOCKS = 50;
const SLACK_BUTTON_MAX_ITEMS = 5;
const SLACK_SELECT_MAX_ITEMS = 100;
const SLACK_SECTION_TEXT_MAX = 3000;
const SLACK_PLAIN_TEXT_MAX = 75;
const SLACK_OPTION_VALUE_MAX = 75;
const SLACK_DIRECTIVE_RE = /\[\[(slack_buttons|slack_select):\s*([^\]]+)\]\]/gi;
type SlackBlock = Record<string, unknown>;
@ -17,6 +21,17 @@ type SlackChoice = {
value: string;
};
function truncateSlackText(value: string, max: number): string {
const trimmed = value.trim();
if (trimmed.length <= max) {
return trimmed;
}
if (max <= 1) {
return trimmed.slice(0, max);
}
return `${trimmed.slice(0, max - 1)}`;
}
function parseChoice(raw: string): SlackChoice | null {
const trimmed = raw.trim();
if (!trimmed) {
@ -54,7 +69,7 @@ function buildSectionBlock(text: string): SlackBlock | null {
type: "section",
text: {
type: "mrkdwn",
text: trimmed,
text: truncateSlackText(trimmed, SLACK_SECTION_TEXT_MAX),
},
};
}
@ -72,10 +87,10 @@ function buildButtonsBlock(raw: string, index: number): SlackBlock | null {
action_id: SLACK_REPLY_BUTTON_ACTION_ID,
text: {
type: "plain_text",
text: choice.label,
text: truncateSlackText(choice.label, SLACK_PLAIN_TEXT_MAX),
emoji: true,
},
value: choice.value,
value: truncateSlackText(choice.value, SLACK_OPTION_VALUE_MAX),
})),
};
}
@ -103,16 +118,16 @@ function buildSelectBlock(raw: string, index: number): SlackBlock | null {
action_id: SLACK_REPLY_SELECT_ACTION_ID,
placeholder: {
type: "plain_text",
text: placeholder,
text: truncateSlackText(placeholder, SLACK_PLAIN_TEXT_MAX),
emoji: true,
},
options: choices.map((choice) => ({
text: {
type: "plain_text",
text: choice.label,
text: truncateSlackText(choice.label, SLACK_PLAIN_TEXT_MAX),
emoji: true,
},
value: choice.value,
value: truncateSlackText(choice.value, SLACK_OPTION_VALUE_MAX),
})),
},
],
@ -181,6 +196,9 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
}
const existingBlocks = readExistingSlackBlocks(payload);
if (existingBlocks.length + generatedBlocks.length > SLACK_MAX_BLOCKS) {
return payload;
}
const nextBlocks = [...existingBlocks, ...generatedBlocks];
return {