Skip to content

Commit 1069c60

Browse files
ly-wang19claude
andauthored
fix(slack): truncate on code-point boundaries to avoid splitting surrogate pairs (#96382)
truncateSlackText sliced by UTF-16 code unit ('trimmed.slice(0, max - 1)'), so an emoji or other astral character straddling the limit was cut in half, leaving a lone high surrogate before the ellipsis — e.g. truncateSlackText('abc😀def', 5) returned 'abc\uD83D…' instead of 'abc…'. That invalid half-character is sent in live Slack payloads (message text and Block Kit section/button/header/option labels, which truncate at limits as small as 75). Use the repo's canonical sliceUtf16Safe (already re-exported from plugin-sdk/text-utility-runtime, the module slack code imports from) so a straddling pair is dropped whole. Behavior is byte-identical for all-BMP input. Co-authored-by: ly-wang19 <[email protected]> Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 9e68fb1 commit 1069c60

2 files changed

Lines changed: 34 additions & 2 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Slack tests cover truncate plugin behavior.
2+
import { describe, expect, it } from "vitest";
3+
import { truncateSlackText } from "./truncate.js";
4+
5+
describe("truncateSlackText", () => {
6+
it("drops a surrogate-pair emoji whole when it straddles the limit", () => {
7+
// "abc😀def": 😀 (U+1F600) sits at the cut point. Slicing by UTF-16 code unit
8+
// would keep only its high surrogate — a lone \uD83D — before the ellipsis,
9+
// which serializes to an invalid character in the Slack payload.
10+
const out = truncateSlackText("abc😀def", 5);
11+
expect(out).toBe("abc…");
12+
// No dangling high surrogate (a high surrogate not followed by a low one).
13+
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out)).toBe(false);
14+
});
15+
16+
it("truncates plain BMP text unchanged", () => {
17+
expect(truncateSlackText("hello world", 5)).toBe("hell…");
18+
});
19+
20+
it("keeps an emoji that fits before the cut", () => {
21+
expect(truncateSlackText("😀abcdef", 5)).toBe("😀ab…");
22+
});
23+
24+
it("returns the trimmed input unchanged when it fits", () => {
25+
expect(truncateSlackText("ab😀cd", 10)).toBe("ab😀cd");
26+
});
27+
});

extensions/slack/src/truncate.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
// Slack plugin module implements truncate behavior.
2+
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
3+
24
export function truncateSlackText(value: string, max: number): string {
35
const trimmed = value.trim();
46
if (trimmed.length <= max) {
57
return trimmed;
68
}
9+
// Slice on a code-point boundary so a surrogate pair (emoji / astral char)
10+
// straddling the limit is dropped whole, instead of leaving a lone surrogate
11+
// half that serializes to an invalid `\uD83D` in the Slack payload.
712
if (max <= 1) {
8-
return trimmed.slice(0, max);
13+
return sliceUtf16Safe(trimmed, 0, max);
914
}
10-
return `${trimmed.slice(0, max - 1)}…`;
15+
return `${sliceUtf16Safe(trimmed, 0, max - 1)}…`;
1116
}

0 commit comments

Comments
 (0)