diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 0260ac93791b..42a7bc650e35 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -1936,15 +1936,15 @@ class QQAdapter(BasePlatformAdapter): ) return None - # 4. Call STT API + # 4. Call STT API and always clean up the temp WAV afterward. logger.debug("[%s] STT: calling ASR on %s", self._log_tag, wav_path) - transcript = await self._call_stt(wav_path) - - # 5. Cleanup temp file try: - os.unlink(wav_path) - except OSError: - pass + transcript = await self._call_stt(wav_path) + finally: + try: + os.unlink(wav_path) + except OSError: + pass if transcript: logger.debug("[%s] STT success: %r", self._log_tag, transcript[:100]) diff --git a/tests/gateway/test_qqbot.py b/tests/gateway/test_qqbot.py index 10d558c82602..349b2a0ed799 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -5,6 +5,7 @@ import os from types import SimpleNamespace from unittest import mock +import httpx import pytest from gateway.config import PlatformConfig @@ -215,6 +216,73 @@ class TestVoiceAttachmentSSRFProtection: assert connected_explicit is False +# --------------------------------------------------------------------------- +# Voice attachment temp-file cleanup +# --------------------------------------------------------------------------- + +class TestVoiceAttachmentTempCleanup: + def _make_adapter(self, **extra): + from gateway.platforms.qqbot import QQAdapter + return QQAdapter(_make_config(**extra)) + + def _setup_download_mocks(self, adapter, content=b"RIFFmock-wav-audio-data"): + response = mock.Mock() + response.content = content + response.headers = {"content-type": "audio/wav"} + response.raise_for_status = mock.Mock() + + adapter._http_client = mock.AsyncMock() + adapter._http_client.get = mock.AsyncMock(return_value=response) + + def test_temp_wav_cleaned_up_on_stt_failure(self): + adapter = self._make_adapter(app_id="a", client_secret="b") + self._setup_download_mocks(adapter) + seen = {} + + async def _raise_transport_error(path): + seen["wav_path"] = path + raise httpx.TransportError("boom") + + with mock.patch("tools.url_safety.is_safe_url", return_value=True): + adapter._call_stt = mock.AsyncMock(side_effect=_raise_transport_error) + transcript = asyncio.run( + adapter._stt_voice_attachment( + "https://cdn.qq.com/voice.silk", + "audio/silk", + "voice.silk", + voice_wav_url="https://cdn.qq.com/voice.wav", + ) + ) + + assert transcript is None + assert "wav_path" in seen + assert not os.path.exists(seen["wav_path"]) + + def test_temp_wav_cleaned_up_on_stt_success(self): + adapter = self._make_adapter(app_id="a", client_secret="b") + self._setup_download_mocks(adapter) + seen = {} + + async def _return_transcript(path): + seen["wav_path"] = path + return "hello from qq voice" + + with mock.patch("tools.url_safety.is_safe_url", return_value=True): + adapter._call_stt = mock.AsyncMock(side_effect=_return_transcript) + transcript = asyncio.run( + adapter._stt_voice_attachment( + "https://cdn.qq.com/voice.silk", + "audio/silk", + "voice.silk", + voice_wav_url="https://cdn.qq.com/voice.wav", + ) + ) + + assert transcript == "hello from qq voice" + assert "wav_path" in seen + assert not os.path.exists(seen["wav_path"]) + + # --------------------------------------------------------------------------- # WebSocket proxy handling # ---------------------------------------------------------------------------