Skip to content

Commit 490d376

Browse files
WeeLi-009QiuYuang
authored andcommitted
fix(googlechat): truncate approval card text on UTF-16 boundary (openclaw#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.
1 parent fd21233 commit 490d376

2 files changed

Lines changed: 66 additions & 1 deletion

File tree

extensions/googlechat/src/approval-handler.runtime.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,45 @@ function createDeferred<T>(): {
110110
return { promise, reject, resolve };
111111
}
112112

113+
type CardPayloadWithTextWidgets = {
114+
cardsV2: Array<{
115+
card: {
116+
sections?: Array<{
117+
header?: string;
118+
widgets?: Array<{ textParagraph?: { text: string } }>;
119+
}>;
120+
};
121+
}>;
122+
};
123+
124+
function getTextParagraphText(payload: unknown, header: string): string {
125+
const text = (payload as CardPayloadWithTextWidgets).cardsV2[0]?.card.sections?.find(
126+
(section) => section.header === header,
127+
)?.widgets?.[0]?.textParagraph?.text;
128+
if (typeof text !== "string") {
129+
throw new Error(`Expected ${header} text paragraph`);
130+
}
131+
return text;
132+
}
133+
134+
function isUtf16WellFormed(value: string): boolean {
135+
for (let index = 0; index < value.length; index += 1) {
136+
const codeUnit = value.charCodeAt(index);
137+
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
138+
const nextCodeUnit = index + 1 < value.length ? value.charCodeAt(index + 1) : -1;
139+
if (nextCodeUnit < 0xdc00 || nextCodeUnit > 0xdfff) {
140+
return false;
141+
}
142+
index += 1;
143+
continue;
144+
}
145+
if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
146+
return false;
147+
}
148+
}
149+
return true;
150+
}
151+
113152
describe("googleChatApprovalNativeRuntime", () => {
114153
async function preparePendingDelivery(view = createPendingView()) {
115154
const nowMs = Date.now();
@@ -149,6 +188,31 @@ describe("googleChatApprovalNativeRuntime", () => {
149188
return { pendingPayload, plannedTarget, prepared, request, view };
150189
}
151190

191+
it("keeps truncated pending command card text UTF-16 well formed", async () => {
192+
const view = createPendingView();
193+
view.commandText = `${"a".repeat(1796)}😀${"b".repeat(100)}`;
194+
195+
const { pendingPayload } = await preparePendingDelivery(view);
196+
const commandText = getTextParagraphText(pendingPayload, "Command");
197+
198+
expect(commandText.length).toBeLessThanOrEqual(1800);
199+
expect(commandText.endsWith("...")).toBe(true);
200+
expect(isUtf16WellFormed(commandText)).toBe(true);
201+
expect(JSON.stringify(pendingPayload.cardsV2)).not.toContain("\\ud83d");
202+
});
203+
204+
it("preserves a complete astral character when it fits before the truncation suffix", async () => {
205+
const view = createPendingView();
206+
view.commandText = `${"a".repeat(1795)}😀${"b".repeat(100)}`;
207+
208+
const { pendingPayload } = await preparePendingDelivery(view);
209+
const commandText = getTextParagraphText(pendingPayload, "Command");
210+
211+
expect(commandText).toBe(`${"a".repeat(1795)}😀...`);
212+
expect(commandText.length).toBe(1800);
213+
expect(isUtf16WellFormed(commandText)).toBe(true);
214+
});
215+
152216
it("sends pending cards and updates the delivered message without buttons", async () => {
153217
sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1" });
154218
updateGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1" });

extensions/googlechat/src/approval-handler.runtime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approva
99
import type { ExecApprovalDecision } from "openclaw/plugin-sdk/approval-runtime";
1010
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
1111
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
12+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
1213
import { resolveGoogleChatAccount, type ResolvedGoogleChatAccount } from "./accounts.js";
1314
import { sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js";
1415
import {
@@ -87,7 +88,7 @@ function escapeGoogleChatText(text: string): string {
8788
}
8889

8990
function truncateText(text: string, maxChars = MAX_TEXT_PARAGRAPH_CHARS): string {
90-
return text.length <= maxChars ? text : `${text.slice(0, maxChars - 3)}...`;
91+
return text.length <= maxChars ? text : `${truncateUtf16Safe(text, maxChars - 3)}...`;
9192
}
9293

9394
function buildMetadataText(metadata: readonly { label: string; value: string }[]): string {

0 commit comments

Comments
 (0)