fix(qqbot): bound speech transcription requests (#102028)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Alix-007 2026-07-09 20:25:29 +08:00 committed by GitHub
parent 0535679083
commit 64e80888d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 46 additions and 3 deletions

View file

@ -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.<id>.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

View file

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

View file

@ -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<string, unknown>): 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<string, unknown>): 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}` },