Slack: harden interactive reply routing and limits
This commit is contained in:
parent
357fa94893
commit
b7271de633
@ -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>() {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user