From ee8cb929987af131a69c2d3ce4c69867b2a6cac1 Mon Sep 17 00:00:00 2001 From: Asanilo Date: Sat, 13 Jun 2026 20:04:15 +0800 Subject: [PATCH 1/2] fix(tts): include feishu in want_opus platforms --- tests/tools/test_tts_opus_routing.py | 29 ++++++++++++++++++++++++++++ tools/tts_tool.py | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_tts_opus_routing.py b/tests/tools/test_tts_opus_routing.py index 0073146c3045..0a0b501fb401 100644 --- a/tests/tools/test_tts_opus_routing.py +++ b/tests/tools/test_tts_opus_routing.py @@ -68,3 +68,32 @@ def fake_convert(path: str) -> str: assert result["voice_compatible"] is True assert result["media_tag"] == f"[[audio_as_voice]]\nMEDIA:{opus}" convert.assert_called_once_with(str(out)) + + +def test_minimax_feishu_converts_to_opus_voice(tmp_path, monkeypatch): + out = tmp_path / "speech.mp3" + opus = tmp_path / "speech.ogg" + + def fake_convert(path: str) -> str: + assert path == str(out) + opus.write_bytes(b"ogg") + return str(opus) + + convert = Mock(side_effect=fake_convert) + + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "feishu") + monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: {"provider": "minimax"}) + monkeypatch.setattr( + tts_tool, + "_generate_minimax_tts", + lambda _text, _output, _cfg: (_ := Path(_output).write_bytes(b"mp3")) or _output, + ) + monkeypatch.setattr(tts_tool, "_convert_to_opus", convert) + + result = json.loads(tts_tool.text_to_speech_tool("hello", output_path=str(out))) + + assert result["success"] is True + assert result["file_path"] == str(opus) + assert result["voice_compatible"] is True + assert result["media_tag"] == f"[[audio_as_voice]]\nMEDIA:{opus}" + convert.assert_called_once_with(str(out)) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index c6e7c22de0f0..8dd547ee61cc 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -2064,7 +2064,7 @@ def text_to_speech_tool( # and needs ffmpeg for conversion. from gateway.session_context import get_session_env platform = get_session_env("HERMES_SESSION_PLATFORM", "").lower() - want_opus = (platform == "telegram") + want_opus = platform in {"telegram", "feishu"} # Determine output path if output_path: From 89749a8e5658177f4c2a28cfe9ed87e1ed52a740 Mon Sep 17 00:00:00 2001 From: Asanilo Date: Mon, 15 Jun 2026 11:49:53 +0800 Subject: [PATCH 2/2] fix(feishu): route non-Opus audio as file_type=opus for voice bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _requested_message_type_ was being ignored for non-Opus audio files (MP3/WAV/M4A). When send_voice() passes outbound_message_type='audio', _resolve_outbound_file_routing() still fell through to msg_type=file because it only checked the file extension against _FEISHU_OPUS_UPLOAD_EXTENSIONS. Feishu's im.v1.file.create accepts any audio uploaded as file_type=opus without codec validation — MP3/WAV/M4A all render as voice bubbles when sent with msg_type=audio. Tested end-to-end against the live API. Giving requested_message_type='audio' priority over the extension check fixes the fallback and makes the parameter meaningful. Co-authored-by: @users.noreply.github.com --- gateway/platforms/feishu.py | 6 +++++ tests/gateway/test_feishu.py | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 4814107bacd2..b4df3f82d31c 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -4865,6 +4865,12 @@ def _resolve_outbound_file_routing( ) -> tuple[str, str]: ext = Path(file_path).suffix.lower() + # If the caller explicitly requests audio, honor it. Feishu accepts + # non-Opus audio uploaded as file_type=opus without codec validation, + # so MP3/WAV/M4A all render as voice bubbles when routed this way. + if requested_message_type == "audio": + return "opus", "audio" + if ext in _FEISHU_OPUS_UPLOAD_EXTENSIONS: return "opus", "audio" diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 4d78b454b0ca..4c80cc42e699 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -2491,6 +2491,58 @@ async def _direct(func, *args, **kwargs): self.assertEqual(captured["message_request"].request_body.msg_type, "audio") self.assertEqual(captured["message_request"].request_body.content, '{"file_key": "file_audio_123"}') + def test_send_voice_routes_mp3_as_opus(self): + """MP3 files should be uploaded as file_type=opus when sent via send_voice, + even though .mp3 is not in _FEISHU_OPUS_UPLOAD_EXTENSIONS. Feishu accepts + non-Opus audio uploaded as opus without codec validation.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + captured = {} + + class _FileAPI: + def create(self, request): + captured["upload_request"] = request + return SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(file_key="file_mp3_456"), + ) + + class _MessageAPI: + def create(self, request): + captured["message_request"] = request + return SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(message_id="om_mp3_msg"), + ) + + adapter._client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + file=_FileAPI(), + message=_MessageAPI(), + ) + ) + ) + + async def _direct(func, *args, **kwargs): + return func(*args, **kwargs) + + with tempfile.NamedTemporaryFile("wb", suffix=".mp3", delete=False) as tmp: + tmp.write(b"mp3") + audio_path = tmp.name + + try: + with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + result = asyncio.run(adapter.send_voice(chat_id="oc_chat", audio_path=audio_path)) + finally: + os.unlink(audio_path) + + self.assertTrue(result.success) + self.assertEqual(captured["upload_request"].request_body.file_type, "opus") + self.assertEqual(captured["message_request"].request_body.msg_type, "audio") + @patch.dict(os.environ, {}, clear=True) def test_build_post_payload_extracts_title_and_links(self): from gateway.config import PlatformConfig