# NousResearch/hermes-agent · Audio duration fixes for Feishu and Telegram voice messages

## Open now

- 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.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #65535. Verify it addresses your case.
- **Recommended:** read PR #65535 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-65535`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member 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.

## Closed / duplicate issues

- #36005: \[Bug\]: Telegram voice/audio longer than ~4:50 shows 0:00 (duration not set on send)

## Prior & competing PRs

- **#65535: fix(telegram): set duration on voice/audio so long clips don't show 0:00 (#36020 salvage)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `plugins/platforms/telegram/adapter.py`, `scripts/release.py`, `tests/gateway/test_telegram_voice_duration.py`, `tools/send_message_tool.py`
  - fix hunks:
  - `plugins/platforms/telegram/adapter.py`

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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),
                             },
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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(
    ```
- **#36009: fix(telegram): pass explicit duration to send_voice/send_audio for long clips**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#36005`, `#65535`, `#36020`
- **#36020: fix(gateway): set duration on Telegram voice/audio so long clips don'…**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `d73a6f5ac`, `#65535`

## Fix status

- Merged to main in PR #65535 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-16`.
- Subsystem: `gateway`
