fix(whatsapp): elide auto-reply text on UTF-16 boundary (#96580)

elide truncated text with String.slice(0, limit) on a UTF-16 code-unit
index, so an astral character straddling the limit was cut into a lone
surrogate; the truncated-char count was also computed from the fixed
limit rather than the actual kept length.

Truncate with truncateUtf16Safe so a surrogate pair is never split, and
derive the truncated-char count from the kept length so the annotation
stays accurate.

Adds tests asserting no lone surrogate when the limit lands inside an
emoji and that a complete astral character is kept when it fits.
This commit is contained in:
llagy009
2026-06-28 00:31:16 +08:00
committed by GitHub
parent b5c662f4f5
commit cb8bc71ff8
2 changed files with 26 additions and 1 deletions
+3 -1
View File
@@ -1,5 +1,6 @@
// Whatsapp plugin module implements util behavior.
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
export function elide(text?: string, limit = 400) {
if (!text) {
@@ -8,7 +9,8 @@ export function elide(text?: string, limit = 400) {
if (text.length <= limit) {
return text;
}
return `${text.slice(0, limit)}… (truncated ${text.length - limit} chars)`;
const truncated = truncateUtf16Safe(text, limit);
return `${truncated}… (truncated ${text.length - truncated.length} chars)`;
}
export function markWhatsAppVisibleDeliveryError(error: unknown): unknown {
@@ -365,6 +365,15 @@ describe("web auto-reply util", () => {
});
describe("elide", () => {
const hasLoneSurrogate = (value: string): boolean =>
Array.from(value).some((char) => {
if (char.length !== 1) {
return false;
}
const codeUnit = char.charCodeAt(0);
return codeUnit >= 0xd800 && codeUnit <= 0xdfff;
});
it("returns undefined for undefined input", () => {
expect(elide(undefined)).toBe(undefined);
});
@@ -376,6 +385,20 @@ describe("web auto-reply util", () => {
it("truncates and annotates when over limit", () => {
expect(elide("abcdef", 3)).toBe("abc… (truncated 3 chars)");
});
it("does not split surrogate pairs when the limit lands inside an emoji", () => {
const output = elide("😀😀😀", 5);
expect(output).toBe("😀😀… (truncated 2 chars)");
expect(hasLoneSurrogate(output ?? "")).toBe(false);
});
it("keeps a complete astral character when it fits before the limit", () => {
const output = elide("ab😀cd", 4);
expect(output).toBe("ab😀… (truncated 2 chars)");
expect(hasLoneSurrogate(output ?? "")).toBe(false);
});
});
describe("isLikelyWhatsAppCryptoError", () => {