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 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ly-wang19
2026-06-24 19:30:29 +08:00
committed by GitHub
parent 9e68fb1178
commit 1069c60e1e
2 changed files with 34 additions and 2 deletions

View File

@@ -0,0 +1,27 @@
// Slack tests cover truncate plugin behavior.
import { describe, expect, it } from "vitest";
import { truncateSlackText } from "./truncate.js";
describe("truncateSlackText", () => {
it("drops a surrogate-pair emoji whole when it straddles the limit", () => {
// "abc😀def": 😀 (U+1F600) sits at the cut point. Slicing by UTF-16 code unit
// would keep only its high surrogate — a lone \uD83D — before the ellipsis,
// which serializes to an invalid character in the Slack payload.
const out = truncateSlackText("abc😀def", 5);
expect(out).toBe("abc…");
// No dangling high surrogate (a high surrogate not followed by a low one).
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out)).toBe(false);
});
it("truncates plain BMP text unchanged", () => {
expect(truncateSlackText("hello world", 5)).toBe("hell…");
});
it("keeps an emoji that fits before the cut", () => {
expect(truncateSlackText("😀abcdef", 5)).toBe("😀ab…");
});
it("returns the trimmed input unchanged when it fits", () => {
expect(truncateSlackText("ab😀cd", 10)).toBe("ab😀cd");
});
});

View File

@@ -1,11 +1,16 @@
// Slack plugin module implements truncate behavior.
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
export function truncateSlackText(value: string, max: number): string {
const trimmed = value.trim();
if (trimmed.length <= max) {
return trimmed;
}
// Slice on a code-point boundary so a surrogate pair (emoji / astral char)
// straddling the limit is dropped whole, instead of leaving a lone surrogate
// half that serializes to an invalid `\uD83D` in the Slack payload.
if (max <= 1) {
return trimmed.slice(0, max);
return sliceUtf16Safe(trimmed, 0, max);
}
return `${trimmed.slice(0, max - 1)}`;
return `${sliceUtf16Safe(trimmed, 0, max - 1)}`;
}