mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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.
This commit is contained in:
parent
eb48e22106
commit
27364b24fe
2 changed files with 264 additions and 0 deletions
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
188
tests/gateway/test_telegram_voice_duration.py
Normal file
188
tests/gateway/test_telegram_voice_duration.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue