Skip to content

Commit 21450b1

Browse files
committed
fix(slack): truncate rich text preview on UTF-16 boundary
summarizeRichTextPreview truncated the block-action rich text preview with String.slice(0, 119) on a UTF-16 code-unit index, so an astral character straddling the 120-char boundary was cut into a lone surrogate in the interaction summary preview. Use truncateUtf16Safe so truncation never splits a surrogate pair, keeping the existing 120-char budget and ellipsis suffix. Adds tests driving the block_actions handler with a rich_text_input at the truncation boundary, asserting the preview stays UTF-16 well formed and that a complete emoji is kept when it fits.
1 parent c6f5725 commit 21450b1

2 files changed

Lines changed: 92 additions & 1 deletion

File tree

extensions/slack/src/monitor/events/interactions.block-actions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
SLACK_REPLY_BUTTON_ACTION_ID,
2222
SLACK_REPLY_SELECT_ACTION_ID,
2323
} from "../../reply-action-ids.js";
24+
import { truncateSlackText } from "../../truncate.js";
2425
import {
2526
authorizeSlackSystemEventSender,
2627
resolveSlackCommandIngress,
@@ -173,7 +174,7 @@ function summarizeRichTextPreview(value: unknown): string | undefined {
173174
return undefined;
174175
}
175176
const max = 120;
176-
return joined.length <= max ? joined : `${joined.slice(0, max - 1)}…`;
177+
return joined.length <= max ? joined : truncateSlackText(joined, max);
177178
}
178179

179180
function readInteractionAction(raw: unknown) {

extensions/slack/src/monitor/events/interactions.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,13 @@ function requireRecord(value: unknown, label: string): Record<string, unknown> {
346346
return value as Record<string, unknown>;
347347
}
348348

349+
function hasLoneSurrogate(value: string): boolean {
350+
return Array.from(value).some((char) => {
351+
const codePoint = char.codePointAt(0) ?? 0;
352+
return codePoint >= 0xd800 && codePoint <= 0xdfff;
353+
});
354+
}
355+
349356
function expectRecordFields(
350357
actual: Record<string, unknown>,
351358
expected: Record<string, unknown>,
@@ -3190,5 +3197,88 @@ describe("registerSlackInteractionEvents", () => {
31903197
expect(Array.isArray(payload.inputs) ? payload.inputs.length : 0).toBeLessThanOrEqual(3);
31913198
expect((payload.inputsOmitted ?? 0) >= 1).toBe(true);
31923199
});
3200+
3201+
it("keeps block action rich text previews UTF-16 safe at the truncation boundary", async () => {
3202+
enqueueSystemEventMock.mockClear();
3203+
const { ctx, getHandler } = createContext();
3204+
registerSlackInteractionEvents({ ctx: ctx as never });
3205+
const handler = getHandler();
3206+
3207+
const boundaryText = `${"x".repeat(118)}😀y`;
3208+
const ack = vi.fn().mockResolvedValue(undefined);
3209+
await handler({
3210+
ack,
3211+
body: {
3212+
user: { id: "U555" },
3213+
channel: { id: "C1" },
3214+
message: {
3215+
ts: "111.222",
3216+
text: "fallback",
3217+
blocks: [{ type: "actions", block_id: "richtext_block", elements: [] }],
3218+
},
3219+
},
3220+
action: {
3221+
type: "rich_text_input",
3222+
action_id: "openclaw:richtext",
3223+
block_id: "richtext_block",
3224+
rich_text_value: {
3225+
type: "rich_text",
3226+
elements: [
3227+
{
3228+
type: "rich_text_section",
3229+
elements: [{ type: "text", text: boundaryText }],
3230+
},
3231+
],
3232+
},
3233+
},
3234+
} as never);
3235+
3236+
expect(ack).toHaveBeenCalled();
3237+
const payload = slackInteractionPayload() as { richTextPreview?: string };
3238+
expect(payload.richTextPreview).toBe(`${"x".repeat(118)}…`);
3239+
expect(payload.richTextPreview?.length).toBeLessThanOrEqual(120);
3240+
expect(hasLoneSurrogate(payload.richTextPreview ?? "")).toBe(false);
3241+
expect(() => encodeURIComponent(payload.richTextPreview ?? "")).not.toThrow();
3242+
});
3243+
3244+
it("keeps complete emoji in rich text previews when the UTF-16 boundary can include it", async () => {
3245+
enqueueSystemEventMock.mockClear();
3246+
const { ctx, getHandler } = createContext();
3247+
registerSlackInteractionEvents({ ctx: ctx as never });
3248+
const handler = getHandler();
3249+
3250+
const text = `${"x".repeat(117)}😀yy`;
3251+
await handler({
3252+
ack: vi.fn().mockResolvedValue(undefined),
3253+
body: {
3254+
user: { id: "U555" },
3255+
channel: { id: "C1" },
3256+
message: {
3257+
ts: "111.333",
3258+
text: "fallback",
3259+
blocks: [{ type: "actions", block_id: "richtext_block", elements: [] }],
3260+
},
3261+
},
3262+
action: {
3263+
type: "rich_text_input",
3264+
action_id: "openclaw:richtext",
3265+
block_id: "richtext_block",
3266+
rich_text_value: {
3267+
type: "rich_text",
3268+
elements: [
3269+
{
3270+
type: "rich_text_section",
3271+
elements: [{ type: "text", text }],
3272+
},
3273+
],
3274+
},
3275+
},
3276+
} as never);
3277+
3278+
const payload = slackInteractionPayload() as { richTextPreview?: string };
3279+
expect(payload.richTextPreview).toBe(`${"x".repeat(117)}😀…`);
3280+
expect(payload.richTextPreview?.length).toBeLessThanOrEqual(120);
3281+
expect(hasLoneSurrogate(payload.richTextPreview ?? "")).toBe(false);
3282+
});
31933283
});
31943284
const selectedDateTimeEpoch = 1_771_632_300;

0 commit comments

Comments
 (0)