NousResearch/hermes-agent

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

0open issues
0open PRs
4items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #65535 fix(telegram): set duration on voice/audio so long clips don't show 0:00 (#36020 salvage)

    merged to main

    files: plugins/platforms/telegram/adapter.py, scripts/release.py, tests/gateway/test_telegram_voice_duration.py, tools/send_message_tool.py

    Show 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

  • #36009 fix(telegram): pass explicit duration to send_voice/send_audio for long clips · superseded by another PR → #36005, #65535, #36020
  • #36020 fix(gateway): set duration on Telegram voice/audio so long clips don'… · merged via another change → d73a6f5, #65535
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.00 means 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 Lab

At a glance

Subsystem
gateway
Merged PRs
#65535
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
16 Jul 2026

For your agent

The same facts, typed.