From 64e80888d4da955f38461b1f669f105c03adc952 Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Thu, 9 Jul 2026 20:25:29 +0800
Subject: [PATCH] fix(qqbot): bound speech transcription requests (#102028)
Co-authored-by: Peter Steinberger
---
docs/channels/qqbot.md | 5 +++
extensions/qqbot/src/engine/utils/stt.test.ts | 12 ++++++-
extensions/qqbot/src/engine/utils/stt.ts | 32 +++++++++++++++++--
3 files changed, 46 insertions(+), 3 deletions(-)
diff --git a/docs/channels/qqbot.md b/docs/channels/qqbot.md
index 3f3c0cdb319..2243b9a5e72 100644
--- a/docs/channels/qqbot.md
+++ b/docs/channels/qqbot.md
@@ -259,6 +259,11 @@ STT and TTS support two-level configuration with priority fallback:
Set `enabled: false` on either to disable. Account-level TTS overrides use the
same shape as `messages.tts` and deep-merge over channel/global TTS config.
+STT requests time out after 60 seconds by default. Plugin-specific STT uses the
+selected `models.providers..timeoutSeconds` override. Framework audio STT
+uses `tools.media.audio.models[0].timeoutSeconds`, then
+`tools.media.audio.timeoutSeconds`, then the selected provider override.
+
Inbound QQ voice attachments are exposed to agents as audio media metadata
while keeping raw voice files out of generic `MediaPaths`. `[[audio_as_voice]]`
in a plain-text reply synthesizes TTS and sends a native QQ voice message when
diff --git a/extensions/qqbot/src/engine/utils/stt.test.ts b/extensions/qqbot/src/engine/utils/stt.test.ts
index 164aab914dc..d23f6c5e2ea 100644
--- a/extensions/qqbot/src/engine/utils/stt.test.ts
+++ b/extensions/qqbot/src/engine/utils/stt.test.ts
@@ -75,6 +75,7 @@ function requireFirstSsrfRequest(): {
url?: unknown;
auditContext?: unknown;
init?: RequestInit;
+ timeoutMs?: unknown;
} {
const [call] = ssrfRuntimeMocks.fetchWithSsrFGuard.mock.calls;
if (!call) {
@@ -84,6 +85,7 @@ function requireFirstSsrfRequest(): {
url?: unknown;
auditContext?: unknown;
init?: RequestInit;
+ timeoutMs?: unknown;
};
}
@@ -118,6 +120,7 @@ describe("engine/utils/stt", () => {
providers: {
openai: {
apiKey: "provider-key",
+ timeoutSeconds: 45,
},
},
},
@@ -127,6 +130,7 @@ describe("engine/utils/stt", () => {
baseUrl: "https://api.example.test/v1",
apiKey: "provider-key",
model: "whisper-large",
+ timeoutMs: 45_000,
});
});
@@ -136,13 +140,14 @@ describe("engine/utils/stt", () => {
tools: {
media: {
audio: {
+ timeoutSeconds: 90,
models: [{ provider: "local", baseUrl: "https://stt.example.test/", model: "sense" }],
},
},
},
models: {
providers: {
- local: { apiKey: "local-key" },
+ local: { apiKey: "local-key", timeoutSeconds: 120 },
},
},
};
@@ -151,7 +156,11 @@ describe("engine/utils/stt", () => {
baseUrl: "https://stt.example.test",
apiKey: "local-key",
model: "sense",
+ timeoutMs: 90_000,
});
+
+ Object.assign(cfg.tools.media.audio.models[0], { timeoutSeconds: 75 });
+ expect(resolveSTTConfig(cfg)?.timeoutMs).toBe(75_000);
});
it("returns null when no usable STT credentials are configured", () => {
@@ -191,6 +200,7 @@ describe("engine/utils/stt", () => {
const request = requireFirstSsrfRequest();
expect(request.url).toBe("https://api.example.test/v1/audio/transcriptions");
expect(request.auditContext).toBe("qqbot-stt");
+ expect(request.timeoutMs).toBe(60_000);
expect(request.init?.method).toBe("POST");
expect(request.init?.headers).toEqual({ Authorization: "Bearer secret" });
expect(request.init?.body).toBeInstanceOf(FormData);
diff --git a/extensions/qqbot/src/engine/utils/stt.ts b/extensions/qqbot/src/engine/utils/stt.ts
index cc3457ead94..b6cbc8e3926 100644
--- a/extensions/qqbot/src/engine/utils/stt.ts
+++ b/extensions/qqbot/src/engine/utils/stt.ts
@@ -8,6 +8,7 @@
import * as fs from "node:fs";
import path from "node:path";
import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
+import { finiteSecondsToTimerSafeMilliseconds } from "openclaw/plugin-sdk/number-runtime";
import {
readProviderJsonResponse,
readResponseTextLimited,
@@ -22,11 +23,23 @@ import {
} from "./string-normalize.js";
const STT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
+const DEFAULT_STT_TIMEOUT_MS = 60_000;
interface STTConfig {
baseUrl: string;
apiKey: string;
model: string;
+ timeoutMs: number;
+}
+
+function resolveSTTTimeoutMs(...timeoutSeconds: unknown[]): number {
+ for (const value of timeoutSeconds) {
+ const timeoutMs = finiteSecondsToTimerSafeMilliseconds(value);
+ if (timeoutMs !== undefined) {
+ return timeoutMs;
+ }
+ }
+ return DEFAULT_STT_TIMEOUT_MS;
}
/** Resolve the STT configuration from the nested config object. */
@@ -45,7 +58,12 @@ export function resolveSTTConfig(cfg: Record): STTConfig | null
const apiKey = readString(channelStt, "apiKey") ?? readString(providerCfg, "apiKey");
const model = readString(channelStt, "model") ?? "whisper-1";
if (baseUrl && apiKey) {
- return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model };
+ return {
+ baseUrl: baseUrl.replace(/\/+$/, ""),
+ apiKey,
+ model,
+ timeoutMs: resolveSTTTimeoutMs(providerCfg?.timeoutSeconds),
+ };
}
}
@@ -62,7 +80,16 @@ export function resolveSTTConfig(cfg: Record): STTConfig | null
const apiKey = readString(audioModelEntry, "apiKey") ?? readString(providerCfg, "apiKey");
const model = readString(audioModelEntry, "model") ?? "whisper-1";
if (baseUrl && apiKey) {
- return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model };
+ return {
+ baseUrl: baseUrl.replace(/\/+$/, ""),
+ apiKey,
+ model,
+ timeoutMs: resolveSTTTimeoutMs(
+ audioModelEntry.timeoutSeconds,
+ audio?.timeoutSeconds,
+ providerCfg?.timeoutSeconds,
+ ),
+ };
}
}
@@ -90,6 +117,7 @@ export async function transcribeAudio(
const { response: resp, release } = await fetchWithSsrFGuard({
url: `${sttCfg.baseUrl}/audio/transcriptions`,
auditContext: "qqbot-stt",
+ timeoutMs: sttCfg.timeoutMs,
init: {
method: "POST",
headers: { Authorization: `Bearer ${sttCfg.apiKey}` },