mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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.
This commit is contained in:
@@ -110,6 +110,45 @@ function createDeferred<T>(): {
|
||||
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" });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user