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 <steipete@gmail.com>
This commit is contained in:
wangmiao0668000666
2026-07-07 15:22:06 +08:00
committed by GitHub
parent 08abf83a66
commit 048fe0850e
6 changed files with 29 additions and 12 deletions

View File

@@ -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);
});
});

View File

@@ -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}`);

View File

@@ -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,

View File

@@ -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<T>(
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);

View File

@@ -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<void> {
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++;

View File

@@ -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") {