diff --git a/extensions/whatsapp/src/auto-reply/util.ts b/extensions/whatsapp/src/auto-reply/util.ts index 5609b61dce8c..31a5d4310ad2 100644 --- a/extensions/whatsapp/src/auto-reply/util.ts +++ b/extensions/whatsapp/src/auto-reply/util.ts @@ -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 { diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts index 8feb6b7e30de..65dbc451ded5 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts @@ -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", () => {