From ccbd9eb28dfb7a903dbb301ebe42c95fa2c3f05f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 7 Jul 2026 02:18:57 -0700 Subject: [PATCH] fix(qqbot): keep bounded previews UTF-16 safe (#101516) Co-authored-by: wangmiao0668000666 --- .../qqbot/src/engine/api/api-client.test.ts | 9 +++--- extensions/qqbot/src/engine/api/api-client.ts | 3 +- .../src/engine/api/media-chunked.test.ts | 14 ++++++---- .../qqbot/src/engine/api/media-chunked.ts | 7 +++-- extensions/qqbot/src/engine/api/retry.ts | 3 +- .../engine/commands/slash-command-handler.ts | 3 +- .../qqbot/src/engine/gateway/gateway.ts | 3 +- .../src/engine/gateway/inbound-attachments.ts | 3 +- .../qqbot/src/engine/gateway/message-queue.ts | 3 +- .../src/engine/gateway/outbound-dispatch.ts | 3 +- .../src/engine/gateway/stages/quote-stage.ts | 3 +- .../src/engine/messaging/outbound-deliver.ts | 20 +++++++------ .../engine/messaging/outbound-media-send.ts | 28 +++++++++++++++---- .../qqbot/src/engine/messaging/outbound.ts | 5 ++-- extensions/qqbot/src/engine/utils/log.test.ts | 8 ++++++ extensions/qqbot/src/engine/utils/log.ts | 6 ++-- extensions/qqbot/src/engine/utils/stt.test.ts | 8 +++--- extensions/qqbot/src/engine/utils/stt.ts | 3 +- 18 files changed, 85 insertions(+), 47 deletions(-) diff --git a/extensions/qqbot/src/engine/api/api-client.test.ts b/extensions/qqbot/src/engine/api/api-client.test.ts index 9beba470eb33..b0ad0023bb47 100644 --- a/extensions/qqbot/src/engine/api/api-client.test.ts +++ b/extensions/qqbot/src/engine/api/api-client.test.ts @@ -43,9 +43,10 @@ describe("ApiClient", () => { fetchWithSsrFGuardMock.mockReset(); }); - it("bounds error bodies without using response.text()", async () => { + it("bounds error bodies on a UTF-16 boundary without using response.text()", async () => { const release = vi.fn(async () => {}); - const tracked = cancelTrackedResponse(`${"qqbot api unavailable ".repeat(1024)}tail`, { + const safePrefix = "x".repeat(199); + const tracked = cancelTrackedResponse(`${safePrefix}πŸŽ‰${"tail".repeat(4096)}`, { status: 503, headers: { "content-type": "text/plain" }, }); @@ -65,9 +66,7 @@ describe("ApiClient", () => { } expect(error).toBeInstanceOf(ApiError); - expect(String(error)).toContain("API Error [/v2/users/@me] HTTP 503"); - expect(String(error)).toContain("qqbot api unavailable"); - expect(String(error)).not.toContain("tail"); + expect((error as Error).message).toBe(`API Error [/v2/users/@me] HTTP 503: ${safePrefix}`); expect(tracked.wasCanceled()).toBe(true); expect(textSpy).not.toHaveBeenCalled(); expect(release).toHaveBeenCalledTimes(1); diff --git a/extensions/qqbot/src/engine/api/api-client.ts b/extensions/qqbot/src/engine/api/api-client.ts index a8ce31309be4..a9feb32204ed 100644 --- a/extensions/qqbot/src/engine/api/api-client.ts +++ b/extensions/qqbot/src/engine/api/api-client.ts @@ -14,6 +14,7 @@ import { readResponseTextLimited, } from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { ApiError, type ApiClientConfig, type EngineLogger } from "../types.js"; import { formatErrorMessage } from "../utils/format.js"; @@ -215,7 +216,7 @@ export class ApiClient { throw parseErr; } throw new ApiError( - `API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`, + `API Error [${path}] HTTP ${res.status}: ${truncateUtf16Safe(rawBody, 200)}`, res.status, path, ); diff --git a/extensions/qqbot/src/engine/api/media-chunked.test.ts b/extensions/qqbot/src/engine/api/media-chunked.test.ts index f86efcde0731..04cf9b475a44 100644 --- a/extensions/qqbot/src/engine/api/media-chunked.test.ts +++ b/extensions/qqbot/src/engine/api/media-chunked.test.ts @@ -279,7 +279,7 @@ describe("media-chunked: ChunkedMediaApi.uploadChunked", () => { expect(last.totalBytes).toBe(FIXTURE_BUFFER.length); }); - it("bounds COS PUT error bodies without using response.text()", async () => { + it("bounds COS PUT error bodies on UTF-16 boundaries without using response.text()", async () => { vi.useFakeTimers(); const client = mockApiClient(); const tm = mockTokenManager(); @@ -292,8 +292,10 @@ describe("media-chunked: ChunkedMediaApi.uploadChunked", () => { }); const releases = [vi.fn(async () => {}), vi.fn(async () => {}), vi.fn(async () => {})]; + const safeErrorPrefix = "x".repeat(119); + const safeLogPrefix = `${safeErrorPrefix}πŸŽ‰${"y".repeat(38)}`; const trackedResponses = releases.map((release) => { - const tracked = cancelTrackedResponse(`${"cos gateway unavailable ".repeat(1024)}tail`, { + const tracked = cancelTrackedResponse(`${safeLogPrefix}πŸŽ‰${"tail".repeat(4096)}`, { status: 503, statusText: "Service Unavailable", headers: { @@ -335,16 +337,16 @@ describe("media-chunked: ChunkedMediaApi.uploadChunked", () => { await vi.runAllTimersAsync(); const error = await upload; - expect(String(error)).toContain("COS PUT failed: 503 Service Unavailable"); - expect(String(error)).toContain("cos gateway unavailable"); - expect(String(error)).not.toContain("tail"); + expect((error as Error).message).toBe( + `COS PUT failed: 503 Service Unavailable - ${safeErrorPrefix}`, + ); expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(3); for (const tracked of trackedResponses) { expect(tracked.wasCanceled()).toBe(true); expect(tracked.textSpy).not.toHaveBeenCalled(); expect(tracked.release).toHaveBeenCalledTimes(1); } - expect(JSON.stringify(logger.error.mock.calls)).toContain("cos gateway unavailable"); + expect(String(logger.error.mock.calls[0]?.[0]).split("body=")[1]).toBe(safeLogPrefix); expect(JSON.stringify(logger.error.mock.calls)).not.toContain("tail"); }); diff --git a/extensions/qqbot/src/engine/api/media-chunked.ts b/extensions/qqbot/src/engine/api/media-chunked.ts index 7f611b102aec..29036623a5d7 100644 --- a/extensions/qqbot/src/engine/api/media-chunked.ts +++ b/extensions/qqbot/src/engine/api/media-chunked.ts @@ -39,6 +39,7 @@ import type { FileHandle } from "node:fs/promises"; import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; import { sleep } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { MediaSource, OpenedLocalFile } from "../messaging/media-source.js"; import { openLocalFile } from "../messaging/media-source.js"; import { @@ -577,10 +578,10 @@ async function putToPresignedUrl( PART_UPLOAD_ERROR_BODY_LIMIT_BYTES, ).catch(() => ""); logger?.error?.( - `${prefix} PUT part ${partIndex}/${totalParts}: HTTP ${response.status} ${response.statusText} (${elapsed}ms, requestId=${requestId}) body=${body.slice(0, 160)}`, + `${prefix} PUT part ${partIndex}/${totalParts}: HTTP ${response.status} ${response.statusText} (${elapsed}ms, requestId=${requestId}) body=${truncateUtf16Safe(body, 160)}`, ); throw new Error( - `COS PUT failed: ${response.status} ${response.statusText} - ${body.slice(0, 120)}`, + `COS PUT failed: ${response.status} ${response.statusText} - ${truncateUtf16Safe(body, 120)}`, ); } @@ -601,7 +602,7 @@ async function putToPresignedUrl( if (attempt < PART_UPLOAD_MAX_RETRIES) { const delay = 1000 * 2 ** attempt; (logger?.warn ?? logger?.error)?.( - `${prefix} PUT part ${partIndex}/${totalParts} attempt ${attempt + 1} failed (${lastError.message.slice(0, 120)}), retrying in ${delay}ms`, + `${prefix} PUT part ${partIndex}/${totalParts} attempt ${attempt + 1} failed (${truncateUtf16Safe(lastError.message, 120)}), retrying in ${delay}ms`, ); await sleep(delay); } diff --git a/extensions/qqbot/src/engine/api/retry.ts b/extensions/qqbot/src/engine/api/retry.ts index 7ae12971157d..e2ed50a5d738 100644 --- a/extensions/qqbot/src/engine/api/retry.ts +++ b/extensions/qqbot/src/engine/api/retry.ts @@ -11,6 +11,7 @@ */ import { sleep } from "openclaw/plugin-sdk/runtime-env"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { EngineLogger } from "../types.js"; import { formatErrorMessage } from "../utils/format.js"; @@ -88,7 +89,7 @@ export async function withRetry( policy.backoff === "exponential" ? policy.baseDelayMs * 2 ** attempt : policy.baseDelayMs; logger?.debug?.( - `[qqbot:retry] Attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 100)}`, + `[qqbot:retry] Attempt ${attempt + 1} failed, retrying in ${delay}ms: ${truncateUtf16Safe(lastError.message, 100)}`, ); await sleep(delay); } diff --git a/extensions/qqbot/src/engine/commands/slash-command-handler.ts b/extensions/qqbot/src/engine/commands/slash-command-handler.ts index 9a02b8deb446..565f75d88ae7 100644 --- a/extensions/qqbot/src/engine/commands/slash-command-handler.ts +++ b/extensions/qqbot/src/engine/commands/slash-command-handler.ts @@ -5,6 +5,7 @@ * Handles urgent commands, normal slash commands, and file delivery. */ +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { resolveGroupCommandLevelFromAccountConfig } from "../config/group.js"; import type { QueuedMessage } from "../gateway/message-queue.js"; import type { GatewayAccount, EngineLogger } from "../gateway/types.js"; @@ -93,7 +94,7 @@ export async function trySlashCommand( if (isGroup && !commandAuthorized) { return "enqueue"; } - log?.info(`Urgent command detected: ${content.slice(0, 20)}`); + log?.info(`Urgent command detected: ${truncateUtf16Safe(content, 20)}`); return "urgent"; } diff --git a/extensions/qqbot/src/engine/gateway/gateway.ts b/extensions/qqbot/src/engine/gateway/gateway.ts index 5b99086f7fdd..fd6ab9e2a5c0 100644 --- a/extensions/qqbot/src/engine/gateway/gateway.ts +++ b/extensions/qqbot/src/engine/gateway/gateway.ts @@ -1,5 +1,6 @@ // Qqbot plugin module implements gateway behavior. import path from "node:path"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { classifyCoreCommandForGroup, PRIVATE_CHAT_ONLY_TEXT, @@ -61,7 +62,7 @@ export async function startGateway(ctx: CoreGatewayContext): Promise { onMessageSent(account.appId, (refIdx, meta) => { log?.info( - `onMessageSent called: refIdx=${refIdx}, mediaType=${meta.mediaType}, ttsText=${meta.ttsText?.slice(0, 30)}`, + `onMessageSent called: refIdx=${refIdx}, mediaType=${meta.mediaType}, ttsText=${meta.ttsText === undefined ? undefined : truncateUtf16Safe(meta.ttsText, 30)}`, ); const attachments: RefAttachmentSummary[] = []; if (meta.mediaType) { diff --git a/extensions/qqbot/src/engine/gateway/inbound-attachments.ts b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts index f52ca842439e..3a634588518e 100644 --- a/extensions/qqbot/src/engine/gateway/inbound-attachments.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts @@ -1,4 +1,5 @@ // Qqbot plugin module implements inbound attachments behavior. +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { AudioConvertPort } from "../adapter/audio.port.js"; import { downloadFile } from "../utils/file-utils.js"; import { getQQBotMediaDir } from "../utils/platform.js"; @@ -331,7 +332,7 @@ async function processVoiceAttachment( try { const transcript = await transcribeAudio(audioPath, cfg as Record); if (transcript) { - log?.debug?.(`STT transcript: ${transcript.slice(0, 100)}...`); + log?.debug?.(`STT transcript: ${truncateUtf16Safe(transcript, 100)}...`); return { localPath, type: "voice", transcript, transcriptSource: "stt", meta }; } if (asrReferText) { diff --git a/extensions/qqbot/src/engine/gateway/message-queue.ts b/extensions/qqbot/src/engine/gateway/message-queue.ts index f9989d4f4765..90400221e135 100644 --- a/extensions/qqbot/src/engine/gateway/message-queue.ts +++ b/extensions/qqbot/src/engine/gateway/message-queue.ts @@ -1,4 +1,5 @@ // Qqbot plugin module implements message queue behavior. +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { formatErrorMessage } from "../utils/format.js"; const DEFAULT_GLOBAL_QUEUE_SIZE = 1000; @@ -245,7 +246,7 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { for (const cmd of commands) { log?.debug?.( - `Processing command independently for ${peerId}: ${(cmd.content ?? "").trim().slice(0, 50)}`, + `Processing command independently for ${peerId}: ${truncateUtf16Safe((cmd.content ?? "").trim(), 50)}`, ); await processOne(cmd, peerId, "Command processor"); } diff --git a/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts b/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts index 52a00e56adb3..f05821d34345 100644 --- a/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts +++ b/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts @@ -15,6 +15,7 @@ import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inb import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "openclaw/plugin-sdk/reply-chunking"; import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { createQQBotMarkdownChunker } from "../messaging/markdown-table-chunking.js"; import { parseAndSendMediaTags, @@ -240,7 +241,7 @@ export async function dispatchOutbound( } } if (toolTexts.length > 0) { - await sendErrorMessage(toolTexts.slice(-3).join("\n---\n").slice(0, 2000)); + await sendErrorMessage(truncateUtf16Safe(toolTexts.slice(-3).join("\n---\n"), 2000)); } }; diff --git a/extensions/qqbot/src/engine/gateway/stages/quote-stage.ts b/extensions/qqbot/src/engine/gateway/stages/quote-stage.ts index 863a4be343d3..f284b4b66f22 100644 --- a/extensions/qqbot/src/engine/gateway/stages/quote-stage.ts +++ b/extensions/qqbot/src/engine/gateway/stages/quote-stage.ts @@ -7,6 +7,7 @@ * 3. Otherwise β†’ id-only placeholder so the pipeline still knows it's a reply */ +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { formatMessageReferenceForAgent, type AttachmentProcessor, @@ -89,7 +90,7 @@ export async function resolveQuote( attachmentProcessor, ); log?.debug?.( - `Quote detected via msg_elements[0] (cache miss): id=${event.refMsgIdx}, content="${(refBody ?? "").slice(0, 80)}..."`, + `Quote detected via msg_elements[0] (cache miss): id=${event.refMsgIdx}, content="${truncateUtf16Safe(refBody ?? "", 80)}..."`, ); return { id: event.refMsgIdx, diff --git a/extensions/qqbot/src/engine/messaging/outbound-deliver.ts b/extensions/qqbot/src/engine/messaging/outbound-deliver.ts index 7a83cad42822..03629b930647 100644 --- a/extensions/qqbot/src/engine/messaging/outbound-deliver.ts +++ b/extensions/qqbot/src/engine/messaging/outbound-deliver.ts @@ -6,6 +6,7 @@ * `DeliverDeps.mediaSender`. */ +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { GatewayAccount } from "../types.js"; import { formatErrorMessage } from "../utils/format.js"; import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "../utils/image-size.js"; @@ -242,7 +243,7 @@ async function sendTextChunks( allowDm: true, log, onSuccess: (chunk) => - `Sent text chunk (${chunk.length}/${text.length} chars): ${chunk.slice(0, 50)}...`, + `Sent text chunk (${chunk.length}/${text.length} chars): ${truncateUtf16Safe(chunk, 50)}...`, onError: (err) => `Failed to send text chunk: ${formatErrorMessage(err)}`, }); } @@ -271,7 +272,7 @@ export async function sendTextOnlyReply( forcePlainText: true, log, onSuccess: (chunk) => - `Sent text-only chunk (${chunk.length}/${safeText.length} chars): ${chunk.slice(0, 50)}...`, + `Sent text-only chunk (${chunk.length}/${safeText.length} chars): ${truncateUtf16Safe(chunk, 50)}...`, onError: (err) => `Failed to send text-only chunk: ${formatErrorMessage(err)}`, }); } @@ -600,7 +601,7 @@ export async function sendPlainReply( if (!collectedImageUrls.includes(url)) { collectedImageUrls.push(url); log?.debug?.( - `Collected ${isDataUrl ? "Base64" : "media URL"}: ${isDataUrl ? `(length: ${url.length})` : url.slice(0, 80) + "..."}`, + `Collected ${isDataUrl ? "Base64" : "media URL"}: ${isDataUrl ? `(length: ${url.length})` : truncateUtf16Safe(url, 80) + "..."}`, ); } return true; @@ -632,7 +633,7 @@ export async function sendPlainReply( if (url && !collectedImageUrls.includes(url)) { if (isHttpUrl(url)) { collectedImageUrls.push(url); - log?.debug?.(`Extracted HTTP image from markdown: ${url.slice(0, 80)}...`); + log?.debug?.(`Extracted HTTP image from markdown: ${truncateUtf16Safe(url, 80)}...`); } else if (isLocalFilePath(url)) { if (!localMediaToSend.includes(url)) { localMediaToSend.push(url); @@ -650,7 +651,7 @@ export async function sendPlainReply( const url = m[1]; if (url && !collectedImageUrls.includes(url)) { collectedImageUrls.push(url); - log?.debug?.(`Extracted bare image URL: ${url.slice(0, 80)}...`); + log?.debug?.(`Extracted bare image URL: ${truncateUtf16Safe(url, 80)}...`); } } @@ -743,7 +744,7 @@ export async function sendPlainReply( ...(actx.mediaLocalRoots ? { mediaLocalRoots: actx.mediaLocalRoots } : {}), ...(actx.mediaReadFile ? { mediaReadFile: actx.mediaReadFile } : {}), log, - onSuccess: (mediaUrl) => `Forwarded tool media: ${mediaUrl.slice(0, 80)}...`, + onSuccess: (mediaUrl) => `Forwarded tool media: ${truncateUtf16Safe(mediaUrl, 80)}...`, onResultError: (_mediaUrl, error) => `Tool media forward error: ${error}`, onThrownError: (_mediaUrl, error) => `Tool media forward failed: ${error}`, }); @@ -826,7 +827,7 @@ async function sendMarkdownReply( const size = await getImageSize(url); imagesToAppend.push(formatQQBotMarkdownImage(url, size)); log?.debug?.( - `Formatted HTTP image: ${size ? `${size.width}x${size.height}` : "default size"} - ${url.slice(0, 60)}...`, + `Formatted HTTP image: ${size ? `${size.width}x${size.height}` : "default size"} - ${truncateUtf16Safe(url, 60)}...`, ); } catch (err) { log?.debug?.(`Failed to get image size, using default: ${formatErrorMessage(err)}`); @@ -846,7 +847,7 @@ async function sendMarkdownReply( const size = await getImageSize(imgUrl); result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, size)); log?.debug?.( - `Updated image with size: ${size ? `${size.width}x${size.height}` : "default"} - ${imgUrl.slice(0, 60)}...`, + `Updated image with size: ${size ? `${size.width}x${size.height}` : "default"} - ${truncateUtf16Safe(imgUrl, 60)}...`, ); } catch (err) { log?.debug?.( @@ -923,7 +924,8 @@ async function sendPlainTextReply( imageUrl, mediaSender: deps.mediaSender, log, - onSuccess: (nextImageUrl) => `Sent image via sendPhoto: ${nextImageUrl.slice(0, 80)}...`, + onSuccess: (nextImageUrl) => + `Sent image via sendPhoto: ${truncateUtf16Safe(nextImageUrl, 80)}...`, onError: (error) => `Failed to send image: ${error}`, }); } diff --git a/extensions/qqbot/src/engine/messaging/outbound-media-send.ts b/extensions/qqbot/src/engine/messaging/outbound-media-send.ts index 083ba8034ba9..b709d1fd4c44 100644 --- a/extensions/qqbot/src/engine/messaging/outbound-media-send.ts +++ b/extensions/qqbot/src/engine/messaging/outbound-media-send.ts @@ -11,6 +11,7 @@ import { pathExistsSync, resolveLocalPathFromRootsSync, } from "openclaw/plugin-sdk/security-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { GatewayAccount } from "../types.js"; import { MediaFileType } from "../types.js"; import { @@ -456,7 +457,10 @@ export async function sendPhoto( if (localFile) { return await sendPhotoFromLocal(ctx, localFile); } - return { channel: "qqbot", error: `Failed to download image: ${mediaPath.slice(0, 80)}` }; + return { + channel: "qqbot", + error: `Failed to download image: ${truncateUtf16Safe(mediaPath, 80)}`, + }; } if (isLocal) { @@ -464,7 +468,10 @@ export async function sendPhoto( } if (!isHttp && !isData) { - return { channel: "qqbot", error: `Unsupported image source: ${mediaPath.slice(0, 50)}` }; + return { + channel: "qqbot", + error: `Unsupported image source: ${truncateUtf16Safe(mediaPath, 50)}`, + }; } // Remote URL or data: URL β€” try direct upload first, fall back to @@ -620,7 +627,10 @@ export async function sendVoice( if (localFile) { return await sendVoiceFromLocal(ctx, localFile, directUploadFormats, transcodeEnabled); } - return { channel: "qqbot", error: `Failed to download audio: ${mediaPath.slice(0, 80)}` }; + return { + channel: "qqbot", + error: `Failed to download audio: ${truncateUtf16Safe(mediaPath, 80)}`, + }; } return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled); @@ -729,7 +739,10 @@ export async function sendVideoMsg( if (localFile) { return await sendVideoFromLocal(ctx, localFile); } - return { channel: "qqbot", error: `Failed to download video: ${mediaPath.slice(0, 80)}` }; + return { + channel: "qqbot", + error: `Failed to download video: ${truncateUtf16Safe(mediaPath, 80)}`, + }; } try { @@ -841,7 +854,10 @@ export async function sendDocument( if (localFile) { return await sendDocumentFromLocal(ctx, localFile); } - return { channel: "qqbot", error: `Failed to download file: ${mediaPath.slice(0, 80)}` }; + return { + channel: "qqbot", + error: `Failed to download file: ${truncateUtf16Safe(mediaPath, 80)}`, + }; } try { @@ -935,7 +951,7 @@ async function downloadToFallbackDir(httpUrl: string, caller: string): Promise { debugLog( "[qqbot] sendText ctx:", JSON.stringify( - { to, text: text?.slice(0, 50), replyToId, accountId: account.accountId }, + { to, text: truncateUtf16Safe(text, 50), replyToId, accountId: account.accountId }, null, 2, ), @@ -231,7 +232,7 @@ export async function sendText(ctx: OutboundContext): Promise { timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx, }; - debugLog(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`); + debugLog(`[qqbot] sendText: Sent text part: ${truncateUtf16Safe(item.content, 30)}...`); } else if (item.type === "image") { lastResult = await sendPhoto(mediaTarget, item.content); } else if (item.type === "voice") { diff --git a/extensions/qqbot/src/engine/utils/log.test.ts b/extensions/qqbot/src/engine/utils/log.test.ts index adec4d412e2e..537f9062905d 100644 --- a/extensions/qqbot/src/engine/utils/log.test.ts +++ b/extensions/qqbot/src/engine/utils/log.test.ts @@ -18,6 +18,14 @@ describe("QQBot debug logging", () => { expect(sanitizeDebugLogValue("before\nforged\r\tentry")).toBe("before forged entry"); }); + it.each([ + { name: "drops a pair crossing the cap", prefixLength: 4095, expectedEmoji: "" }, + { name: "keeps a pair ending at the cap", prefixLength: 4094, expectedEmoji: "πŸŽ‰" }, + ])("$name", ({ prefixLength, expectedEmoji }) => { + const prefix = "x".repeat(prefixLength); + expect(sanitizeDebugLogValue(`${prefix}πŸŽ‰tail`)).toBe(`${prefix}${expectedEmoji}...`); + }); + it("sanitizes arguments before debug console output", () => { process.env.QQBOT_DEBUG = "1"; const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); diff --git a/extensions/qqbot/src/engine/utils/log.ts b/extensions/qqbot/src/engine/utils/log.ts index 711b2d7ea8b1..27bd87cfa708 100644 --- a/extensions/qqbot/src/engine/utils/log.ts +++ b/extensions/qqbot/src/engine/utils/log.ts @@ -4,10 +4,10 @@ * * Only outputs when the QQBOT_DEBUG environment variable is set, * preventing user message content from leaking in production logs. - * - * Self-contained within engine/ β€” no framework SDK dependency. */ +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; + function isQqbotDebugEnabled(): boolean { const value = process.env.QQBOT_DEBUG; if (typeof value !== "string") { @@ -48,7 +48,7 @@ export function sanitizeDebugLogValue(value: unknown): string { if (sanitized.length <= MAX_LOG_VALUE_CHARS) { return sanitized; } - return `${sanitized.slice(0, MAX_LOG_VALUE_CHARS)}...`; + return `${truncateUtf16Safe(sanitized, MAX_LOG_VALUE_CHARS)}...`; } function formatDebugLogArgs(args: unknown[]): string { diff --git a/extensions/qqbot/src/engine/utils/stt.test.ts b/extensions/qqbot/src/engine/utils/stt.test.ts index 235d815099bc..164aab914dc9 100644 --- a/extensions/qqbot/src/engine/utils/stt.test.ts +++ b/extensions/qqbot/src/engine/utils/stt.test.ts @@ -245,13 +245,14 @@ describe("engine/utils/stt", () => { }); }); - it("bounds STT error bodies without using response.text()", async () => { + it("bounds STT error bodies on a UTF-16 boundary without using response.text()", async () => { await withTempDir("openclaw-qqbot-stt-error-", async (tmpDir) => { const audioPath = path.join(tmpDir, "voice.wav"); fs.writeFileSync(audioPath, Buffer.from([1, 2, 3, 4])); const release = vi.fn(async () => {}); - const tracked = cancelTrackedResponse(`${"stt provider unavailable ".repeat(1024)}tail`, { + const safePrefix = "x".repeat(299); + const tracked = cancelTrackedResponse(`${safePrefix}πŸŽ‰${"tail".repeat(4096)}`, { status: 503, statusText: "Service Unavailable", headers: { "content-type": "text/plain" }, @@ -279,8 +280,7 @@ describe("engine/utils/stt", () => { error = caught; } - expect(String(error)).toContain("STT failed (HTTP 503): stt provider unavailable"); - expect(String(error)).not.toContain("tail"); + expect((error as Error).message).toBe(`STT failed (HTTP 503): ${safePrefix}`); expect(tracked.wasCanceled()).toBe(true); expect(textSpy).not.toHaveBeenCalled(); expect(release).toHaveBeenCalledTimes(1); diff --git a/extensions/qqbot/src/engine/utils/stt.ts b/extensions/qqbot/src/engine/utils/stt.ts index 0e55442117ec..cc3457ead94d 100644 --- a/extensions/qqbot/src/engine/utils/stt.ts +++ b/extensions/qqbot/src/engine/utils/stt.ts @@ -13,6 +13,7 @@ import { readResponseTextLimited, } from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { normalizeOptionalString, asOptionalObjectRecord as asRecord, @@ -100,7 +101,7 @@ export async function transcribeAudio( const detail = await readResponseTextLimited(resp, STT_ERROR_BODY_LIMIT_BYTES).catch( () => "", ); - throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`); + throw new Error(`STT failed (HTTP ${resp.status}): ${truncateUtf16Safe(detail, 300)}`); } const result = await readProviderJsonResponse<{ text?: string }>(resp, "qqbot.stt");