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}` },