From 27364b24fe0c636a210473efda25282285605d66 Mon Sep 17 00:00:00 2001
From: szafranski
Date: Sun, 31 May 2026 15:46:53 +0200
Subject: [PATCH] fix(gateway): set duration on Telegram voice/audio so long
clips don't show 0:00
Telegram only auto-derives a voice/audio clip's duration from container
metadata for short recordings; clips longer than ~4:50 are delivered with
duration 0 and render as 0:00 in the player. Probe the length locally
(stdlib wave -> mutagen -> ffprobe) and pass duration explicitly to
sendVoice/sendAudio. Best-effort: when nothing can read the file we omit
duration and fall back to Telegram's prior behavior.
Extracts and hardens the Telegram-only part of the stale, Piper-bundled
PR #7815 (ffprobe-only, predates the send_voice retry/anchor refactor);
relates to #8508.
---
plugins/platforms/telegram/adapter.py | 76 +++++++
tests/gateway/test_telegram_voice_duration.py | 188 ++++++++++++++++++
2 files changed, 264 insertions(+)
create mode 100644 tests/gateway/test_telegram_voice_duration.py
diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py
index 4127765f207c..902cd6ce2ff7 100644
--- a/plugins/platforms/telegram/adapter.py
+++ b/plugins/platforms/telegram/adapter.py
@@ -294,6 +294,74 @@ _TELEGRAM_IMAGE_EXT_TO_MIME = {
".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 @@ class TelegramAdapter(BasePlatformAdapter):
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 @@ class TelegramAdapter(BasePlatformAdapter):
"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 @@ class TelegramAdapter(BasePlatformAdapter):
"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),
},
diff --git a/tests/gateway/test_telegram_voice_duration.py b/tests/gateway/test_telegram_voice_duration.py
new file mode 100644
index 000000000000..307407298f25
--- /dev/null
+++ b/tests/gateway/test_telegram_voice_duration.py
@@ -0,0 +1,188 @@
+"""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,
+)
+
+
+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
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize(
+ "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
+
+
+@pytest.mark.parametrize("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
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize(
+ "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
+
+
+@pytest.mark.asyncio
+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()
+
+
+@pytest.mark.asyncio
+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
+
+
+@pytest.mark.asyncio
+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