From e7cc10111a196fee015201a5c8de845fce426479 Mon Sep 17 00:00:00 2001 From: Zioywishing Date: Sun, 31 May 2026 13:50:18 +0800 Subject: [PATCH] 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 fb700f049024..44f6aef97fef 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.""" @@ -1809,15 +1818,25 @@ class QQAdapter(BasePlatformAdapter): 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 b0e9e9c53335..50b09d742350 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -139,22 +139,32 @@ class TestIsVoiceContentType: 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