Audio duration fixes for Feishu and Telegram voice messages
Cluster 36005 · gateway. Data as of
Nothing open on GitHub. Fix merged in #65535 on 16 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #65535. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #65535. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and pull requests address missing or zero-duration metadata on voice and audio messages sent via Feishu and Telegram, including ffprobe-based duration extraction and explicit duration parameters in upload payloads. Some items also touch inbound voice routing across multiple platforms.
Open issues0
Open PRs0
Fixes on record
-
#65535 fix(telegram): set duration on voice/audio so long clips don't show 0:00 (#36020 salvage)
files:
plugins/platforms/telegram/adapter.py,scripts/release.py,tests/gateway/test_telegram_voice_duration.py,tools/send_message_tool.pyShow the diff
-
plugins/platforms/telegram/adapter.py@@ -294,6 +294,74 @@ class _MockContextTypes: ".gif": "image/gif", } +def _coerce_duration_seconds(value: Any) -> Optional[int]: + """Round a raw length to whole positive seconds, or None if unusable.""" + try: + secs = int(round(float(value))) + except (TypeError, ValueError): + return None + return secs if secs > 0 else None + + +def _probe_voice_duration_seconds(path: str) -> Optional[int]: + """Best-effort audio length in whole seconds for outgoing voice/audio. + + Telegram only auto-derives a clip's duration from container metadata for + short recordings; longer ones (roughly 5 min+) are sent with duration 0 + and render as ``0:00`` in the player. We read the length locally and pass + it explicitly so the bubble shows the real time. + + Mirrors ``gateway.run._probe_audio_duration``: stdlib ``wave`` for WAV, + then mutagen for OGG/Opus/MP3/M4A metadata, then an ``ffprobe`` fallback. + All three are optional — when none can read the file we return ``None`` + and the caller omits ``duration``, falling back to Telegram's own + (possibly absent) metadata, i.e. the prior behavior. Blocking (mutagen + read + ffprobe subprocess), so call it via ``asyncio.to_thread``. + """ + ext = os.path.splitext(path)[1].lower() + + if ext == ".wav": + try: + import wave + + with wave.open(path, "rb") as wf: + rate = wf.getframerate() or 0 + if rate: + secs = _coerce_duration_seconds(wf.getnframes() / float(rate)) + if secs is not None: + return secs + except Exception: + pass + + try: + import mutagen + + audio = mutagen.File(path) + secs = _coerce_duration_seconds( + getattr(getattr(audio, "info", None), "length", None) + ) + if secs is not None: + return secs + except Exception: + pass + + try: + import shutil + import subprocess + + if shutil.which("ffprobe"): + proc = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", path], + capture_output=True, text=True, timeout=5, + ) + if proc.returncode == 0: + return _coerce_duration_seconds(proc.stdout.strip()) + except Exception: + pass + + return None + def check_telegram_requirements() -> bool: """Check if Telegram dependencies are available. @@ -6150,6 +6218,12 @@ async def send_voice( if not os.path.exists(audio_path): return SendResult(success=False, error=self._missing_media_path_error("Audio", audio_path)) + # Compute duration locally — Telegram drops it for long clips + # (~5 min+), which then show 0:00 in the player. + _duration_secs = await asyncio.to_thread( + _probe_voice_duration_seconds, audio_path + ) + with open(audio_path, "rb") as audio_file: ext = os.path.splitext(audio_path)[1].lower() # .ogg / .opus files -> send as voice (round playable bubble) @@ -6170,6 +6244,7 @@ async def send_voice( "voice": audio_file, "caption": caption[:1024] if caption else None, "reply_to_message_id": reply_to_id, + "duration": _duration_secs, **voice_thread_kwargs, **self._notification_kwargs(metadata), }, @@ -6196,6 +6271,7 @@ async def send_voice( "audio": audio_file, "caption": caption[:1024] if caption else None, "reply_to_message_id": reply_to_id, + "duration": _duration_secs, **audio_thread_kwargs, **self._notification_kwargs(metadata), }, -
scripts/release.py@@ -1958,6 +1958,7 @@ "[redacted:email]": "underthestars-zhy", # PR #46907 co-author (Photon Spectrum project ids) "[redacted:email]": "OmarB97", # PR #43977 (desktop session model metadata) "[redacted:email]": "OmarB97", # PR #43977 (desktop session model metadata) + "[redacted:email]": "szafranski", # PR #36020 (Telegram voice/audio duration) "[redacted:email]": "PhilipAD", # direct email match "[redacted:email]": "qingshan89", # PR #46895 co-author (desktop remote artifact download) "[redacted:email]": "AJ", # PR #44510 co-author (desktop named-profile boot loop) -
tests/gateway/test_telegram_voice_duration.py@@ -0,0 +1,223 @@ +"""Telegram voice/audio messages must carry an explicit duration. + +Telegram only auto-derives a clip's duration from container metadata for short +recordings; longer ones (~5 min+) are sent with duration 0 and render as +``0:00`` in the player. ``TelegramAdapter.send_voice`` now probes the length +locally and passes ``duration=`` to ``sendVoice`` / ``sendAudio`` so the +bubble shows the real time. + +These tests confirm: + 1. ``_probe_voice_duration_seconds`` rounds to whole seconds (real WAV via + stdlib ``wave``), reads OGG/MP3 lengths via mutagen, and degrades to + ``None`` (omit duration) when nothing can read the file. + 2. The ``.ogg`` voice path forwards the probed duration to ``bot.send_voice``. + 3. The ``.mp3`` audio path forwards the probed duration to ``bot.send_audio``. + +Hermetic: no real mutagen / ffprobe required. The WAV path uses stdlib only; +the mutagen path is exercised with a fake module injected into ``sys.modules``. +""" +import sys +import wave +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import PlatformConfig + + +def _ensure_telegram_mock(): + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return + mod = MagicMock() + mod.error.NetworkError = type("NetworkError", (OSError,), {}) + mod.error.TimedOut = type("TimedOut", (OSError,), {}) + mod.error.BadRequest = type("BadRequest", (Exception,), {}) + for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): + sys.modules.setdefault(name, mod) + sys.modules.setdefault("telegram.error", mod.error) + + +_ensure_telegram_mock() + +from plugins.platforms.telegram import adapter as telegram_mod # noqa: E402 +from plugins.platforms.telegram.adapter import ( # noqa: E402 + TelegramAdapter, + _coerce_duration_seconds, + _probe_voice_duration_seconds, +) +from tools.send_message_tool import _send_telegram # noqa: E402 + + +def _write_wav(path, *, rate, frames): + """Write a silent mono WAV of ``frames`` samples at ``rate`` Hz.""" + with wave.open(str(path), "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(1) + wf.setframerate(rate) + wf.writeframes(b"\x00" * frames) + + +# --------------------------------------------------------------------------- +# 1a. WAV path — real stdlib read, rounding to whole seconds +# --------------------------------------------------------------------------- + [redacted:email]( + "rate,frames,expected", + [ + (10, 2900, 290), # 290.0 -> 290 + (10, 2904, 290), # 290.4 -> 290 + (10, 2906, 291), # 290.6 -> 291 + (10, 12, 1), # 1.2 -> 1 + ], +) +def test_probe_wav_rounds_to_whole_seconds(tmp_path, rate, frames, expected): + f = tmp_path / "clip.wav" + _write_wav(f, rate=rate, frames=frames) + assert _probe_voice_duration_seconds(str(f)) == expected + + +# --------------------------------------------------------------------------- +# 1b. mutagen path — fake module so ogg/mp3 work without the real dependency +# --------------------------------------------------------------------------- + +def _inject_fake_mutagen(monkeypatch, length): + fake = SimpleNamespace( + File=lambda _p: SimpleNamespace(info=SimpleNamespace(length=length)) + ) + monkeypatch.setitem(sys.modules, "mutagen", fake) + + +def test_probe_ogg_via_mutagen(monkeypatch, tmp_path): + f = tmp_path / "voice.ogg" + f.write_bytes(b"\x00" * 16) + _inject_fake_mutagen(monkeypatch, length=291.4) + assert _probe_voice_duration_seconds(str(f)) == 291 + + [redacted:email]("bad_length", [0, 0.0, None]) +def test_probe_returns_none_for_missing_length(monkeypatch, tmp_path, bad_length): + f = tmp_path / "voice.ogg" + f.write_bytes(b"\x00" * 16) + _inject_fake_mutagen(monkeypatch, length=bad_length) + # Force the ffprobe fallback off so the result is deterministically None. + import shutil + monkeypatch.setattr(shutil, "which", lambda _n: None) + assert _probe_voice_duration_seconds(str(f)) is None + + +def test_probe_returns_none_when_nothing_can_read(monkeypatch, tmp_path): + """No mutagen, no ffprobe, unknown container -> None (omit duration).""" + f = tmp_path / "blob.bin" + f.write_bytes(b"\x00" * 16) + monkeypatch.setitem(sys.modules, "mutagen", None) # import mutagen -> ImportError + import shutil + monkeypatch.setattr(shutil, "which", lambda _n: None) + assert _probe_voice_duration_seconds(str(f)) is None + + +# --------------------------------------------------------------------------- +# 1c. _coerce_duration_seconds rounding/guard contract +# --------------------------------------------------------------------------- + [redacted:email]( + "value,expected", + [(290.0, 290), (290.6, 291), ("12.7", 13), (0, None), (-3, None), + (None, None), ("", None), ("x", None)], +) +def test_coerce_duration_seconds(value, expected): + assert _coerce_duration_seconds(value) == expected + + +# --------------------------------------------------------------------------- +# 2 + 3. send_voice forwards the probed duration to the Bot API +# --------------------------------------------------------------------------- + +def _make_adapter() -> TelegramAdapter: + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***")) + adapter._bot = MagicMock() + adapter._bot.send_voice = AsyncMock(return_value=MagicMock(message_id=1)) + adapter._bot.send_audio = AsyncMock(return_value=MagicMock(message_id=2)) + return adapter + + [redacted:email] +async def test_voice_send_forwards_duration(monkeypatch, tmp_path): + monkeypatch.setattr( + telegram_mod, "_probe_voice_duration_seconds", lambda _p: 314 + ) + audio = tmp_path / "reply.ogg" + audio.write_bytes(b"\x00" * 16) + + adapter = _make_adapter() + result = await adapter.send_voice("123", str(audio)) + + assert result.success is True + adapter._bot.send_voice.assert_awaited_once() + assert adapter._bot.send_voice.await_args.kwargs["duration"] == 314 + adapter._bot.send_audio.assert_not_awaited() + + [redacted:email] +async def test_audio_send_forwards_duration(monkeypatch, tmp_path): + monkeypatch.setattr( + telegram_mod, "_probe_voice_duration_seconds", lambda _p: 600 + ) + audio = tmp_path / "song.mp3" + audio.write_bytes(b"\x00" * 16) + + adapter = _make_adapter() + result = await adapter.send_voice("123", str(audio)) + + assert result.success is True + adapter._bot.send_audio.assert_awaited_once() + assert adapter._bot.send_audio.await_args.kwargs["duration"] == 600 + + [redacted:email] +async def test_voice_send_omits_unknown_duration(monkeypatch, tmp_path): + """When the probe fails, duration is None — Telegram's own (legacy) behavior.""" + monkeypatch.setattr( + telegram_mod, "_probe_voice_duration_seconds", lambda _p: None + ) + audio = tmp_path / "reply.ogg" + audio.write_bytes(b"\x00" * 16) + + adapter = _make_adapter() + await adapter.send_voice("123", str(audio)) + + assert adapter._bot.send_voice.await_args.kwargs["duration"] is None + + [redacted:email] [redacted:email]( + ("filename", "is_voice", "sender_name"), + [("reply.ogg", True, "send_voice"), ("song.mp3", False, "send_audio")], +) +async def test_standalone_send_includes_duration_on_thread_retry( + monkeypatch, tmp_path, filename, is_voice, sender_name +): + audio = tmp_path / filename + audio.write_bytes(b"audio") + bot = MagicMock() + bot.send_message = AsyncMock() + bot.send_photo = AsyncMock() + bot.send_video = AsyncMock() + bot.send_voice = AsyncMock() + bot.send_audio = AsyncMock() + bot.send_document = AsyncMock() + sender = getattr(bot, sender_name) + sender.side_effect = [ + Exception("Bad Request: message thread not found"), + MagicMock(message_id=3), + ] + monkeypatch.setattr(sys.modules["telegram"], "Bot", lambda **_kwargs: bot) + monkeypatch.setattr(telegram_mod, "_probe_voice_duration_seconds", lambda _path: 314) + + result = await _send_telegram( + "token", "-1001234567890", "", media_files=[(str(audio), is_voice)], thread_id="17585" + ) + + assert result.get("success") is True, result + assert sender.await_count == 2 + assert all(call.kwargs["duration"] == 314 for call in sender.await_args_list) -
tools/send_message_tool.py@@ -1314,6 +1314,14 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No if _tg_caption is not None and not (ext in _VOICE_EXTS and is_voice): media_kwargs["caption"] = _tg_caption media_kwargs["parse_mode"] = send_parse_mode + if (ext in _VOICE_EXTS and is_voice) or ext in _TELEGRAM_SEND_AUDIO_EXTS: + try: + from plugins.platforms.telegram.adapter import _probe_voice_duration_seconds + duration = await asyncio.to_thread(_probe_voice_duration_seconds, media_path) + if duration is not None: + media_kwargs["duration"] = duration + except Exception: + pass try: if ext in _IMAGE_EXTS and not force_document: last_msg = await bot.send_photo(
-
2 PRs closed without merging
From the close comments: merged via another change 1 · superseded by another PR 1
Closed issues · 1
- Closed#36005[Bug]: Telegram voice/audio longer than ~4:50 shows 0:00 (duration not set on send)
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #65535
- Closed issues
- 1
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 16 Jul 2026