From 640e057d412e71e528dccedb1181a02b18e76975 Mon Sep 17 00:00:00 2001 From: Zioywishing Date: Sun, 31 May 2026 13:19:42 +0800 Subject: [PATCH 1/5] fix(qqbot): stop routing file uploads through STT pipeline The _is_voice_content_type() heuristic matched audio file extensions (.wav, .mp3, .ogg, etc.) even when the QQ Bot API explicitly reported content_type='file'. This caused files sent via QQ's file-transfer feature to be routed through the speech-to-text pipeline instead of being saved as regular attachments. The QQ Bot API already distinguishes voice messages (content_type= 'voice') from file uploads (content_type='file'), so filename-based extension sniffing is unnecessary and harmful. Removed the _VOICE_EXTENSIONS fallback; now only content_type is checked. Closes #XXXX --- gateway/platforms/qqbot/adapter.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 5b4a396ed2fd7..f418c1e133a4b 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -1792,25 +1792,17 @@ async def _download_and_cache( @staticmethod def _is_voice_content_type(content_type: str, filename: str) -> bool: - """Check if an attachment is a voice/audio message.""" + """Check if an attachment is a voice/audio message. + + Only trust ``content_type`` — the QQ Bot API explicitly distinguishes + voice messages (``content_type="voice"``) from file uploads + (``content_type="file"``). Filename-extension sniffing is intentionally + omitted: files sent via QQ's file-transfer feature can have audio + extensions (e.g. ``.wav``, ``.mp3``) but must **not** be routed through + the STT pipeline. + """ ct = content_type.strip().lower() - fn = filename.strip().lower() - if ct == "voice" or ct.startswith("audio/"): - return True - _VOICE_EXTENSIONS = ( - ".silk", - ".amr", - ".mp3", - ".wav", - ".ogg", - ".m4a", - ".aac", - ".speex", - ".flac", - ) - if any(fn.endswith(ext) for ext in _VOICE_EXTENSIONS): - return True - return False + return ct == "voice" or ct.startswith("audio/") def _qq_media_headers(self) -> Dict[str, str]: """Return Authorization headers for QQ multimedia CDN downloads. From 86e39bc86176d885c95cf583be326134f3fda179 Mon Sep 17 00:00:00 2001 From: Zioywishing Date: Sun, 31 May 2026 13:31:34 +0800 Subject: [PATCH 2/5] test(qqbot): update voice detection tests for content_type-only logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update TestIsVoiceContentType to match the new behavior: - Empty content_type with audio extensions → False (no sniffing) - File upload with audio extension → False - Added test_file_upload_with_audio_extension for the reported bug case --- tests/gateway/test_qqbot.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/gateway/test_qqbot.py b/tests/gateway/test_qqbot.py index 6516a25f8b2ca..8ee1392059017 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -139,14 +139,21 @@ def test_voice_content_type(self): def test_audio_content_type(self): assert self._fn("audio/mp3", "file.mp3") is True - def test_voice_extension(self): - assert self._fn("", "file.silk") is True + def test_voice_extension_ignored_when_content_type_empty(self): + """content_type='' with audio extension → False (no extension sniffing).""" + assert self._fn("", "file.silk") is False def test_non_voice(self): assert self._fn("image/jpeg", "photo.jpg") is False - def test_audio_extension_amr(self): - assert self._fn("", "recording.amr") is True + def test_audio_extension_amr_ignored_when_content_type_empty(self): + """content_type='' with .amr extension → False (no extension sniffing).""" + assert self._fn("", "recording.amr") is False + + def test_file_upload_with_audio_extension(self): + """File upload with audio extension must NOT be treated as voice.""" + assert self._fn("file", "song.mp3") is False + assert self._fn("file", "audio-30251.instrumental..wav") is False # --------------------------------------------------------------------------- From e3f6dd4ce1bc593757ba73330d651afa68291b0d Mon Sep 17 00:00:00 2001 From: Zioywishing Date: Sun, 31 May 2026 13:50:18 +0800 Subject: [PATCH 3/5] fix(qqbot): keep extension fallback for voice detection, skip only for explicit file uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refined the fix: instead of removing extension-based fallback entirely, only skip it when content_type is explicitly 'file' (or image/video). Empty or unknown content_types still fall back to extension matching as a defensive measure. - content_type='voice' or 'audio/*' → True (API signal) - content_type='file' → False (file transfer, never voice) - content_type='' → extension fallback (defensive) - content_type=unknown → extension fallback (defensive) Added _looks_like_voice() module-level helper and comprehensive tests. --- gateway/platforms/qqbot/adapter.py | 31 ++++++++++++++++++++++++------ tests/gateway/test_qqbot.py | 22 +++++++++++++++------ 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index f418c1e133a4b..2788dedd819b3 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -151,6 +151,15 @@ def _coerce_list(value: Any) -> List[str]: # --------------------------------------------------------------------------- +def _looks_like_voice(filename: str) -> bool: + """Return True if *filename* has a known voice/audio extension.""" + fn = filename.strip().lower() + return any( + fn.endswith(ext) + for ext in (".silk", ".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac", ".speex", ".flac") + ) + + class QQAdapter(BasePlatformAdapter): """QQ Bot adapter backed by the official QQ Bot WebSocket Gateway + REST API.""" @@ -1794,15 +1803,25 @@ async def _download_and_cache( def _is_voice_content_type(content_type: str, filename: str) -> bool: """Check if an attachment is a voice/audio message. - Only trust ``content_type`` — the QQ Bot API explicitly distinguishes - voice messages (``content_type="voice"``) from file uploads - (``content_type="file"``). Filename-extension sniffing is intentionally - omitted: files sent via QQ's file-transfer feature can have audio - extensions (e.g. ``.wav``, ``.mp3``) but must **not** be routed through + The QQ Bot API explicitly sets ``content_type="voice"`` for voice + messages and ``content_type="file"`` for file uploads. When + ``content_type`` is a known non-voice type (``"file"``, ``"image/*"``, + ``"video/*"``), we trust it unconditionally and **skip** extension + sniffing — files sent via QQ's file-transfer feature can have audio + extensions (e.g. ``.wav``, ``.mp3``) but must not be routed through the STT pipeline. + + When ``content_type`` is empty or unrecognised we fall back to + filename-extension matching as a defensive measure. """ ct = content_type.strip().lower() - return ct == "voice" or ct.startswith("audio/") + if ct == "voice" or ct.startswith("audio/"): + return True + # Explicit non-voice types — never treat as voice regardless of extension. + if ct in ("file", "") or ct.startswith("image/") or ct.startswith("video/"): + return ct == "" and _looks_like_voice(filename) + # Unknown content_type — defensive extension fallback. + return _looks_like_voice(filename) def _qq_media_headers(self) -> Dict[str, str]: """Return Authorization headers for QQ multimedia CDN downloads. diff --git a/tests/gateway/test_qqbot.py b/tests/gateway/test_qqbot.py index 8ee1392059017..8e908f2a1eeca 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -139,22 +139,32 @@ def test_voice_content_type(self): def test_audio_content_type(self): assert self._fn("audio/mp3", "file.mp3") is True - def test_voice_extension_ignored_when_content_type_empty(self): - """content_type='' with audio extension → False (no extension sniffing).""" - assert self._fn("", "file.silk") is False + def test_voice_extension_fallback_when_content_type_empty(self): + """content_type='' with audio extension → True (extension fallback).""" + assert self._fn("", "file.silk") is True def test_non_voice(self): assert self._fn("image/jpeg", "photo.jpg") is False - def test_audio_extension_amr_ignored_when_content_type_empty(self): - """content_type='' with .amr extension → False (no extension sniffing).""" - assert self._fn("", "recording.amr") is False + def test_audio_extension_amr_fallback_when_content_type_empty(self): + """content_type='' with .amr extension → True (extension fallback).""" + assert self._fn("", "recording.amr") is True def test_file_upload_with_audio_extension(self): """File upload with audio extension must NOT be treated as voice.""" assert self._fn("file", "song.mp3") is False assert self._fn("file", "audio-30251.instrumental..wav") is False + def test_file_upload_never_voice(self): + """content_type='file' is never voice, regardless of extension.""" + assert self._fn("file", "recording.silk") is False + assert self._fn("file", "voice.amr") is False + + def test_unknown_content_type_extension_fallback(self): + """Unknown content_type falls back to extension matching.""" + assert self._fn("unknown/type", "voice.ogg") is True + assert self._fn("unknown/type", "data.json") is False + # --------------------------------------------------------------------------- # Voice attachment SSRF protection From 27fb0fe03ccb34307619d097af1b954801f5c3a2 Mon Sep 17 00:00:00 2001 From: Zioywishing Date: Sun, 31 May 2026 13:55:40 +0800 Subject: [PATCH 4/5] fix(qqbot): skip voice detection for file uploads (content_type='file') Minimal fix: add 'if ct == "file": return False' before extension matching. The original fallback logic is preserved for empty/unknown content_types. Only the bug case (file uploads with audio extensions) is fixed. Removed the over-engineered _looks_like_voice helper. --- gateway/platforms/qqbot/adapter.py | 40 ++++++++++-------------------- tests/gateway/test_qqbot.py | 10 +------- 2 files changed, 14 insertions(+), 36 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 2788dedd819b3..ff5b6517b47c3 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -151,15 +151,6 @@ def _coerce_list(value: Any) -> List[str]: # --------------------------------------------------------------------------- -def _looks_like_voice(filename: str) -> bool: - """Return True if *filename* has a known voice/audio extension.""" - fn = filename.strip().lower() - return any( - fn.endswith(ext) - for ext in (".silk", ".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac", ".speex", ".flac") - ) - - class QQAdapter(BasePlatformAdapter): """QQ Bot adapter backed by the official QQ Bot WebSocket Gateway + REST API.""" @@ -1801,27 +1792,22 @@ async def _download_and_cache( @staticmethod def _is_voice_content_type(content_type: str, filename: str) -> bool: - """Check if an attachment is a voice/audio message. - - The QQ Bot API explicitly sets ``content_type="voice"`` for voice - messages and ``content_type="file"`` for file uploads. When - ``content_type`` is a known non-voice type (``"file"``, ``"image/*"``, - ``"video/*"``), we trust it unconditionally and **skip** extension - sniffing — files sent via QQ's file-transfer feature can have audio - extensions (e.g. ``.wav``, ``.mp3``) but must not be routed through - the STT pipeline. - - When ``content_type`` is empty or unrecognised we fall back to - filename-extension matching as a defensive measure. - """ + """Check if an attachment is a voice/audio message.""" ct = content_type.strip().lower() + fn = filename.strip().lower() if ct == "voice" or ct.startswith("audio/"): return True - # Explicit non-voice types — never treat as voice regardless of extension. - if ct in ("file", "") or ct.startswith("image/") or ct.startswith("video/"): - return ct == "" and _looks_like_voice(filename) - # Unknown content_type — defensive extension fallback. - return _looks_like_voice(filename) + # QQ file uploads have content_type="file" — never treat as voice, + # even if the filename has an audio extension. + if ct == "file": + return False + _VOICE_EXTENSIONS = ( + ".silk", ".amr", ".mp3", ".wav", ".ogg", + ".m4a", ".aac", ".speex", ".flac", + ) + if any(fn.endswith(ext) for ext in _VOICE_EXTENSIONS): + return True + return False def _qq_media_headers(self) -> Dict[str, str]: """Return Authorization headers for QQ multimedia CDN downloads. diff --git a/tests/gateway/test_qqbot.py b/tests/gateway/test_qqbot.py index 8e908f2a1eeca..bbbac95937951 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -151,20 +151,12 @@ def test_audio_extension_amr_fallback_when_content_type_empty(self): assert self._fn("", "recording.amr") is True def test_file_upload_with_audio_extension(self): - """File upload with audio extension must NOT be treated as voice.""" + """content_type='file' is never voice, even with audio extension.""" assert self._fn("file", "song.mp3") is False assert self._fn("file", "audio-30251.instrumental..wav") is False - - def test_file_upload_never_voice(self): - """content_type='file' is never voice, regardless of extension.""" assert self._fn("file", "recording.silk") is False assert self._fn("file", "voice.amr") is False - def test_unknown_content_type_extension_fallback(self): - """Unknown content_type falls back to extension matching.""" - assert self._fn("unknown/type", "voice.ogg") is True - assert self._fn("unknown/type", "data.json") is False - # --------------------------------------------------------------------------- # Voice attachment SSRF protection From 2f3d334ded6ef8d0c44d4095b788908c1d4c5c53 Mon Sep 17 00:00:00 2001 From: Zioywishing Date: Sun, 31 May 2026 13:57:20 +0800 Subject: [PATCH 5/5] docs(qqbot): clarify comment on file upload guard --- gateway/platforms/qqbot/adapter.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index ff5b6517b47c3..1087d3cb75935 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -1797,8 +1797,9 @@ def _is_voice_content_type(content_type: str, filename: str) -> bool: fn = filename.strip().lower() if ct == "voice" or ct.startswith("audio/"): return True - # QQ file uploads have content_type="file" — never treat as voice, - # even if the filename has an audio extension. + # QQ file uploads have content_type="file". Without this guard, + # any uploaded audio file (e.g. .wav, .mp3) would be misrouted into + # the STT pipeline and never be received as a normal file attachment. if ct == "file": return False _VOICE_EXTENSIONS = (