mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 04:13:57 +00:00
fix(qqbot): keep bounded previews UTF-16 safe (#101516)
Co-authored-by: wangmiao0668000666 <wang.miao86@xydigit.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<T>(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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<string, unknown>);
|
||||
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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<s
|
||||
const downloadDir = getQQBotMediaDir("downloads", "url-fallback");
|
||||
const localFile = await downloadFile(httpUrl, downloadDir);
|
||||
if (!localFile) {
|
||||
debugError(`${caller} fallback: download also failed for ${httpUrl.slice(0, 80)}`);
|
||||
debugError(`${caller} fallback: download also failed for ${truncateUtf16Safe(httpUrl, 80)}`);
|
||||
return null;
|
||||
}
|
||||
debugLog(`${caller} fallback: downloaded → ${localFile}`);
|
||||
|
||||
@@ -33,6 +33,7 @@ export {
|
||||
sendVoice,
|
||||
} from "./outbound-media-send.js";
|
||||
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import type { GatewayAccount } from "../types.js";
|
||||
import type { EngineLogger } from "../types.js";
|
||||
import { formatErrorMessage } from "../utils/format.js";
|
||||
@@ -101,7 +102,7 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
||||
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<OutboundResult> {
|
||||
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") {
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user