Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 51 additions & 16 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,33 @@ def _wrap_markdown_tables(text: str) -> str:
return '\n'.join(out)


def _probe_audio_duration(audio_path: str) -> Optional[int]:
"""Return the duration of *audio_path* in whole seconds, or ``None``.

Tries mutagen (if installed) for accurate metadata, then falls back to a
rough file-size estimate so that Telegram always receives a ``duration``
kwarg β€” without it, clips longer than ~4 min 50 s render as 0:00.
"""
# --- mutagen (accurate) ---
try:
import mutagen # noqa: F811
info = mutagen.File(audio_path)
if info is not None and info.info is not None:
return max(1, int(info.info.length))
except Exception:
pass

# --- file-size fallback (very rough) ---
try:
size_bytes = os.path.getsize(audio_path)
ext = os.path.splitext(audio_path)[1].lower()
# OGG/Opus voice β‰ˆ 16 kbps; MP3/M4A β‰ˆ 128 kbps
bytes_per_sec = 2000 if ext in {".ogg", ".opus"} else 16000

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a guessed bitrate rather than a duration probe: OGG/Opus and MP3/M4A can use different or variable bitrates, so this may make Telegram display an incorrect duration. Prefer omitting duration when metadata cannot be read rather than sending an estimate.

return max(1, int(size_bytes / bytes_per_sec))
except Exception:
return None


class TelegramAdapter(BasePlatformAdapter):
"""
Telegram bot adapter.
Expand Down Expand Up @@ -3697,6 +3724,8 @@ async def send_voice(

with open(audio_path, "rb") as audio_file:
ext = os.path.splitext(audio_path)[1].lower()
# Probe duration so Telegram shows correct time for long clips.
duration_secs = _probe_audio_duration(audio_path)
# .ogg / .opus files -> send as voice (round playable bubble)
if ext in {".ogg", ".opus"}:
_voice_thread = self._metadata_thread_id(metadata)
Expand All @@ -3708,16 +3737,19 @@ async def send_voice(
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
)
voice_kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"voice": audio_file,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
**voice_thread_kwargs,
**self._notification_kwargs(metadata),
}
if duration_secs is not None:
voice_kwargs["duration"] = duration_secs
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_voice,
{
"chat_id": int(chat_id),
"voice": audio_file,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
**voice_thread_kwargs,
**self._notification_kwargs(metadata),
},
voice_kwargs,
metadata,
reply_to_id,
"voice",
Expand All @@ -3734,16 +3766,19 @@ async def send_voice(
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode
)
audio_kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"audio": audio_file,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
**audio_thread_kwargs,
**self._notification_kwargs(metadata),
}
if duration_secs is not None:
audio_kwargs["duration"] = duration_secs
msg = await self._send_with_dm_topic_reply_anchor_retry(
self._bot.send_audio,
{
"chat_id": int(chat_id),
"audio": audio_file,
"caption": caption[:1024] if caption else None,
"reply_to_message_id": reply_to_id,
**audio_thread_kwargs,
**self._notification_kwargs(metadata),
},
audio_kwargs,
metadata,
reply_to_id,
"audio",
Expand Down
155 changes: 155 additions & 0 deletions tests/gateway/test_telegram_voice_duration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Regression test for issue #36005.

Telegram's Bot API only auto-derives duration from container metadata for
short clips. For voice/audio longer than ~4 min 50 s it delivers the message
with duration 0 unless the sender passes an explicit ``duration`` kwarg.

This test verifies that:
1. ``_probe_audio_duration`` returns a sensible integer for OGG and MP3 files.
2. ``TelegramAdapter.send_voice`` passes ``duration`` through to the Bot API
for both voice (ogg/opus) and audio (mp3/m4a) paths.
"""

import os
import struct
import tempfile
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from gateway.platforms.telegram import _probe_audio_duration


# ---------------------------------------------------------------------------
# _probe_audio_duration unit tests
# ---------------------------------------------------------------------------

class TestProbeAudioDuration:
"""Unit tests for the ``_probe_audio_duration`` helper."""

def test_returns_none_for_missing_file(self):
assert _probe_audio_duration("/nonexistent/path.ogg") is None

def test_ogg_file_size_fallback(self, tmp_path):
"""Without mutagen, falls back to file-size estimate for OGG."""
ogg = tmp_path / "voice.ogg"
# ~100 KB β†’ 100000 / 2000 = 50 seconds
ogg.write_bytes(b"\x00" * 100_000)
result = _probe_audio_duration(str(ogg))
assert result is not None
assert result >= 1
# Should be roughly 50s (Β±20% tolerance for rounding)
assert 40 <= result <= 60

def test_mp3_file_size_fallback(self, tmp_path):
"""Without mutagen, falls back to file-size estimate for MP3."""
mp3 = tmp_path / "audio.mp3"
# ~256 KB β†’ 256000 / 16000 = 16 seconds
mp3.write_bytes(b"\x00" * 256_000)
result = _probe_audio_duration(str(mp3))
assert result is not None
assert result >= 1
assert 12 <= result <= 20

def test_minimum_duration_is_one(self, tmp_path):
"""Even a tiny file should report at least 1 second."""
tiny = tmp_path / "tiny.ogg"
tiny.write_bytes(b"\x00" * 10)
result = _probe_audio_duration(str(tiny))
assert result is not None
assert result >= 1


# ---------------------------------------------------------------------------
# Integration: send_voice passes duration
# ---------------------------------------------------------------------------

class TestSendVoicePassesDuration:
"""Verify that ``TelegramAdapter.send_voice`` forwards ``duration``."""

@pytest.mark.asyncio
async def test_voice_path_includes_duration(self, tmp_path):
"""OGG voice calls should include ``duration`` in kwargs."""
from gateway.platforms.telegram import TelegramAdapter
from gateway.config import PlatformConfig, Platform

ogg = tmp_path / "voice.ogg"
ogg.write_bytes(b"\x00" * 200_000) # ~100s at 2kB/s

adapter = object.__new__(TelegramAdapter)
adapter._bot = MagicMock()
adapter._reply_to_mode = "quote"

# Mock internal helpers
adapter._metadata_thread_id = MagicMock(return_value=None)
adapter._reply_to_message_id_for_send = MagicMock(return_value=None)
adapter._thread_kwargs_for_send = MagicMock(return_value={})
adapter._notification_kwargs = MagicMock(return_value={})

sent_kwargs = {}

async def _capture_send_voice(**kwargs):
sent_kwargs.update(kwargs)
return SimpleNamespace(message_id=42)

async def _fake_retry(fn, kw, *args, **kwargs):
return await _capture_send_voice(**kw)

adapter._send_with_dm_topic_reply_anchor_retry = AsyncMock(side_effect=_fake_retry)

with patch("os.path.exists", return_value=True):
result = await adapter.send_voice(
chat_id="12345",
audio_path=str(ogg),
caption=None,
reply_to=None,
metadata=None,
)

assert result.success is True
assert "duration" in sent_kwargs
assert isinstance(sent_kwargs["duration"], int)
assert sent_kwargs["duration"] >= 1

@pytest.mark.asyncio
async def test_audio_path_includes_duration(self, tmp_path):
"""MP3 audio calls should include ``duration`` in kwargs."""
from gateway.platforms.telegram import TelegramAdapter

mp3 = tmp_path / "audio.mp3"
mp3.write_bytes(b"\x00" * 320_000) # ~20s at 16kB/s

adapter = object.__new__(TelegramAdapter)
adapter._bot = MagicMock()
adapter._reply_to_mode = "quote"

adapter._metadata_thread_id = MagicMock(return_value=None)
adapter._reply_to_message_id_for_send = MagicMock(return_value=None)
adapter._thread_kwargs_for_send = MagicMock(return_value={})
adapter._notification_kwargs = MagicMock(return_value={})

sent_kwargs = {}

async def _capture_send_audio(**kwargs):
sent_kwargs.update(kwargs)
return SimpleNamespace(message_id=43)

async def _fake_retry(fn, kw, *args, **kwargs):
return await _capture_send_audio(**kw)

adapter._send_with_dm_topic_reply_anchor_retry = AsyncMock(side_effect=_fake_retry)

with patch("os.path.exists", return_value=True):
result = await adapter.send_voice(
chat_id="12345",
audio_path=str(mp3),
caption="test",
reply_to=None,
metadata=None,
)

assert result.success is True
assert "duration" in sent_kwargs
assert isinstance(sent_kwargs["duration"], int)
assert sent_kwargs["duration"] >= 1
Loading