mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix(slack): preserve long legacy interactive text (#127994)
* fix(slack): preserve long interactive and presentation text * fix(slack): retain every outbound delivery receipt Co-authored-by: Peter Steinberger <steipete@gmail.com> * test(slack): narrow presentation regression fixtures Co-authored-by: Peter Steinberger <steipete@gmail.com> * fix(slack): preserve single-message presentation edits Co-authored-by: Peter Steinberger <steipete@gmail.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
hasSlackDataVisualizationBlock,
|
||||
SLACK_DATA_VISUALIZATION_BLOCKS_MAX,
|
||||
} from "./data-visualization.js";
|
||||
import { chunkSlackMrkdwnText } from "./format.js";
|
||||
import { renderSlackMessagePresentationChartFallbackText } from "./presentation-fallback.js";
|
||||
import {
|
||||
SLACK_ACTION_BLOCK_ELEMENTS_MAX,
|
||||
@@ -284,22 +285,12 @@ export function buildSlackPresentationBlocks(
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
if (block.type === "context") {
|
||||
blocks.push({
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: truncateSlackText(text, SLACK_SECTION_TEXT_MAX),
|
||||
verbatim: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
blocks.push({
|
||||
type: "section",
|
||||
text: { type: "mrkdwn", text: truncateSlackText(text, SLACK_SECTION_TEXT_MAX) },
|
||||
});
|
||||
for (const chunk of chunkSlackMrkdwnText(text, SLACK_SECTION_TEXT_MAX)) {
|
||||
blocks.push(
|
||||
block.type === "context"
|
||||
? { type: "context", elements: [{ type: "mrkdwn", text: chunk, verbatim: true }] }
|
||||
: { type: "section", text: { type: "mrkdwn", text: chunk } },
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -451,9 +442,6 @@ export function canRenderSlackPresentation(
|
||||
let dataVisualizationCount = options.dataVisualizationCountOffset ?? 0;
|
||||
for (const block of presentation.blocks) {
|
||||
if (block.type === "text" || block.type === "context") {
|
||||
if (!isWithinSlackLimit(block.text.trim(), SLACK_SECTION_TEXT_MAX)) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (block.type === "buttons") {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
// Slack tests cover channel plugin behavior.
|
||||
import { createMessageReceiptFromOutboundResults } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -7,6 +8,7 @@ import { slackPlugin } from "./channel.js";
|
||||
import { registerSlackInstallationState } from "./installation-identity-state.js";
|
||||
import { slackOutbound } from "./outbound-adapter.js";
|
||||
import * as probeModule from "./probe.js";
|
||||
import { SLACK_QUESTION_FINALIZATION_BLOCKS } from "./reply-action-ids.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { setSlackRuntime } from "./runtime.js";
|
||||
|
||||
@@ -1605,7 +1607,11 @@ describe("slackPlugin outbound", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({ channel: "slack", messageId: "m-final" });
|
||||
expect(result).toMatchObject({
|
||||
channel: "slack",
|
||||
messageId: "m-final",
|
||||
receipt: { platformMessageIds: ["m-media-1", "m-media-2", "m-final"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders shared interactive payloads into Slack Block Kit via plugin outbound", async () => {
|
||||
@@ -1671,6 +1677,118 @@ describe("slackPlugin outbound", () => {
|
||||
expectRecordFields(options[1], "production option", { value: "production" });
|
||||
expect(result).toEqual({ channel: "slack", messageId: "m-interactive" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ surface: "interactive", type: "text" },
|
||||
{ surface: "presentation", type: "text" },
|
||||
{ surface: "presentation", type: "context" },
|
||||
] as const)(
|
||||
"delivers oversized $surface $type in order across the real Slack outbound adapter",
|
||||
async ({ surface, type }) => {
|
||||
const sendSlack = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "m-chunk-1" })
|
||||
.mockResolvedValueOnce({ messageId: "m-chunk-2" });
|
||||
const text = "x".repeat(3_000 * 50 + 1);
|
||||
const buttons = {
|
||||
type: "buttons" as const,
|
||||
buttons: [{ label: "Continue", value: "continue" }],
|
||||
};
|
||||
const presentationTextBlock =
|
||||
type === "context" ? { type: "context" as const, text } : { type: "text" as const, text };
|
||||
const payload =
|
||||
surface === "interactive"
|
||||
? { text: "", interactive: { blocks: [{ type: "text" as const, text }, buttons] } }
|
||||
: { text: "", presentation: { blocks: [presentationTextBlock, buttons] } };
|
||||
|
||||
const result = await requireSlackSendPayload()({
|
||||
cfg,
|
||||
to: "channel:C123",
|
||||
text: "",
|
||||
payload,
|
||||
accountId: "default",
|
||||
deps: { sendSlack },
|
||||
});
|
||||
const batches = sendSlack.mock.calls.map((_call, index) =>
|
||||
requireArray(requireMockCallArg(sendSlack, index, 2).blocks, "Slack blocks"),
|
||||
);
|
||||
const delivered = batches.flat().flatMap((entry) => {
|
||||
const block = requireRecord(entry, "Slack block");
|
||||
const textObject =
|
||||
block.type === "context"
|
||||
? requireArray(block.elements, "context elements")[0]
|
||||
: block.type === "section"
|
||||
? block.text
|
||||
: undefined;
|
||||
return textObject ? [String(requireRecord(textObject, "Slack text").text)] : [];
|
||||
});
|
||||
|
||||
expect(batches.map((blocks) => blocks.length)).toEqual([50, 2]);
|
||||
expect(delivered.join("")).toBe(text);
|
||||
expect(batches[1]?.[1]).toMatchObject({ type: "actions" });
|
||||
expect(result).toMatchObject({
|
||||
channel: "slack",
|
||||
messageId: "m-chunk-2",
|
||||
receipt: { platformMessageIds: ["m-chunk-1", "m-chunk-2"] },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("retains media and every reply receipt without losing an earlier question card", async () => {
|
||||
const questionId = "ask_0123456789abcdef0123456789abcdef";
|
||||
const questionMeta = {
|
||||
slackQuestionActionIds: ["openclaw:question_button:1:1"],
|
||||
[SLACK_QUESTION_FINALIZATION_BLOCKS]: [{ type: "divider" as const }],
|
||||
};
|
||||
const createResult = (messageId: string, kind: "media" | "card") => ({
|
||||
messageId,
|
||||
channelId: "C123",
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "slack", messageId }],
|
||||
kind,
|
||||
}),
|
||||
});
|
||||
const sendSlack = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createResult("m-upload", "media"))
|
||||
.mockResolvedValueOnce({ ...createResult("m-question", "card"), meta: questionMeta })
|
||||
.mockResolvedValueOnce(createResult("m-final", "card"));
|
||||
|
||||
const result = await requireSlackSendPayload()({
|
||||
cfg,
|
||||
to: "channel:C123",
|
||||
text: "",
|
||||
accountId: "default",
|
||||
deps: { sendSlack },
|
||||
payload: {
|
||||
text: "",
|
||||
mediaUrls: ["https://example.com/context.png"],
|
||||
channelData: {
|
||||
slack: { blocks: Array.from({ length: 48 }, () => ({ type: "divider" as const })) },
|
||||
},
|
||||
presentation: {
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{
|
||||
label: "Answer",
|
||||
action: { type: "question", questionId, optionValue: "one" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "text", text: "x".repeat(3_001) },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendSlack).toHaveBeenCalledTimes(3);
|
||||
expect(result.messageId).toBe("m-final");
|
||||
expect(result.receipt?.platformMessageIds).toEqual(["m-upload", "m-question", "m-final"]);
|
||||
expect(result.receipt?.parts.map((part) => part.index)).toEqual([0, 1, 2]);
|
||||
expect(result.meta).toEqual({ ...questionMeta, slackQuestionMessageId: "m-question" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("slackPlugin directory", () => {
|
||||
|
||||
@@ -1,8 +1,60 @@
|
||||
// Slack tests cover format plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToSlackMrkdwnChunks, normalizeSlackOutboundText } from "./format.js";
|
||||
import {
|
||||
chunkSlackMrkdwnText,
|
||||
markdownToSlackMrkdwnChunks,
|
||||
normalizeSlackOutboundText,
|
||||
} from "./format.js";
|
||||
import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js";
|
||||
|
||||
describe("chunkSlackMrkdwnText", () => {
|
||||
it("preserves ordinary whitespace at Slack section boundaries", () => {
|
||||
const text = `${"x".repeat(2_998)} tail`;
|
||||
const chunks = chunkSlackMrkdwnText(text, 3_000);
|
||||
|
||||
expect(chunks.join("")).toBe(text);
|
||||
expect(chunks.every((chunk) => chunk.length <= 3_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps short inline-code spans together until the actual section boundary", () => {
|
||||
const text = `${"a`b`".repeat(750)}x`;
|
||||
const chunks = chunkSlackMrkdwnText(text, 3_000);
|
||||
|
||||
expect(chunks).toHaveLength(2);
|
||||
expect(chunks.join("")).toBe(text);
|
||||
expect(chunks.every((chunk) => chunk.length <= 3_000)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["`", "```"])("balances long %s code sections without losing their content", (marker) => {
|
||||
const content = "x".repeat(3_100);
|
||||
const chunks = chunkSlackMrkdwnText(`${marker}${content}${marker}`, 3_000);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.every((chunk) => chunk.startsWith(marker) && chunk.endsWith(marker))).toBe(true);
|
||||
expect(chunks.map((chunk) => chunk.slice(marker.length, -marker.length)).join("")).toBe(
|
||||
content,
|
||||
);
|
||||
expect(chunks.every((chunk) => chunk.length <= 3_000)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["inline", "`", [1, 2]],
|
||||
["fenced", "```", [1, 5, 6]],
|
||||
] as const)("preserves content when a %s code wrapper cannot fit", (_name, marker, limits) => {
|
||||
for (const limit of limits) {
|
||||
expect(chunkSlackMrkdwnText(`${marker}x${marker}`, limit)).toEqual(["x"]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["`", "```"])("does not emit marker-only sections for oversized %s links", (marker) => {
|
||||
const token = `<https://example.com/${"x".repeat(3_000)}>`;
|
||||
const chunks = chunkSlackMrkdwnText(`${marker}${token}${marker}`, 3_000);
|
||||
|
||||
expect(chunks.every((chunk) => chunk.length > marker.length * 2)).toBe(true);
|
||||
expect(chunks.map((chunk) => chunk.slice(marker.length, -marker.length)).join("")).toBe(token);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeSlackOutboundText", () => {
|
||||
it("marks assistant-authored transcript role headers after parsing Markdown", () => {
|
||||
expect(normalizeSlackOutboundText("**user**[Thu 2026-07-02] question")).toBe(
|
||||
|
||||
@@ -456,36 +456,44 @@ export function chunkSlackMrkdwnText(text: string, limit: number): string[] {
|
||||
(text.match(/<[^>\n]+>/gu)?.some(isAllowedSlackAngleToken) ?? false) ||
|
||||
/\\[\s\S]/u.test(text);
|
||||
if (!hasProtectedToken) {
|
||||
return chunkTextForOutbound(text, limit);
|
||||
return chunkTextForOutbound(text, limit, { preserveWhitespace: true });
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let activeMarker: SlackCodeMarker | undefined;
|
||||
let content = "";
|
||||
const wrapper = () =>
|
||||
activeMarker && limit > activeMarker.length * 2 ? activeMarker : undefined;
|
||||
const capacity = () => limit - (wrapper()?.length ?? 0) * 2;
|
||||
const wrapper = (marker: SlackCodeMarker | undefined) =>
|
||||
marker && limit > marker.length * 2 ? marker : undefined;
|
||||
const capacity = (marker: SlackCodeMarker | undefined) => limit - (wrapper(marker)?.length ?? 0);
|
||||
const flush = () => {
|
||||
if (!content) {
|
||||
return;
|
||||
const marker = wrapper(activeMarker);
|
||||
if (content && content !== marker) {
|
||||
chunks.push(marker ? `${content}${marker}` : content);
|
||||
}
|
||||
const marker = wrapper();
|
||||
chunks.push(marker ? `${marker}${content}${marker}` : content);
|
||||
content = "";
|
||||
};
|
||||
|
||||
for (const token of tokenizeSlackMrkdwn(text)) {
|
||||
const transition = resolveSlackCodeMarkerTransition(activeMarker, token);
|
||||
if (transition !== null) {
|
||||
flush();
|
||||
activeMarker = transition;
|
||||
const nextMarker = transition === null ? activeMarker : transition;
|
||||
const sourceMarker = token === "`" || token === "```" ? token : undefined;
|
||||
if (transition !== null && sourceMarker && !wrapper(sourceMarker)) {
|
||||
activeMarker = nextMarker;
|
||||
continue;
|
||||
}
|
||||
if (content && content.length + token.length > capacity(nextMarker)) {
|
||||
flush();
|
||||
}
|
||||
activeMarker = nextMarker;
|
||||
if (!content && transition === undefined) {
|
||||
continue;
|
||||
}
|
||||
content ||= transition === null ? (wrapper(activeMarker) ?? "") : "";
|
||||
|
||||
const contentLimit = capacity();
|
||||
const contentLimit = capacity(activeMarker) - (wrapper(activeMarker)?.length ?? 0);
|
||||
if (token.length > contentLimit) {
|
||||
flush();
|
||||
const marker = wrapper();
|
||||
const marker = wrapper(activeMarker);
|
||||
if (activeMarker && isAllowedSlackAngleToken(token)) {
|
||||
if (marker) {
|
||||
chunks.push(
|
||||
@@ -509,9 +517,6 @@ export function chunkSlackMrkdwnText(text: string, limit: number): string[] {
|
||||
chunks.push(...(token.length <= limit ? [token] : chunkTextForOutbound(token, limit)));
|
||||
continue;
|
||||
}
|
||||
if (content && content.length + token.length > contentLimit) {
|
||||
flush();
|
||||
}
|
||||
content += token;
|
||||
}
|
||||
flush();
|
||||
|
||||
@@ -595,7 +595,34 @@ describe("handleSlackMessageAction", () => {
|
||||
expect(firstAction(invoke)).toMatchObject({ content: `- ${label}`, blocks: undefined });
|
||||
});
|
||||
|
||||
it("rejects presentation fallback edits that overflow after Slack markdown rendering", async () => {
|
||||
it.each(["text", "context"] as const)(
|
||||
"keeps a complete oversized %s presentation in one text-only edit",
|
||||
async (type) => {
|
||||
const invoke = createInvokeSpy();
|
||||
const text = "x".repeat(3_001);
|
||||
|
||||
await handleSlackMessageAction({
|
||||
providerId: "slack",
|
||||
ctx: {
|
||||
action: "edit",
|
||||
cfg: {},
|
||||
params: {
|
||||
channelId: "C1",
|
||||
messageId: "171234.567",
|
||||
presentation: { blocks: [{ type, text }] },
|
||||
},
|
||||
} as never,
|
||||
invoke: invoke as never,
|
||||
});
|
||||
|
||||
expect(firstAction(invoke)).toMatchObject({ content: text, blocks: undefined });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ name: "Slack markdown rendering", text: `${"&".repeat(801)}${"x".repeat(2_200)}` },
|
||||
{ name: "UTF-8 expansion", text: "😀".repeat(1_501) },
|
||||
])("rejects presentation fallback edits that overflow after $name", async ({ text }) => {
|
||||
const invoke = createInvokeSpy();
|
||||
|
||||
await expect(
|
||||
@@ -608,7 +635,7 @@ describe("handleSlackMessageAction", () => {
|
||||
channelId: "C1",
|
||||
messageId: "171234.567",
|
||||
presentation: {
|
||||
blocks: [{ type: "text", text: `${"&".repeat(801)}${"x".repeat(2_200)}` }],
|
||||
blocks: [{ type: "text", text }],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { buildSlackPresentationBlocks, canRenderSlackPresentation } from "./bloc
|
||||
import { normalizeSlackOutboundText } from "./format.js";
|
||||
import { SLACK_EDIT_TEXT_MAX_BYTES } from "./limits.js";
|
||||
import { renderSlackMessagePresentationFallbackText } from "./presentation-fallback.js";
|
||||
import { SLACK_SECTION_TEXT_MAX } from "./presentation.js";
|
||||
import {
|
||||
resolveSlackReplyBlockResolution,
|
||||
resolveSlackReplyDeliveryMessages,
|
||||
@@ -60,9 +61,15 @@ function renderSlackActionPresentation(
|
||||
if (!presentation) {
|
||||
return { usesPresentationTextFallback: false };
|
||||
}
|
||||
const renderedBlocks = canRenderSlackPresentation(presentation)
|
||||
? buildSlackPresentationBlocks(presentation)
|
||||
: undefined;
|
||||
const needsCompleteTextFallback = presentation.blocks.some(
|
||||
(block) =>
|
||||
(block.type === "text" || block.type === "context") &&
|
||||
block.text.trim().length > SLACK_SECTION_TEXT_MAX,
|
||||
);
|
||||
const renderedBlocks =
|
||||
!needsCompleteTextFallback && canRenderSlackPresentation(presentation)
|
||||
? buildSlackPresentationBlocks(presentation)
|
||||
: undefined;
|
||||
const usesPresentationTextFallback = !renderedBlocks || renderedBlocks.length > SLACK_MAX_BLOCKS;
|
||||
const blocks = usesPresentationTextFallback ? undefined : renderedBlocks;
|
||||
return {
|
||||
|
||||
@@ -129,7 +129,19 @@ describe("slackOutbound", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toEqual({ channel: "slack", messageId: "m-final" });
|
||||
expect(result).toMatchObject({
|
||||
channel: "slack",
|
||||
messageId: "m-final",
|
||||
receipt: {
|
||||
platformMessageIds: ["m-media-1", "m-media-2", "m-final"],
|
||||
primaryPlatformMessageId: "m-media-1",
|
||||
parts: [
|
||||
{ index: 0, platformMessageId: "m-media-1" },
|
||||
{ index: 1, platformMessageId: "m-media-2" },
|
||||
{ index: 2, platformMessageId: "m-final" },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards forced-media intent through the core outbound adapter", async () => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// Slack plugin module implements outbound adapter behavior.
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import type { OutboundIdentity } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
resolveOutboundSendDep,
|
||||
type OutboundIdentity,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
attachChannelToResult,
|
||||
type ChannelOutboundAdapter,
|
||||
@@ -316,6 +319,7 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
||||
text: payload.text,
|
||||
});
|
||||
const useSingleDeliveryMarker = mediaUrls.length === 0 && deliveryMessages.length === 1;
|
||||
const sentResults: Awaited<ReturnType<SlackSendFn>>[] = [];
|
||||
return attachChannelToResult(
|
||||
"slack",
|
||||
toSlackOutboundResult(
|
||||
@@ -329,6 +333,9 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
||||
mediaUrl,
|
||||
deliveryQueueId: useSingleDeliveryMarker ? ctx.deliveryQueueId : undefined,
|
||||
}),
|
||||
onResult: (result) => {
|
||||
sentResults.push(result);
|
||||
},
|
||||
finalize: async () => {
|
||||
let lastResult: Awaited<ReturnType<SlackSendFn>> | undefined;
|
||||
for (const message of deliveryMessages) {
|
||||
@@ -345,11 +352,32 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
||||
...(message.textIsSlackPlainText ? { textIsSlackPlainText: true } : {}),
|
||||
deliveryQueueId: useSingleDeliveryMarker ? ctx.deliveryQueueId : undefined,
|
||||
});
|
||||
sentResults.push(lastResult);
|
||||
}
|
||||
if (!lastResult) {
|
||||
throw new Error("Slack rendered presentation produced no deliverable segment");
|
||||
}
|
||||
return lastResult;
|
||||
if (sentResults.length === 1) {
|
||||
return lastResult;
|
||||
}
|
||||
const receipt = createMessageReceiptFromOutboundResults({ results: sentResults });
|
||||
receipt.parts = receipt.parts.map((part, index) => ({ ...part, index }));
|
||||
const questionResult = sentResults.find(
|
||||
(result) => result.meta?.slackQuestionActionIds.length,
|
||||
);
|
||||
return {
|
||||
...lastResult,
|
||||
receipt,
|
||||
...(questionResult?.meta
|
||||
? {
|
||||
meta: {
|
||||
...questionResult.meta,
|
||||
slackQuestionMessageId:
|
||||
questionResult.meta.slackQuestionMessageId ?? questionResult.messageId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -618,30 +618,40 @@ describe("slackOutbound sendPayload", () => {
|
||||
expect(linkButton).not.toHaveProperty("value");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "title",
|
||||
presentation: { title: "x".repeat(151), blocks: [] },
|
||||
},
|
||||
{
|
||||
name: "text block",
|
||||
presentation: { blocks: [{ type: "text", text: "x".repeat(3001) }] },
|
||||
},
|
||||
{
|
||||
name: "context block",
|
||||
presentation: { blocks: [{ type: "context", text: "x".repeat(3001) }] },
|
||||
},
|
||||
] satisfies Array<{
|
||||
name: string;
|
||||
presentation: NonNullable<ReplyPayload["presentation"]>;
|
||||
}>)("keeps the portable fallback for an oversized $name", async ({ presentation }) => {
|
||||
const payload: ReplyPayload = { presentation };
|
||||
|
||||
it("keeps the portable fallback for an oversized title", async () => {
|
||||
const payload: ReplyPayload = { presentation: { title: "x".repeat(151), blocks: [] } };
|
||||
const segments = renderedPresentationSegments(await renderPresentation(payload));
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0]).toMatchObject({ kind: "text", mrkdwn: false });
|
||||
});
|
||||
|
||||
it.each(["text", "context"] as const)(
|
||||
"renders oversized %s blocks as complete bounded native Slack blocks",
|
||||
async (type) => {
|
||||
const text = "x".repeat(3_001);
|
||||
const payload: ReplyPayload = { presentation: { blocks: [{ type, text }] } };
|
||||
const segments = renderedPresentationSegments(await renderPresentation(payload));
|
||||
const [segment] = segments;
|
||||
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segment?.kind).toBe("blocks");
|
||||
if (segment?.kind !== "blocks") {
|
||||
throw new Error("Expected native Slack blocks");
|
||||
}
|
||||
expect(segment.blocks).toHaveLength(2);
|
||||
const chunks = segment.blocks.flatMap((block) => {
|
||||
if (block.type === "section" && "text" in block && block.text?.type === "mrkdwn") {
|
||||
return [block.text.text];
|
||||
}
|
||||
const element =
|
||||
block.type === "context" && "elements" in block ? block.elements[0] : undefined;
|
||||
return element?.type === "mrkdwn" ? [element.text] : [];
|
||||
});
|
||||
expect(chunks.join("")).toBe(text);
|
||||
expect(chunks.every((chunk) => chunk.length <= 3_000)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("starts a new segment when presentation content crosses Slack's block limit", async () => {
|
||||
const payload: ReplyPayload = {
|
||||
channelData: {
|
||||
|
||||
@@ -521,6 +521,31 @@ describe("renderSlackMessagePresentationFallbackText", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["interactive", "presentation"] as const)(
|
||||
"preserves %s text across Slack's 50-block message boundary",
|
||||
(surface) => {
|
||||
const text = "x".repeat(3_000 * 50 + 1);
|
||||
const payload =
|
||||
surface === "interactive"
|
||||
? { interactive: { blocks: [{ type: "text" as const, text }] } }
|
||||
: { presentation: { blocks: [{ type: "text" as const, text }] } };
|
||||
const { segments } = resolveSlackReplyBlockResolution(payload);
|
||||
const blockSegments = segments.flatMap((segment) =>
|
||||
segment.kind === "blocks" ? [segment.blocks] : [],
|
||||
);
|
||||
const delivered = blockSegments.flatMap((blocks) =>
|
||||
blocks.flatMap((block) =>
|
||||
block.type === "section" && "text" in block && block.text?.type === "mrkdwn"
|
||||
? [block.text.text]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
|
||||
expect(blockSegments.map((blocks) => blocks.length)).toEqual([50, 1]);
|
||||
expect(delivered.join("")).toBe(text);
|
||||
},
|
||||
);
|
||||
|
||||
it("subtracts exact legacy mirrors for every typed action family", () => {
|
||||
const presentation = {
|
||||
blocks: [
|
||||
|
||||
@@ -76,7 +76,22 @@ describe("buildSlackInteractiveBlocks", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("truncates Slack render strings to Block Kit limits", () => {
|
||||
it("preserves long legacy text, whitespace, protected entities, and surrogate pairs", () => {
|
||||
const text = `${"x".repeat(2_998)} &🚀tail`;
|
||||
const blocks = buildSlackInteractiveBlocks({ blocks: [{ type: "text", text }] });
|
||||
const sections = blocks.map((block) =>
|
||||
block.type === "section" && "text" in block && block.text?.type === "mrkdwn"
|
||||
? block.text.text
|
||||
: "",
|
||||
);
|
||||
|
||||
expect(sections.join("")).toBe(text);
|
||||
expect(sections).toHaveLength(2);
|
||||
expect(sections.every((section) => section.length <= 3_000)).toBe(true);
|
||||
expect(sections.some((section) => section.includes("&🚀"))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Slack sections and interactive controls within Block Kit limits", () => {
|
||||
const long = "x".repeat(120);
|
||||
const blocks = buildSlackInteractiveBlocks({
|
||||
blocks: [
|
||||
@@ -85,15 +100,24 @@ describe("buildSlackInteractiveBlocks", () => {
|
||||
{ type: "buttons", buttons: [{ label: long, value: long }] },
|
||||
],
|
||||
});
|
||||
const section = blocks[0] as { text?: { text?: string } };
|
||||
const selectBlock = blocks[1] as {
|
||||
const sections = blocks.flatMap((block) =>
|
||||
block.type === "section" && "text" in block && block.text?.type === "mrkdwn"
|
||||
? [block.text.text]
|
||||
: [],
|
||||
);
|
||||
const selectBlock = blocks.find(
|
||||
(block) => block.type === "actions" && block.block_id === "openclaw_reply_select_1",
|
||||
) as {
|
||||
elements?: Array<{ placeholder?: { text?: string } }>;
|
||||
};
|
||||
const buttonBlock = blocks[2] as {
|
||||
const buttonBlock = blocks.find(
|
||||
(block) => block.type === "actions" && block.block_id === "openclaw_reply_buttons_1",
|
||||
) as {
|
||||
elements?: Array<{ value?: string }>;
|
||||
};
|
||||
|
||||
expect((section.text?.text ?? "").length).toBeLessThanOrEqual(3000);
|
||||
expect(sections.join("")).toBe("y".repeat(3_100));
|
||||
expect(sections.every((section) => section.length <= 3_000)).toBe(true);
|
||||
expect((selectBlock.elements?.[0]?.placeholder?.text ?? "").length).toBeLessThanOrEqual(75);
|
||||
expect(buttonBlock.elements?.[0]?.value).toBe(long);
|
||||
});
|
||||
@@ -388,6 +412,31 @@ describe("buildSlackInteractiveBlocks", () => {
|
||||
});
|
||||
|
||||
describe("buildSlackPresentationBlocks", () => {
|
||||
it.each(["text", "context"] as const)(
|
||||
"preserves long %s presentation blocks without truncating their mrkdwn",
|
||||
(type) => {
|
||||
const text = `${"x".repeat(2_998)} &🚀tail`;
|
||||
const presentation: MessagePresentation = { blocks: [{ type, text }] };
|
||||
const blocks = buildSlackPresentationBlocks(presentation);
|
||||
const chunks = blocks.flatMap((block) => {
|
||||
if (block.type === "section" && "text" in block && block.text?.type === "mrkdwn") {
|
||||
return [block.text.text];
|
||||
}
|
||||
const element =
|
||||
block.type === "context" && "elements" in block ? block.elements[0] : undefined;
|
||||
return element?.type === "mrkdwn" ? [element.text] : [];
|
||||
});
|
||||
|
||||
expect(canRenderSlackPresentation(presentation)).toBe(true);
|
||||
expect(chunks.join("")).toBe(text);
|
||||
expect(chunks).toHaveLength(2);
|
||||
expect(chunks.every((chunk) => chunk.length <= 3_000)).toBe(true);
|
||||
expect(
|
||||
blocks.every((block) => block.type === (type === "text" ? "section" : "context")),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("renders question choices with compact private indices", () => {
|
||||
const questionId = "ask_0123456789abcdef0123456789abcdef";
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user