{"repo":"NousResearch/hermes-agent","public_id":"36005","display_label":"Audio duration fixes for Feishu and Telegram voice messages","summary":"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.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":65535,"merged_at":"2026-07-16T11:40:35+00:00"},"resolution":{"state":"resolved","by":"pr-65535"},"freshness":{"state":"verified","last_verified_at":"2026-07-16T11:40:35+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":36005,"pr_number":65535,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-16T11:40:35+00:00"}],"members":[{"kind":"issue","number":36005,"title":"[Bug]: Telegram voice/audio longer than ~4:50 shows 0:00 (duration not set on send)","url":null,"state":"closed"},{"kind":"pr","number":36009,"title":"fix(telegram): pass explicit duration to send_voice/send_audio for long clips","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#36005","number":36005},{"kind":"pr","value":"#65535","number":65535},{"kind":"pr","value":"#36020","number":36020}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":36020,"title":"fix(gateway): set duration on Telegram voice/audio so long clips don'…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d73a6f5ac","number":0},{"kind":"pr","value":"#65535","number":65535}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":65535,"title":"fix(telegram): set duration on voice/audio so long clips don't show 0:00 (#36020 salvage)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/telegram/adapter.py","scripts/release.py","tests/gateway/test_telegram_voice_duration.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -294,6 +294,74 @@ class _MockContextTypes:\n     \".gif\": \"image/gif\",\n }\n \n+def _coerce_duration_seconds(value: Any) -> Optional[int]:\n+    \"\"\"Round a raw length to whole positive seconds, or None if unusable.\"\"\"\n+    try:\n+        secs = int(round(float(value)))\n+    except (TypeError, ValueError):\n+        return None\n+    return secs if secs > 0 else None\n+\n+\n+def _probe_voice_duration_seconds(path: str) -> Optional[int]:\n+    \"\"\"Best-effort audio length in whole seconds for outgoing voice/audio.\n+\n+    Telegram only auto-derives a clip's duration from container metadata for\n+    short recordings; longer ones (roughly 5 min+) are sent with duration 0\n+    and render as ``0:00`` in the player. We read the length locally and pass\n+    it explicitly so the bubble shows the real time.\n+\n+    Mirrors ``gateway.run._probe_audio_duration``: stdlib ``wave`` for WAV,\n+    then mutagen for OGG/Opus/MP3/M4A metadata, then an ``ffprobe`` fallback.\n+    All three are optional — when none can read the file we return ``None``\n+    and the caller omits ``duration``, falling back to Telegram's own\n+    (possibly absent) metadata, i.e. the prior behavior. Blocking (mutagen\n+    read + ffprobe subprocess), so call it via ``asyncio.to_thread``.\n+    \"\"\"\n+    ext = os.path.splitext(path)[1].lower()\n+\n+    if ext == \".wav\":\n+        try:\n+            import wave\n+\n+            with wave.open(path, \"rb\") as wf:\n+                rate = wf.getframerate() or 0\n+                if rate:\n+                    secs = _coerce_duration_seconds(wf.getnframes() / float(rate))\n+                    if secs is not None:\n+                        return secs\n+        except Exception:\n+            pass\n+\n+    try:\n+        import mutagen\n+\n+        audio = mutagen.File(path)\n+        secs = _coerce_duration_seconds(\n+            getattr(getattr(audio, \"info\", None), \"length\", None)\n+        )\n+        if secs is not None:\n+            return secs\n+    except Exception:\n+        pass\n+\n+    try:\n+        import shutil\n+        import subprocess\n+\n+        if shutil.which(\"ffprobe\"):\n+            proc = subprocess.run(\n+                [\"ffprobe\", \"-v\", \"error\", \"-show_entries\", \"format=duration\",\n+                 \"-of\", \"default=noprint_wrappers=1:nokey=1\", path],\n+                capture_output=True, text=True, timeout=5,\n+            )\n+            if proc.returncode == 0:\n+                return _coerce_duration_seconds(proc.stdout.strip())\n+    except Exception:\n+        pass\n+\n+    return None\n+\n \n def check_telegram_requirements() -> bool:\n     \"\"\"Check if Telegram dependencies are available.","@@ -6150,6 +6218,12 @@ async def send_voice(\n             if not os.path.exists(audio_path):\n                 return SendResult(success=False, error=self._missing_media_path_error(\"Audio\", audio_path))\n             \n+            # Compute duration locally — Telegram drops it for long clips\n+            # (~5 min+), which then show 0:00 in the player.\n+            _duration_secs = await asyncio.to_thread(\n+                _probe_voice_duration_seconds, audio_path\n+            )\n+\n             with open(audio_path, \"rb\") as audio_file:\n                 ext = os.path.splitext(audio_path)[1].lower()\n                 # .ogg / .opus files -> send as voice (round playable bubble)","@@ -6170,6 +6244,7 @@ async def send_voice(\n                             \"voice\": audio_file,\n                             \"caption\": caption[:1024] if caption else None,\n                             \"reply_to_message_id\": reply_to_id,\n+                            \"duration\": _duration_secs,\n                             **voice_thread_kwargs,\n                             **self._notification_kwargs(metadata),\n                         },","@@ -6196,6 +6271,7 @@ async def send_voice(\n                             \"audio\": audio_file,\n                             \"caption\": caption[:1024] if caption else None,\n                             \"reply_to_message_id\": reply_to_id,\n+                            \"duration\": _duration_secs,\n                             **audio_thread_kwargs,\n                             **self._notification_kwargs(metadata),\n                         },"]},{"file":"scripts/release.py","hunks":["@@ -1958,6 +1958,7 @@\n     \"[redacted:email]\": \"underthestars-zhy\",  # PR #46907 co-author (Photon Spectrum project ids)\n     \"[redacted:email]\": \"OmarB97\",  # PR #43977 (desktop session model metadata)\n     \"[redacted:email]\": \"OmarB97\",  # PR #43977 (desktop session model metadata)\n+    \"[redacted:email]\": \"szafranski\",  # PR #36020 (Telegram voice/audio duration)\n     \"[redacted:email]\": \"PhilipAD\",  # direct email match\n     \"[redacted:email]\": \"qingshan89\",  # PR #46895 co-author (desktop remote artifact download)\n     \"[redacted:email]\": \"AJ\",  # PR #44510 co-author (desktop named-profile boot loop)"]},{"file":"tests/gateway/test_telegram_voice_duration.py","hunks":["@@ -0,0 +1,223 @@\n+\"\"\"Telegram voice/audio messages must carry an explicit duration.\n+\n+Telegram only auto-derives a clip's duration from container metadata for short\n+recordings; longer ones (~5 min+) are sent with duration 0 and render as\n+``0:00`` in the player. ``TelegramAdapter.send_voice`` now probes the length\n+locally and passes ``duration=`` to ``sendVoice`` / ``sendAudio`` so the\n+bubble shows the real time.\n+\n+These tests confirm:\n+  1. ``_probe_voice_duration_seconds`` rounds to whole seconds (real WAV via\n+     stdlib ``wave``), reads OGG/MP3 lengths via mutagen, and degrades to\n+     ``None`` (omit duration) when nothing can read the file.\n+  2. The ``.ogg`` voice path forwards the probed duration to ``bot.send_voice``.\n+  3. The ``.mp3`` audio path forwards the probed duration to ``bot.send_audio``.\n+\n+Hermetic: no real mutagen / ffprobe required. The WAV path uses stdlib only;\n+the mutagen path is exercised with a fake module injected into ``sys.modules``.\n+\"\"\"\n+import sys\n+import wave\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock\n+\n+import pytest\n+\n+from gateway.config import PlatformConfig\n+\n+\n+def _ensure_telegram_mock():\n+    if \"telegram\" in sys.modules and hasattr(sys.modules[\"telegram\"], \"__file__\"):\n+        return\n+    mod = MagicMock()\n+    mod.error.NetworkError = type(\"NetworkError\", (OSError,), {})\n+    mod.error.TimedOut = type(\"TimedOut\", (OSError,), {})\n+    mod.error.BadRequest = type(\"BadRequest\", (Exception,), {})\n+    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\", \"telegram.request\"):\n+        sys.modules.setdefault(name, mod)\n+    sys.modules.setdefault(\"telegram.error\", mod.error)\n+\n+\n+_ensure_telegram_mock()\n+\n+from plugins.platforms.telegram import adapter as telegram_mod  # noqa: E402\n+from plugins.platforms.telegram.adapter import (  # noqa: E402\n+    TelegramAdapter,\n+    _coerce_duration_seconds,\n+    _probe_voice_duration_seconds,\n+)\n+from tools.send_message_tool import _send_telegram  # noqa: E402\n+\n+\n+def _write_wav(path, *, rate, frames):\n+    \"\"\"Write a silent mono WAV of ``frames`` samples at ``rate`` Hz.\"\"\"\n+    with wave.open(str(path), \"wb\") as wf:\n+        wf.setnchannels(1)\n+        wf.setsampwidth(1)\n+        wf.setframerate(rate)\n+        wf.writeframes(b\"\\x00\" * frames)\n+\n+\n+# ---------------------------------------------------------------------------\n+# 1a. WAV path — real stdlib read, rounding to whole seconds\n+# ---------------------------------------------------------------------------\n+\n[redacted:email](\n+    \"rate,frames,expected\",\n+    [\n+        (10, 2900, 290),   # 290.0 -> 290\n+        (10, 2904, 290),   # 290.4 -> 290\n+        (10, 2906, 291),   # 290.6 -> 291\n+        (10, 12, 1),       # 1.2   -> 1\n+    ],\n+)\n+def test_probe_wav_rounds_to_whole_seconds(tmp_path, rate, frames, expected):\n+    f = tmp_path / \"clip.wav\"\n+    _write_wav(f, rate=rate, frames=frames)\n+    assert _probe_voice_duration_seconds(str(f)) == expected\n+\n+\n+# ---------------------------------------------------------------------------\n+# 1b. mutagen path — fake module so ogg/mp3 work without the real dependency\n+# ---------------------------------------------------------------------------\n+\n+def _inject_fake_mutagen(monkeypatch, length):\n+    fake = SimpleNamespace(\n+        File=lambda _p: SimpleNamespace(info=SimpleNamespace(length=length))\n+    )\n+    monkeypatch.setitem(sys.modules, \"mutagen\", fake)\n+\n+\n+def test_probe_ogg_via_mutagen(monkeypatch, tmp_path):\n+    f = tmp_path / \"voice.ogg\"\n+    f.write_bytes(b\"\\x00\" * 16)\n+    _inject_fake_mutagen(monkeypatch, length=291.4)\n+    assert _probe_voice_duration_seconds(str(f)) == 291\n+\n+\n[redacted:email](\"bad_length\", [0, 0.0, None])\n+def test_probe_returns_none_for_missing_length(monkeypatch, tmp_path, bad_length):\n+    f = tmp_path / \"voice.ogg\"\n+    f.write_bytes(b\"\\x00\" * 16)\n+    _inject_fake_mutagen(monkeypatch, length=bad_length)\n+    # Force the ffprobe fallback off so the result is deterministically None.\n+    import shutil\n+    monkeypatch.setattr(shutil, \"which\", lambda _n: None)\n+    assert _probe_voice_duration_seconds(str(f)) is None\n+\n+\n+def test_probe_returns_none_when_nothing_can_read(monkeypatch, tmp_path):\n+    \"\"\"No mutagen, no ffprobe, unknown container -> None (omit duration).\"\"\"\n+    f = tmp_path / \"blob.bin\"\n+    f.write_bytes(b\"\\x00\" * 16)\n+    monkeypatch.setitem(sys.modules, \"mutagen\", None)  # import mutagen -> ImportError\n+    import shutil\n+    monkeypatch.setattr(shutil, \"which\", lambda _n: None)\n+    assert _probe_voice_duration_seconds(str(f)) is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# 1c. _coerce_duration_seconds rounding/guard contract\n+# ---------------------------------------------------------------------------\n+\n[redacted:email](\n+    \"value,expected\",\n+    [(290.0, 290), (290.6, 291), (\"12.7\", 13), (0, None), (-3, None),\n+     (None, None), (\"\", None), (\"x\", None)],\n+)\n+def test_coerce_duration_seconds(value, expected):\n+    assert _coerce_duration_seconds(value) == expected\n+\n+\n+# ---------------------------------------------------------------------------\n+# 2 + 3. send_voice forwards the probed duration to the Bot API\n+# ---------------------------------------------------------------------------\n+\n+def _make_adapter() -> TelegramAdapter:\n+    adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"***\"))\n+    adapter._bot = MagicMock()\n+    adapter._bot.send_voice = AsyncMock(return_value=MagicMock(message_id=1))\n+    adapter._bot.send_audio = AsyncMock(return_value=MagicMock(message_id=2))\n+    return adapter\n+\n+\n[redacted:email]\n+async def test_voice_send_forwards_duration(monkeypatch, tmp_path):\n+    monkeypatch.setattr(\n+        telegram_mod, \"_probe_voice_duration_seconds\", lambda _p: 314\n+    )\n+    audio = tmp_path / \"reply.ogg\"\n+    audio.write_bytes(b\"\\x00\" * 16)\n+\n+    adapter = _make_adapter()\n+    result = await adapter.send_voice(\"123\", str(audio))\n+\n+    assert result.success is True\n+    adapter._bot.send_voice.assert_awaited_once()\n+    assert adapter._bot.send_voice.await_args.kwargs[\"duration\"] == 314\n+    adapter._bot.send_audio.assert_not_awaited()\n+\n+\n[redacted:email]\n+async def test_audio_send_forwards_duration(monkeypatch, tmp_path):\n+    monkeypatch.setattr(\n+        telegram_mod, \"_probe_voice_duration_seconds\", lambda _p: 600\n+    )\n+    audio = tmp_path / \"song.mp3\"\n+    audio.write_bytes(b\"\\x00\" * 16)\n+\n+    adapter = _make_adapter()\n+    result = await adapter.send_voice(\"123\", str(audio))\n+\n+    assert result.success is True\n+    adapter._bot.send_audio.assert_awaited_once()\n+    assert adapter._bot.send_audio.await_args.kwargs[\"duration\"] == 600\n+\n+\n[redacted:email]\n+async def test_voice_send_omits_unknown_duration(monkeypatch, tmp_path):\n+    \"\"\"When the probe fails, duration is None — Telegram's own (legacy) behavior.\"\"\"\n+    monkeypatch.setattr(\n+        telegram_mod, \"_probe_voice_duration_seconds\", lambda _p: None\n+    )\n+    audio = tmp_path / \"reply.ogg\"\n+    audio.write_bytes(b\"\\x00\" * 16)\n+\n+    adapter = _make_adapter()\n+    await adapter.send_voice(\"123\", str(audio))\n+\n+    assert adapter._bot.send_voice.await_args.kwargs[\"duration\"] is None\n+\n+\n[redacted:email]\n[redacted:email](\n+    (\"filename\", \"is_voice\", \"sender_name\"),\n+    [(\"reply.ogg\", True, \"send_voice\"), (\"song.mp3\", False, \"send_audio\")],\n+)\n+async def test_standalone_send_includes_duration_on_thread_retry(\n+    monkeypatch, tmp_path, filename, is_voice, sender_name\n+):\n+    audio = tmp_path / filename\n+    audio.write_bytes(b\"audio\")\n+    bot = MagicMock()\n+    bot.send_message = AsyncMock()\n+    bot.send_photo = AsyncMock()\n+    bot.send_video = AsyncMock()\n+    bot.send_voice = AsyncMock()\n+    bot.send_audio = AsyncMock()\n+    bot.send_document = AsyncMock()\n+    sender = getattr(bot, sender_name)\n+    sender.side_effect = [\n+        Exception(\"Bad Request: message thread not found\"),\n+        MagicMock(message_id=3),\n+    ]\n+    monkeypatch.setattr(sys.modules[\"telegram\"], \"Bot\", lambda **_kwargs: bot)\n+    monkeypatch.setattr(telegram_mod, \"_probe_voice_duration_seconds\", lambda _path: 314)\n+\n+    result = await _send_telegram(\n+        \"token\", \"-1001234567890\", \"\", media_files=[(str(audio), is_voice)], thread_id=\"17585\"\n+    )\n+\n+    assert result.get(\"success\") is True, result\n+    assert sender.await_count == 2\n+    assert all(call.kwargs[\"duration\"] == 314 for call in sender.await_args_list)"]},{"file":"tools/send_message_tool.py","hunks":["@@ -1314,6 +1314,14 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No\n                     if _tg_caption is not None and not (ext in _VOICE_EXTS and is_voice):\n                         media_kwargs[\"caption\"] = _tg_caption\n                         media_kwargs[\"parse_mode\"] = send_parse_mode\n+                    if (ext in _VOICE_EXTS and is_voice) or ext in _TELEGRAM_SEND_AUDIO_EXTS:\n+                        try:\n+                            from plugins.platforms.telegram.adapter import _probe_voice_duration_seconds\n+                            duration = await asyncio.to_thread(_probe_voice_duration_seconds, media_path)\n+                            if duration is not None:\n+                                media_kwargs[\"duration\"] = duration\n+                        except Exception:\n+                            pass\n                     try:\n                         if ext in _IMAGE_EXTS and not force_document:\n                             last_msg = await bot.send_photo(\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}