feat(telegram): skip-STT audio path + 2GB cap via local Bot API server

Two coordinated changes that unblock downstream audio pipelines
(diarization, custom transcription, archival) on attachments larger
than the public Bot API's 20MB getFile ceiling.

- `stt.enabled: false` no longer drops voice/audio with a generic
  "transcription disabled" note. The gateway probes the cached file's
  duration (wave → mutagen → ffprobe ladder) and surfaces
  `[The user sent a voice message: <abs path> (duration: M:SS)]` to
  the agent so a skill or tool can pick up the raw file. The previous
  placeholder is replaced rather than appended when present.

- `platforms.telegram.extra.base_url` set → adapter auto-lifts its
  document size cap from 20MB to 2GB (the local telegram-bot-api
  `--local` ceiling) and the "too large" reply reports the active
  limit dynamically. No new config knob; presence of `base_url` is the
  opt-in.

- `platforms.telegram.extra.local_mode: true` wires
  `Application.builder().local_mode(True)` on the python-telegram-bot
  builder. PTB then reads files from disk instead of HTTP, which is
  required when telegram-bot-api runs in `--local` mode (the server
  returns absolute filesystem paths, not `/file/bot...` URLs).

- gateway/run.py: rewrites the `stt.enabled: false` branch of
  `_enrich_message_with_transcription`. New `_format_duration` +
  `_probe_audio_duration` helpers.
- gateway/platforms/telegram.py: `_max_doc_bytes` instance attribute
  derived from `extra.base_url`; `local_mode` builder wiring;
  dynamic "too large" message.
- tests/gateway/test_stt_config.py: covers path-surfacing with and
  without an existing user message, and placeholder replacement.
- tests/gateway/test_telegram_max_doc_bytes.py: 3 cases — default 20MB
  without base_url, 2GB when set, empty-string base_url keeps default.
- website/docs/user-guide/messaging/telegram.md: new "Skipping STT"
  subsection under Voice Messages and a full "Large Files (>20MB) via
  Local Bot API Server" walkthrough (api_id/api_hash, docker-compose,
  one-time `logOut` migration, `platforms.telegram.extra` config, the
  `local_mode` disk-access requirement, the silent HTTP-fallback 404).
- website/docs/user-guide/features/voice-mode.md: documents the
  `stt.enabled` knob in the config reference.

- `pytest tests/gateway/test_telegram_max_doc_bytes.py
  tests/gateway/test_stt_config.py` → 9/9 passing.
- Verified end-to-end on a live deployment: gateway log shows
  `Using custom Telegram base_url: http://...` and
  `Using Telegram local_mode (read files from disk)` on startup;
  voice messages above 20MB cache to disk and surface their path to
  the agent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Albert G
2026-05-14 00:38:32 +02:00
committed by Teknium
parent 6265b3a132
commit ad2531be08
6 changed files with 327 additions and 14 deletions

View File

@@ -439,6 +439,14 @@ class TelegramAdapter(BasePlatformAdapter):
self._dm_topic_chat_ids: Set[str] = {
str(e["chat_id"]) for e in self._dm_topics_config if "chat_id" in e
}
# Document size cap. Telegram's public Bot API caps getFile at 20MB; a
# locally-hosted telegram-bot-api server (configured via extra.base_url)
# raises that to 2GB, so the presence of base_url is the opt-in.
self._max_doc_bytes: int = (
2 * 1024 * 1024 * 1024
if self.config.extra.get("base_url")
else 20 * 1024 * 1024
)
# Interactive model picker state per chat
self._model_picker_state: Dict[str, dict] = {}
# Approval button state: message_id → session_key
@@ -1315,6 +1323,14 @@ class TelegramAdapter(BasePlatformAdapter):
"[%s] Using custom Telegram base_url: %s",
self.name, custom_base_url,
)
# In local-mode telegram-bot-api, file_path is an absolute path on the
# server's filesystem rather than a relative HTTP path. PTB needs
# local_mode=True so download_*() reads from disk instead of issuing
# an HTTP GET that would 404. Requires that the same path is
# readable by the Hermes process (shared mount, same machine, etc.).
if self.config.extra.get("local_mode"):
builder = builder.local_mode(True)
logger.info("[%s] Using Telegram local_mode (read files from disk)", self.name)
# PTB defaults (pool_timeout=1s) are too aggressive on flaky networks and
# can trigger "Pool timeout: All connections in the connection pool are occupied"
@@ -4894,11 +4910,11 @@ class TelegramAdapter(BasePlatformAdapter):
# Check file size early so image documents cannot bypass the
# document size limit by taking the image path.
MAX_DOC_BYTES = 20 * 1024 * 1024
if not doc.file_size or doc.file_size > MAX_DOC_BYTES:
if not doc.file_size or doc.file_size > self._max_doc_bytes:
limit_mb = self._max_doc_bytes // (1024 * 1024)
event.text = (
"The document is too large or its size could not be verified. "
"Maximum: 20 MB."
f"Maximum: {limit_mb} MB."
)
logger.info("[Telegram] Document too large: %s bytes", doc.file_size)
await self.handle_message(event)

View File

@@ -918,6 +918,59 @@ def _build_media_placeholder(event) -> str:
return "\n".join(parts)
def _format_duration(seconds: float) -> str:
total = int(round(seconds))
if total < 0:
total = 0
hours, rem = divmod(total, 3600)
minutes, secs = divmod(rem, 60)
if hours:
return f"{hours}:{minutes:02d}:{secs:02d}"
return f"{minutes}:{secs:02d}"
async def _probe_audio_duration(path: str) -> Optional[str]:
"""Best-effort duration probe. Returns formatted MM:SS / HH:MM:SS, or None on failure."""
ext = os.path.splitext(path)[1].lower()
if ext == ".wav":
try:
def _wav_duration() -> float:
import wave
with wave.open(path, "rb") as wf:
frames = wf.getnframes()
rate = wf.getframerate() or 1
return frames / float(rate)
secs = await asyncio.to_thread(_wav_duration)
return _format_duration(secs)
except Exception:
pass
if ext in (".ogg", ".opus", ".oga"):
try:
def _ogg_duration() -> float:
from mutagen.oggopus import OggOpus
return float(OggOpus(path).info.length)
secs = await asyncio.to_thread(_ogg_duration)
return _format_duration(secs)
except Exception:
pass
try:
proc = await asyncio.create_subprocess_exec(
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
if proc.returncode == 0:
return _format_duration(float(stdout.decode().strip()))
except Exception:
pass
return None
def _dequeue_pending_event(adapter, session_key: str) -> MessageEvent | None:
"""Consume and return the full pending event for a session.
@@ -14151,16 +14204,25 @@ class GatewayRunner:
The enriched message string with transcriptions prepended.
"""
if not getattr(self.config, "stt_enabled", True):
disabled_note = "[The user sent voice message(s), but transcription is disabled in config."
if self._has_setup_skill():
disabled_note += (
" You have a skill called hermes-agent-setup that can help "
"users configure Hermes features including voice, tools, and more."
)
disabled_note += "]"
notes = []
for path in audio_paths:
abs_path = os.path.abspath(path)
duration_str = await _probe_audio_duration(abs_path)
if duration_str:
notes.append(
f"[The user sent a voice message: {abs_path} (duration: {duration_str})]"
)
else:
notes.append(f"[The user sent a voice message: {abs_path}]")
if not notes:
return user_text
prefix = "\n\n".join(notes)
_placeholder = "(The user sent a message with no text content)"
if user_text and user_text.strip() == _placeholder:
return prefix
if user_text:
return f"{disabled_note}\n\n{user_text}"
return disabled_note
return f"{prefix}\n\n{user_text}"
return prefix
from tools.transcription_tools import transcribe_audio