mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:50:42 +00:00
fix: harden slack interactive blocks
This commit is contained in:
@@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Google Meet: block managed Chrome intro/test speech until browser health proves the participant is in-call, and expose `speechReady` diagnostics so login, admission, permission, and audio-bridge blockers no longer look like successful speech. Refs #72478. Thanks @DougButdorf.
|
- Google Meet: block managed Chrome intro/test speech until browser health proves the participant is in-call, and expose `speechReady` diagnostics so login, admission, permission, and audio-bridge blockers no longer look like successful speech. Refs #72478. Thanks @DougButdorf.
|
||||||
- Slack/commands: keep native command argument menus on select controls for encoded choice values up to Slack's option limit and truncate fallback button labels to Slack's button-text limit, so long valid choices no longer render invalid Slack blocks. Thanks @slackapi.
|
- Slack/commands: keep native command argument menus on select controls for encoded choice values up to Slack's option limit and truncate fallback button labels to Slack's button-text limit, so long valid choices no longer render invalid Slack blocks. Thanks @slackapi.
|
||||||
- Agents/Codex: flush accepted debounced steering messages before normal app-server turn cleanup, so inbound follow-ups acknowledged as queued are not dropped when the turn completes before the debounce fires. Thanks @vincentkoc.
|
- Agents/Codex: flush accepted debounced steering messages before normal app-server turn cleanup, so inbound follow-ups acknowledged as queued are not dropped when the turn completes before the debounce fires. Thanks @vincentkoc.
|
||||||
|
- Slack/interactive replies: keep rendered buttons and selects within Slack Block Kit value and count limits, and align command argument select values with Slack's option limit, so overlong agent-authored choices no longer make Slack reject the whole block payload. Thanks @slackapi.
|
||||||
- CLI/update: scope packaged Node compile caches by OpenClaw version and install metadata, so global installs no longer reuse stale compiled chunks after package updates. Thanks @pashpashpash.
|
- CLI/update: scope packaged Node compile caches by OpenClaw version and install metadata, so global installs no longer reuse stale compiled chunks after package updates. Thanks @pashpashpash.
|
||||||
- Channels/Voice call: keep pre-auth webhook in-flight limiting active when socket remote address metadata is missing, so slow-body requests from stripped-IP proxy paths still share the fallback bucket. (#74453) Thanks @davidangularme.
|
- Channels/Voice call: keep pre-auth webhook in-flight limiting active when socket remote address metadata is missing, so slow-body requests from stripped-IP proxy paths still share the fallback bucket. (#74453) Thanks @davidangularme.
|
||||||
- Plugin SDK/testing: lazy-load TypeScript from the plugin test-contract runtime and add release checks for critical SDK contract entrypoint imports and bundle size, so published packages fail preflight before shipping ESM-incompatible or oversized contract helpers. Thanks @vincentkoc.
|
- Plugin SDK/testing: lazy-load TypeScript from the plugin test-contract runtime and add release checks for critical SDK contract entrypoint imports and bundle size, so published packages fail preflight before shipping ESM-incompatible or oversized contract helpers. Thanks @vincentkoc.
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import { truncateSlackText } from "./truncate.js";
|
|||||||
|
|
||||||
const SLACK_SECTION_TEXT_MAX = 3000;
|
const SLACK_SECTION_TEXT_MAX = 3000;
|
||||||
const SLACK_PLAIN_TEXT_MAX = 75;
|
const SLACK_PLAIN_TEXT_MAX = 75;
|
||||||
|
const SLACK_OPTION_VALUE_MAX = 75;
|
||||||
|
const SLACK_BUTTON_VALUE_MAX = 2000;
|
||||||
|
const SLACK_STATIC_SELECT_OPTIONS_MAX = 100;
|
||||||
|
const SLACK_ACTION_BLOCK_ELEMENTS_MAX = 25;
|
||||||
|
|
||||||
export type SlackBlock = Block | KnownBlock;
|
export type SlackBlock = Block | KnownBlock;
|
||||||
|
|
||||||
@@ -36,6 +40,10 @@ function resolveSlackButtonStyle(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWithinSlackLimit(value: string, maxLength: number): boolean {
|
||||||
|
return value.length <= maxLength;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): SlackBlock[] {
|
export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): SlackBlock[] {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
blocks: [] as SlackBlock[],
|
blocks: [] as SlackBlock[],
|
||||||
@@ -58,26 +66,32 @@ export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): Sla
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
if (block.type === "buttons") {
|
if (block.type === "buttons") {
|
||||||
const elements = block.buttons.flatMap((button, choiceIndex) => {
|
const elements = block.buttons
|
||||||
if (!button.value && !button.url) {
|
.flatMap((button, choiceIndex) => {
|
||||||
return [];
|
const value =
|
||||||
}
|
button.value && isWithinSlackLimit(button.value, SLACK_BUTTON_VALUE_MAX)
|
||||||
const style = resolveSlackButtonStyle(button.style);
|
? button.value
|
||||||
return [
|
: undefined;
|
||||||
{
|
if (!value && !button.url) {
|
||||||
type: "button" as const,
|
return [];
|
||||||
action_id: buildSlackReplyButtonActionId(state.buttonIndex + 1, choiceIndex),
|
}
|
||||||
text: {
|
const style = resolveSlackButtonStyle(button.style);
|
||||||
type: "plain_text" as const,
|
return [
|
||||||
text: truncateSlackText(button.label, SLACK_PLAIN_TEXT_MAX),
|
{
|
||||||
emoji: true,
|
type: "button" as const,
|
||||||
|
action_id: buildSlackReplyButtonActionId(state.buttonIndex + 1, choiceIndex),
|
||||||
|
text: {
|
||||||
|
type: "plain_text" as const,
|
||||||
|
text: truncateSlackText(button.label, SLACK_PLAIN_TEXT_MAX),
|
||||||
|
emoji: true,
|
||||||
|
},
|
||||||
|
...(value ? { value } : {}),
|
||||||
|
...(button.url ? { url: button.url } : {}),
|
||||||
|
...(style ? { style } : {}),
|
||||||
},
|
},
|
||||||
...(button.value ? { value: button.value } : {}),
|
];
|
||||||
...(button.url ? { url: button.url } : {}),
|
})
|
||||||
...(style ? { style } : {}),
|
.slice(0, SLACK_ACTION_BLOCK_ELEMENTS_MAX);
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
if (elements.length === 0) {
|
if (elements.length === 0) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -88,7 +102,10 @@ export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): Sla
|
|||||||
});
|
});
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
if (block.options.length === 0) {
|
const options = block.options
|
||||||
|
.filter((option) => isWithinSlackLimit(option.value, SLACK_OPTION_VALUE_MAX))
|
||||||
|
.slice(0, SLACK_STATIC_SELECT_OPTIONS_MAX);
|
||||||
|
if (options.length === 0) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
state.blocks.push({
|
state.blocks.push({
|
||||||
@@ -106,7 +123,7 @@ export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): Sla
|
|||||||
),
|
),
|
||||||
emoji: true,
|
emoji: true,
|
||||||
},
|
},
|
||||||
options: block.options.map((option, _choiceIndex) => ({
|
options: options.map((option, _choiceIndex) => ({
|
||||||
text: {
|
text: {
|
||||||
type: "plain_text",
|
type: "plain_text",
|
||||||
text: truncateSlackText(option.label, SLACK_PLAIN_TEXT_MAX),
|
text: truncateSlackText(option.label, SLACK_PLAIN_TEXT_MAX),
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ vi.mock("./slash-commands.runtime.js", () => {
|
|||||||
if (params.command?.key === "reportlong") {
|
if (params.command?.key === "reportlong") {
|
||||||
return resolvePeriodMenu(params, [
|
return resolvePeriodMenu(params, [
|
||||||
...fullReportPeriodChoices,
|
...fullReportPeriodChoices,
|
||||||
{ value: "x".repeat(90), label: "long" },
|
{ value: "x".repeat(45), label: "long" },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (params.command?.key === "reportlongbutton") {
|
if (params.command?.key === "reportlongbutton") {
|
||||||
@@ -576,7 +576,7 @@ describe("Slack native command argument menus", () => {
|
|||||||
expect(firstElement?.type).toBe("button");
|
expect(firstElement?.type).toBe("button");
|
||||||
expect(firstElement?.text?.text).toHaveLength(75);
|
expect(firstElement?.text?.text).toHaveLength(75);
|
||||||
expect(firstElement?.text?.text?.endsWith("…")).toBe(true);
|
expect(firstElement?.text?.text?.endsWith("…")).toBe(true);
|
||||||
expect(firstElement?.value?.length).toBeGreaterThan(150);
|
expect(firstElement?.value?.length).toBeGreaterThan(75);
|
||||||
expect(firstElement?.confirm).toBeTruthy();
|
expect(firstElement?.confirm).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const SLACK_COMMAND_ARG_OVERFLOW_MIN = 3;
|
|||||||
const SLACK_COMMAND_ARG_OVERFLOW_MAX = 5;
|
const SLACK_COMMAND_ARG_OVERFLOW_MAX = 5;
|
||||||
const SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX = 100;
|
const SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX = 100;
|
||||||
const SLACK_COMMAND_ARG_SELECT_OPTION_TEXT_MAX = 75;
|
const SLACK_COMMAND_ARG_SELECT_OPTION_TEXT_MAX = 75;
|
||||||
const SLACK_COMMAND_ARG_SELECT_OPTION_VALUE_MAX = 150;
|
const SLACK_COMMAND_ARG_SELECT_OPTION_VALUE_MAX = 75;
|
||||||
const SLACK_COMMAND_ARG_BUTTON_TEXT_MAX = 75;
|
const SLACK_COMMAND_ARG_BUTTON_TEXT_MAX = 75;
|
||||||
const SLACK_HEADER_TEXT_MAX = 150;
|
const SLACK_HEADER_TEXT_MAX = 150;
|
||||||
let slashCommandsRuntimePromise: Promise<typeof import("./slash-commands.runtime.js")> | null =
|
let slashCommandsRuntimePromise: Promise<typeof import("./slash-commands.runtime.js")> | null =
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe("buildSlackInteractiveBlocks", () => {
|
|||||||
const blocks = buildSlackInteractiveBlocks({
|
const blocks = buildSlackInteractiveBlocks({
|
||||||
blocks: [
|
blocks: [
|
||||||
{ type: "text", text: "y".repeat(3100) },
|
{ type: "text", text: "y".repeat(3100) },
|
||||||
{ type: "select", placeholder: long, options: [{ label: long, value: long }] },
|
{ type: "select", placeholder: long, options: [{ label: long, value: "valid" }] },
|
||||||
{ type: "buttons", buttons: [{ label: long, value: long }] },
|
{ type: "buttons", buttons: [{ label: long, value: long }] },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -83,6 +83,108 @@ describe("buildSlackInteractiveBlocks", () => {
|
|||||||
expect(selectBlock.elements?.[0]?.options?.[0]?.value).toBe("codex:approve:thread-1");
|
expect(selectBlock.elements?.[0]?.options?.[0]?.value).toBe("codex:approve:thread-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops Slack select options with values beyond Block Kit limits", () => {
|
||||||
|
const blocks = buildSlackInteractiveBlocks({
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "Allowed", value: "a".repeat(75) },
|
||||||
|
{ label: "Too long", value: "b".repeat(76) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectBlock = blocks[0] as {
|
||||||
|
elements?: Array<{ options?: Array<{ value?: string }> }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(selectBlock.elements?.[0]?.options).toHaveLength(1);
|
||||||
|
expect(selectBlock.elements?.[0]?.options?.[0]?.value).toBe("a".repeat(75));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits Slack select blocks when every option value exceeds Block Kit limits", () => {
|
||||||
|
expect(
|
||||||
|
buildSlackInteractiveBlocks({
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
options: [{ label: "Too long", value: "x".repeat(76) }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps Slack static selects at the Block Kit option limit", () => {
|
||||||
|
const blocks = buildSlackInteractiveBlocks({
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
options: Array.from({ length: 101 }, (_entry, index) => ({
|
||||||
|
label: `Option ${index + 1}`,
|
||||||
|
value: `v${index + 1}`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectBlock = blocks[0] as {
|
||||||
|
elements?: Array<{ options?: Array<{ value?: string }> }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(selectBlock.elements?.[0]?.options).toHaveLength(100);
|
||||||
|
expect(selectBlock.elements?.[0]?.options?.at(-1)?.value).toBe("v100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops value-only Slack buttons with values beyond Block Kit limits", () => {
|
||||||
|
const blocks = buildSlackInteractiveBlocks({
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: "buttons",
|
||||||
|
buttons: [
|
||||||
|
{ label: "Allowed", value: "a".repeat(2000) },
|
||||||
|
{ label: "Too long", value: "b".repeat(2001) },
|
||||||
|
{ label: "Docs", value: "c".repeat(2001), url: "https://example.com/docs" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttonBlock = blocks[0] as {
|
||||||
|
elements?: Array<{ value?: string; url?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buttonBlock.elements).toHaveLength(2);
|
||||||
|
expect(buttonBlock.elements?.[0]?.value).toBe("a".repeat(2000));
|
||||||
|
expect(buttonBlock.elements?.[1]).toEqual(
|
||||||
|
expect.objectContaining({ url: "https://example.com/docs" }),
|
||||||
|
);
|
||||||
|
expect(buttonBlock.elements?.[1]).not.toHaveProperty("value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps Slack actions blocks at the Block Kit element limit", () => {
|
||||||
|
const blocks = buildSlackInteractiveBlocks({
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: "buttons",
|
||||||
|
buttons: Array.from({ length: 26 }, (_entry, index) => ({
|
||||||
|
label: `Option ${index + 1}`,
|
||||||
|
value: `v${index + 1}`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttonBlock = blocks[0] as {
|
||||||
|
elements?: Array<{ value?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buttonBlock.elements).toHaveLength(25);
|
||||||
|
expect(buttonBlock.elements?.at(-1)?.value).toBe("v25");
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves URL-only buttons as Slack link buttons", () => {
|
it("preserves URL-only buttons as Slack link buttons", () => {
|
||||||
const blocks = buildSlackInteractiveBlocks({
|
const blocks = buildSlackInteractiveBlocks({
|
||||||
blocks: [
|
blocks: [
|
||||||
|
|||||||
Reference in New Issue
Block a user