From ce392da4501df85c4c1dfcc6128a3d00c90e5c07 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Mon, 1 Jun 2026 02:19:12 +0800 Subject: [PATCH] fix(telegram): pass explicit duration to send_voice/send_audio for long clips Telegram only auto-derives duration from container metadata for short recordings. For clips longer than ~4 min 50 s it delivers the message with duration 0 unless the sender passes an explicit `duration` kwarg to sendVoice / sendAudio. Add `_probe_audio_duration()` helper that tries mutagen (if installed) for accurate metadata, then falls back to a file-size estimate so the duration is always populated. The helper is called once per send_voice invocation and the result is passed to both the .ogg/.opus voice path and the .mp3/.m4a audio path. Fixes #36005 --- gateway/platforms/telegram.py | 67 ++++++-- tests/gateway/test_telegram_voice_duration.py | 155 ++++++++++++++++++ 2 files changed, 206 insertions(+), 16 deletions(-) create mode 100644 tests/gateway/test_telegram_voice_duration.py diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 4ab36be4a49c6..6781dbc856052 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -331,6 +331,33 @@ def _wrap_markdown_tables(text: str) -> str: return '\n'.join(out) +def _probe_audio_duration(audio_path: str) -> Optional[int]: + """Return the duration of *audio_path* in whole seconds, or ``None``. + + Tries mutagen (if installed) for accurate metadata, then falls back to a + rough file-size estimate so that Telegram always receives a ``duration`` + kwarg — without it, clips longer than ~4 min 50 s render as 0:00. + """ + # --- mutagen (accurate) --- + try: + import mutagen # noqa: F811 + info = mutagen.File(audio_path) + if info is not None and info.info is not None: + return max(1, int(info.info.length)) + except Exception: + pass + + # --- file-size fallback (very rough) --- + try: + size_bytes = os.path.getsize(audio_path) + ext = os.path.splitext(audio_path)[1].lower() + # OGG/Opus voice ≈ 16 kbps; MP3/M4A ≈ 128 kbps + bytes_per_sec = 2000 if ext in {".ogg", ".opus"} else 16000 + return max(1, int(size_bytes / bytes_per_sec)) + except Exception: + return None + + class TelegramAdapter(BasePlatformAdapter): """ Telegram bot adapter. @@ -3697,6 +3724,8 @@ async def send_voice( with open(audio_path, "rb") as audio_file: ext = os.path.splitext(audio_path)[1].lower() + # Probe duration so Telegram shows correct time for long clips. + duration_secs = _probe_audio_duration(audio_path) # .ogg / .opus files -> send as voice (round playable bubble) if ext in {".ogg", ".opus"}: _voice_thread = self._metadata_thread_id(metadata) @@ -3708,16 +3737,19 @@ async def send_voice( reply_to_message_id=reply_to_id, reply_to_mode=self._reply_to_mode ) + voice_kwargs: Dict[str, Any] = { + "chat_id": int(chat_id), + "voice": audio_file, + "caption": caption[:1024] if caption else None, + "reply_to_message_id": reply_to_id, + **voice_thread_kwargs, + **self._notification_kwargs(metadata), + } + if duration_secs is not None: + voice_kwargs["duration"] = duration_secs msg = await self._send_with_dm_topic_reply_anchor_retry( self._bot.send_voice, - { - "chat_id": int(chat_id), - "voice": audio_file, - "caption": caption[:1024] if caption else None, - "reply_to_message_id": reply_to_id, - **voice_thread_kwargs, - **self._notification_kwargs(metadata), - }, + voice_kwargs, metadata, reply_to_id, "voice", @@ -3734,16 +3766,19 @@ async def send_voice( reply_to_message_id=reply_to_id, reply_to_mode=self._reply_to_mode ) + audio_kwargs: Dict[str, Any] = { + "chat_id": int(chat_id), + "audio": audio_file, + "caption": caption[:1024] if caption else None, + "reply_to_message_id": reply_to_id, + **audio_thread_kwargs, + **self._notification_kwargs(metadata), + } + if duration_secs is not None: + audio_kwargs["duration"] = duration_secs msg = await self._send_with_dm_topic_reply_anchor_retry( self._bot.send_audio, - { - "chat_id": int(chat_id), - "audio": audio_file, - "caption": caption[:1024] if caption else None, - "reply_to_message_id": reply_to_id, - **audio_thread_kwargs, - **self._notification_kwargs(metadata), - }, + audio_kwargs, metadata, reply_to_id, "audio", diff --git a/tests/gateway/test_telegram_voice_duration.py b/tests/gateway/test_telegram_voice_duration.py new file mode 100644 index 0000000000000..bafa15bf4faa3 --- /dev/null +++ b/tests/gateway/test_telegram_voice_duration.py @@ -0,0 +1,155 @@ +"""Regression test for issue #36005. + +Telegram's Bot API only auto-derives duration from container metadata for +short clips. For voice/audio longer than ~4 min 50 s it delivers the message +with duration 0 unless the sender passes an explicit ``duration`` kwarg. + +This test verifies that: +1. ``_probe_audio_duration`` returns a sensible integer for OGG and MP3 files. +2. ``TelegramAdapter.send_voice`` passes ``duration`` through to the Bot API + for both voice (ogg/opus) and audio (mp3/m4a) paths. +""" + +import os +import struct +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.platforms.telegram import _probe_audio_duration + + +# --------------------------------------------------------------------------- +# _probe_audio_duration unit tests +# --------------------------------------------------------------------------- + +class TestProbeAudioDuration: + """Unit tests for the ``_probe_audio_duration`` helper.""" + + def test_returns_none_for_missing_file(self): + assert _probe_audio_duration("/nonexistent/path.ogg") is None + + def test_ogg_file_size_fallback(self, tmp_path): + """Without mutagen, falls back to file-size estimate for OGG.""" + ogg = tmp_path / "voice.ogg" + # ~100 KB → 100000 / 2000 = 50 seconds + ogg.write_bytes(b"\x00" * 100_000) + result = _probe_audio_duration(str(ogg)) + assert result is not None + assert result >= 1 + # Should be roughly 50s (±20% tolerance for rounding) + assert 40 <= result <= 60 + + def test_mp3_file_size_fallback(self, tmp_path): + """Without mutagen, falls back to file-size estimate for MP3.""" + mp3 = tmp_path / "audio.mp3" + # ~256 KB → 256000 / 16000 = 16 seconds + mp3.write_bytes(b"\x00" * 256_000) + result = _probe_audio_duration(str(mp3)) + assert result is not None + assert result >= 1 + assert 12 <= result <= 20 + + def test_minimum_duration_is_one(self, tmp_path): + """Even a tiny file should report at least 1 second.""" + tiny = tmp_path / "tiny.ogg" + tiny.write_bytes(b"\x00" * 10) + result = _probe_audio_duration(str(tiny)) + assert result is not None + assert result >= 1 + + +# --------------------------------------------------------------------------- +# Integration: send_voice passes duration +# --------------------------------------------------------------------------- + +class TestSendVoicePassesDuration: + """Verify that ``TelegramAdapter.send_voice`` forwards ``duration``.""" + + @pytest.mark.asyncio + async def test_voice_path_includes_duration(self, tmp_path): + """OGG voice calls should include ``duration`` in kwargs.""" + from gateway.platforms.telegram import TelegramAdapter + from gateway.config import PlatformConfig, Platform + + ogg = tmp_path / "voice.ogg" + ogg.write_bytes(b"\x00" * 200_000) # ~100s at 2kB/s + + adapter = object.__new__(TelegramAdapter) + adapter._bot = MagicMock() + adapter._reply_to_mode = "quote" + + # Mock internal helpers + adapter._metadata_thread_id = MagicMock(return_value=None) + adapter._reply_to_message_id_for_send = MagicMock(return_value=None) + adapter._thread_kwargs_for_send = MagicMock(return_value={}) + adapter._notification_kwargs = MagicMock(return_value={}) + + sent_kwargs = {} + + async def _capture_send_voice(**kwargs): + sent_kwargs.update(kwargs) + return SimpleNamespace(message_id=42) + + async def _fake_retry(fn, kw, *args, **kwargs): + return await _capture_send_voice(**kw) + + adapter._send_with_dm_topic_reply_anchor_retry = AsyncMock(side_effect=_fake_retry) + + with patch("os.path.exists", return_value=True): + result = await adapter.send_voice( + chat_id="12345", + audio_path=str(ogg), + caption=None, + reply_to=None, + metadata=None, + ) + + assert result.success is True + assert "duration" in sent_kwargs + assert isinstance(sent_kwargs["duration"], int) + assert sent_kwargs["duration"] >= 1 + + @pytest.mark.asyncio + async def test_audio_path_includes_duration(self, tmp_path): + """MP3 audio calls should include ``duration`` in kwargs.""" + from gateway.platforms.telegram import TelegramAdapter + + mp3 = tmp_path / "audio.mp3" + mp3.write_bytes(b"\x00" * 320_000) # ~20s at 16kB/s + + adapter = object.__new__(TelegramAdapter) + adapter._bot = MagicMock() + adapter._reply_to_mode = "quote" + + adapter._metadata_thread_id = MagicMock(return_value=None) + adapter._reply_to_message_id_for_send = MagicMock(return_value=None) + adapter._thread_kwargs_for_send = MagicMock(return_value={}) + adapter._notification_kwargs = MagicMock(return_value={}) + + sent_kwargs = {} + + async def _capture_send_audio(**kwargs): + sent_kwargs.update(kwargs) + return SimpleNamespace(message_id=43) + + async def _fake_retry(fn, kw, *args, **kwargs): + return await _capture_send_audio(**kw) + + adapter._send_with_dm_topic_reply_anchor_retry = AsyncMock(side_effect=_fake_retry) + + with patch("os.path.exists", return_value=True): + result = await adapter.send_voice( + chat_id="12345", + audio_path=str(mp3), + caption="test", + reply_to=None, + metadata=None, + ) + + assert result.success is True + assert "duration" in sent_kwargs + assert isinstance(sent_kwargs["duration"], int) + assert sent_kwargs["duration"] >= 1