From 2e881ab1c679d1b20c4f352fe98e71236f2eeb99 Mon Sep 17 00:00:00 2001 From: llagy009 Date: Sun, 28 Jun 2026 00:31:26 +0800 Subject: [PATCH] fix(googlechat): truncate approval card text on UTF-16 boundary (#96573) truncateText sliced the approval card text paragraph with String.slice, which can cut through an astral character's surrogate pair (e.g. an emoji straddling the 1797-char limit), leaving a lone surrogate in the card text sent to Google Chat. Use truncateUtf16Safe from the plugin SDK so truncation never splits a surrogate pair, keeping the '...' suffix and the existing length budget. Adds tests asserting the truncated Command card text stays UTF-16 well formed and that an astral character is preserved when it fits. --- .../src/approval-handler.runtime.test.ts | 64 +++++++++++++++++++ .../src/approval-handler.runtime.ts | 3 +- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/extensions/googlechat/src/approval-handler.runtime.test.ts b/extensions/googlechat/src/approval-handler.runtime.test.ts index e80e27f50f58..820e6665b541 100644 --- a/extensions/googlechat/src/approval-handler.runtime.test.ts +++ b/extensions/googlechat/src/approval-handler.runtime.test.ts @@ -110,6 +110,45 @@ function createDeferred(): { return { promise, reject, resolve }; } +type CardPayloadWithTextWidgets = { + cardsV2: Array<{ + card: { + sections?: Array<{ + header?: string; + widgets?: Array<{ textParagraph?: { text: string } }>; + }>; + }; + }>; +}; + +function getTextParagraphText(payload: unknown, header: string): string { + const text = (payload as CardPayloadWithTextWidgets).cardsV2[0]?.card.sections?.find( + (section) => section.header === header, + )?.widgets?.[0]?.textParagraph?.text; + if (typeof text !== "string") { + throw new Error(`Expected ${header} text paragraph`); + } + return text; +} + +function isUtf16WellFormed(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const nextCodeUnit = index + 1 < value.length ? value.charCodeAt(index + 1) : -1; + if (nextCodeUnit < 0xdc00 || nextCodeUnit > 0xdfff) { + return false; + } + index += 1; + continue; + } + if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return false; + } + } + return true; +} + describe("googleChatApprovalNativeRuntime", () => { async function preparePendingDelivery(view = createPendingView()) { const nowMs = Date.now(); @@ -149,6 +188,31 @@ describe("googleChatApprovalNativeRuntime", () => { return { pendingPayload, plannedTarget, prepared, request, view }; } + it("keeps truncated pending command card text UTF-16 well formed", async () => { + const view = createPendingView(); + view.commandText = `${"a".repeat(1796)}😀${"b".repeat(100)}`; + + const { pendingPayload } = await preparePendingDelivery(view); + const commandText = getTextParagraphText(pendingPayload, "Command"); + + expect(commandText.length).toBeLessThanOrEqual(1800); + expect(commandText.endsWith("...")).toBe(true); + expect(isUtf16WellFormed(commandText)).toBe(true); + expect(JSON.stringify(pendingPayload.cardsV2)).not.toContain("\\ud83d"); + }); + + it("preserves a complete astral character when it fits before the truncation suffix", async () => { + const view = createPendingView(); + view.commandText = `${"a".repeat(1795)}😀${"b".repeat(100)}`; + + const { pendingPayload } = await preparePendingDelivery(view); + const commandText = getTextParagraphText(pendingPayload, "Command"); + + expect(commandText).toBe(`${"a".repeat(1795)}😀...`); + expect(commandText.length).toBe(1800); + expect(isUtf16WellFormed(commandText)).toBe(true); + }); + it("sends pending cards and updates the delivered message without buttons", async () => { sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1" }); updateGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1" }); diff --git a/extensions/googlechat/src/approval-handler.runtime.ts b/extensions/googlechat/src/approval-handler.runtime.ts index 39a36782f04a..325fe44c0173 100644 --- a/extensions/googlechat/src/approval-handler.runtime.ts +++ b/extensions/googlechat/src/approval-handler.runtime.ts @@ -9,6 +9,7 @@ import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approva import type { ExecApprovalDecision } from "openclaw/plugin-sdk/approval-runtime"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { resolveGoogleChatAccount, type ResolvedGoogleChatAccount } from "./accounts.js"; import { sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js"; import { @@ -87,7 +88,7 @@ function escapeGoogleChatText(text: string): string { } function truncateText(text: string, maxChars = MAX_TEXT_PARAGRAPH_CHARS): string { - return text.length <= maxChars ? text : `${text.slice(0, maxChars - 3)}...`; + return text.length <= maxChars ? text : `${truncateUtf16Safe(text, maxChars - 3)}...`; } function buildMetadataText(metadata: readonly { label: string; value: string }[]): string {