fix(qqbot): keep bounded previews UTF-16 safe (#101516)

Co-authored-by: wangmiao0668000666 <wang.miao86@xydigit.com>
This commit is contained in:
Vincent Koc
2026-07-07 02:18:57 -07:00
committed by GitHub
parent 202d90041d
commit ccbd9eb28d
18 changed files with 85 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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