From 048fe0850edd0fca4d6dde573dea88f5fc4f5f9e Mon Sep 17 00:00:00 2001 From: wangmiao0668000666 Date: Tue, 7 Jul 2026 15:22:06 +0800 Subject: [PATCH] fix(qqbot): use UTF-16-safe truncation for messaging reply and approval previews (#101421) * fix(qqbot): use UTF-16-safe truncation for messaging reply and approval previews * test(qqbot): focus UTF-16 approval regression --------- Co-authored-by: Peter Steinberger --- .../qqbot/src/engine/approval/index.test.ts | 17 ++++++++++++++++- extensions/qqbot/src/engine/approval/index.ts | 3 ++- .../src/engine/messaging/reply-dispatcher.ts | 8 +++----- extensions/qqbot/src/engine/messaging/sender.ts | 3 ++- .../qqbot/src/engine/messaging/streaming-c2c.ts | 7 ++++--- .../engine/messaging/streaming-media-send.ts | 3 ++- 6 files changed, 29 insertions(+), 12 deletions(-) diff --git a/extensions/qqbot/src/engine/approval/index.test.ts b/extensions/qqbot/src/engine/approval/index.test.ts index ba773f0598d6..1ae00afc8fc4 100644 --- a/extensions/qqbot/src/engine/approval/index.test.ts +++ b/extensions/qqbot/src/engine/approval/index.test.ts @@ -1,6 +1,6 @@ // Qqbot tests cover index plugin behavior. import { describe, expect, it } from "vitest"; -import { buildApprovalKeyboard } from "./index.js"; +import { buildApprovalKeyboard, buildExecApprovalText } from "./index.js"; describe("buildApprovalKeyboard", () => { it("omits allow-always when the decision is unavailable", () => { @@ -21,3 +21,18 @@ describe("buildApprovalKeyboard", () => { expect(buttons.map((button) => button.id)).toEqual(["allow", "always", "deny"]); }); }); + +describe("buildExecApprovalText", () => { + it("truncates the command preview on a UTF-16 boundary without splitting surrogate pairs", () => { + const safePrefix = "x".repeat(299); + const text = buildExecApprovalText({ + id: "approval-1", + expiresAtMs: Date.now() + 60_000, + request: { + commandPreview: `${safePrefix}๐ŸŽ‰ trailing text`, + }, + }); + const codeFence = text.split("```\n")[1]?.split("\n```")[0] ?? ""; + expect(codeFence).toBe(safePrefix); + }); +}); diff --git a/extensions/qqbot/src/engine/approval/index.ts b/extensions/qqbot/src/engine/approval/index.ts index c873e9a2f2fd..6234647c3b28 100644 --- a/extensions/qqbot/src/engine/approval/index.ts +++ b/extensions/qqbot/src/engine/approval/index.ts @@ -6,6 +6,7 @@ * - Parse INTERACTION_CREATE button data */ +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { ChatScope, InlineKeyboard, KeyboardButton } from "../types.js"; // ============ Types ============ @@ -61,7 +62,7 @@ export function buildExecApprovalText(request: ExecApprovalRequest): string { const lines: string[] = ["\u{1f510} \u547d\u4ee4\u6267\u884c\u5ba1\u6279", ""]; const cmd = request.request.commandPreview ?? request.request.command ?? ""; if (cmd) { - lines.push(`\`\`\`\n${cmd.slice(0, 300)}\n\`\`\``); + lines.push(`\`\`\`\n${truncateUtf16Safe(cmd, 300)}\n\`\`\``); } if (request.request.cwd) { lines.push(`\u{1f4c1} \u76ee\u5f55: ${request.request.cwd}`); diff --git a/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts b/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts index c589cb499ae1..1b11bc2251aa 100644 --- a/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts +++ b/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts @@ -8,6 +8,7 @@ import crypto from "node:crypto"; import path from "node:path"; import { resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { MediaFileType, type GatewayAccount } from "../types.js"; import { formatFileSize, getImageMimeType, getMaxUploadSize } from "../utils/file-utils.js"; import { formatErrorMessage } from "../utils/format.js"; @@ -281,10 +282,7 @@ function resolveStructuredPayloadPath( } function sanitizeForLog(value: string, maxLen = 200): string { - return value - .replace(/[\r\n\t]/g, " ") - .replaceAll("\0", " ") - .slice(0, maxLen); + return truncateUtf16Safe(value.replace(/[\r\n\t]/g, " ").replaceAll("\0", " "), maxLen); } function describeMediaTargetForLog(pathValue: string, isHttpUrl: boolean): string { @@ -513,7 +511,7 @@ export async function sendTextAsVoiceReply( return false; } - log?.debug?.(`TTS: "${ttsText.slice(0, 50)}..."`); + log?.debug?.(`TTS: "${truncateUtf16Safe(ttsText, 50)}..."`); const ttsResult = await deps.tts.textToSpeech({ text: ttsText, cfg, diff --git a/extensions/qqbot/src/engine/messaging/sender.ts b/extensions/qqbot/src/engine/messaging/sender.ts index c91b875cd6a0..1f72b342c732 100644 --- a/extensions/qqbot/src/engine/messaging/sender.ts +++ b/extensions/qqbot/src/engine/messaging/sender.ts @@ -25,6 +25,7 @@ */ import os from "node:os"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { ApiClient } from "../api/api-client.js"; import { ChunkedMediaApi as ChunkedMediaApiClass } from "../api/media-chunked.js"; import { downloadDirectUploadUrl, MediaApi as MediaApiClass } from "../api/media.js"; @@ -349,7 +350,7 @@ export async function withTokenRetry( if (looksLike401) { log?.warn?.( `Token retry triggered by string heuristic (err is not ApiError). ` + - `Consider propagating ApiError end-to-end. msg=${errMsg.slice(0, 120)}`, + `Consider propagating ApiError end-to-end. msg=${truncateUtf16Safe(errMsg, 120)}`, ); clearTokenCache(creds.appId); const newToken = await getAccessToken(creds.appId, creds.clientSecret); diff --git a/extensions/qqbot/src/engine/messaging/streaming-c2c.ts b/extensions/qqbot/src/engine/messaging/streaming-c2c.ts index f4e8dc9faf67..390514c08865 100644 --- a/extensions/qqbot/src/engine/messaging/streaming-c2c.ts +++ b/extensions/qqbot/src/engine/messaging/streaming-c2c.ts @@ -15,6 +15,7 @@ * it is treated as a new message. */ +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { getNextMsgSeq } from "../api/routes.js"; import type { GatewayAccount } from "../types.js"; import { @@ -543,7 +544,7 @@ export class StreamingController { */ async onDeliver(payload: { text?: string }): Promise { const rawLen = payload.text?.length ?? 0; - const preview = (payload.text ?? "").slice(0, 60).replace(/\n/g, "\\n"); + const preview = truncateUtf16Safe(payload.text ?? "", 60).replace(/\n/g, "\\n"); this.logDebug( `onDeliver: rawLen=${rawLen}, phase=${this.phase}, streamMsgId=${this.streamMsgId}, sentIndex=${this.sentIndex}, sentChunks=${this.sentStreamChunkCount}, firstCB=${this.firstCallbackSource}, preview="${preview}"`, ); @@ -790,7 +791,7 @@ export class StreamingController { } this.logInfo( - `processMediaTags: found <${found.tagName}> at offset ${this.sentIndex}, textBefore="${found.textBefore.slice(0, 40)}"`, + `processMediaTags: found <${found.tagName}> at offset ${this.sentIndex}, textBefore="${truncateUtf16Safe(found.textBefore, 40)}"`, ); // ---- 1.1 ็ปˆ็ป“ๅฝ“ๅ‰ๆตๅผไผš่ฏ๏ผˆๅฆ‚ๆžœๆœ‰็š„่ฏ๏ผ‰ ---- @@ -813,7 +814,7 @@ export class StreamingController { if (found.mediaPath && this.deps.mediaContext) { const item: SendQueueItem = { type: found.itemType, content: found.mediaPath }; this.logDebug( - `processMediaTags: sending ${found.itemType}: ${found.mediaPath.slice(0, 80)}`, + `processMediaTags: sending ${found.itemType}: ${truncateUtf16Safe(found.mediaPath, 80)}`, ); await sendMediaQueue([item], this.deps.mediaContext); this.sentMediaCount++; diff --git a/extensions/qqbot/src/engine/messaging/streaming-media-send.ts b/extensions/qqbot/src/engine/messaging/streaming-media-send.ts index 6c1a3435a205..83ff3b2c7c0c 100644 --- a/extensions/qqbot/src/engine/messaging/streaming-media-send.ts +++ b/extensions/qqbot/src/engine/messaging/streaming-media-send.ts @@ -5,6 +5,7 @@ * ๆ‹†ๅˆ†ใ€่ทฏๅพ„็ผ–็ ไฟฎๅค๏ผŒไปฅๅŠ็ปŸไธ€็š„ๅ‘้€้˜Ÿๅˆ—ๆ‰ง่กŒๅ™จใ€‚ */ +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { GatewayAccount } from "../types.js"; import { normalizePath } from "../utils/platform.js"; import type { OutboundMediaAccessContext } from "./outbound-types.js"; @@ -295,7 +296,7 @@ export async function executeSendQueue( } log?.info( - `${prefix} executeSendQueue: sending ${item.type}: ${item.content.slice(0, 80)}...`, + `${prefix} executeSendQueue: sending ${item.type}: ${truncateUtf16Safe(item.content, 80)}...`, ); if (item.type === "image") {