- Correct the per-generation reset comment: an in-place updater restart keeps PTB's update_queue, so old-generation dispatches can briefly exceed received; the check already treats that as no backlog. - Make the once-per-stall gate explicit (cap the heartbeat count) instead of relying on `!=`. - Drop the dead getattr in _record_updates_received (only reachable after _record_polling_progress dereferenced the same instance state); keep the fallbacks in the heartbeat check and group-99 handler, which sibling watchdogs share because object.__new__ adapter doubles exist in tests. - Split the single invariant test so a failure names the broken guard: stall-once, re-arm-on-progress, generation-reset; drop the unused _app mock.
6615 lines
385 KiB
Python
6615 lines
385 KiB
Python
"""Telegram platform adapter (python-telegram-bot): inbound messages/media/commands, outbound replies."""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import dataclasses
|
|
import inspect
|
|
import json
|
|
import logging
|
|
import os
|
|
import html as _html
|
|
import re
|
|
import time
|
|
from contextvars import ContextVar
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Awaitable, Callable, Dict, Iterator, List, Optional, Set
|
|
from hermes_cli import setup_platforms
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
from agent.deadline import run_bounded_async
|
|
from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, platform_gate_env as _scoped_gate_env
|
|
|
|
|
|
def _redact_telegram_error_text(error: object) -> str:
|
|
"""Redact secrets from Telegram transport errors before logging or returning them."""
|
|
text = "" if error is None else str(error)
|
|
if not text:
|
|
return text
|
|
try:
|
|
from agent.redact import redact_sensitive_text
|
|
return redact_sensitive_text(text, force=True)
|
|
except Exception:
|
|
return "<telegram error redacted>"
|
|
|
|
|
|
def _consume_abandoned_task(task: asyncio.Task) -> None:
|
|
"""Observe a detached task's terminal exception to avoid noisy loop logs."""
|
|
try:
|
|
task.exception()
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception:
|
|
logger.debug("Abandoned Telegram init task failed after timeout", exc_info=True)
|
|
|
|
|
|
async def _await_with_thread_deadline(awaitable, timeout: float, *, on_abandon=None):
|
|
"""Wall-clock deadline that survives a blocked loop / cancellation-shielded PTB+httpcore init.
|
|
|
|
``on_abandon`` runs detached so an abandoned initialize() can't leak an httpx pool. Raises
|
|
``asyncio.TimeoutError`` on expiry (feeds the PTB retry ladder).
|
|
|
|
Thin wrapper over :func:`agent.deadline.run_bounded_async` (#85125 Phase 2f) — this adapter's private
|
|
implementation was the ancestor of that primitive and is now consolidated onto it. The unified layer
|
|
keeps every property the 9 call sites here rely on: thread-timer deadline that survives a blocked event
|
|
loop (#63309), abandonment of cancellation-shielded tasks (PTB/httpcore init inside anyio scopes),
|
|
detached best-effort ``on_abandon`` cleanup so an abandoned initialize() can't leak an httpx pool per
|
|
retry attempt, and off-loop stack-dump diagnostics when the loop never processes the expiry.
|
|
"""
|
|
result = await run_bounded_async(awaitable, timeout, label="telegram-init", on_abandon=on_abandon)
|
|
if result.timed_out:
|
|
raise asyncio.TimeoutError()
|
|
return result.value
|
|
|
|
|
|
def _iter_exception_graph(error: BaseException) -> "Iterator[BaseException]":
|
|
"""Yield ``error`` and every ``__cause__``/``__context__`` ancestor (DFS, cycle-safe) —
|
|
PTB wraps httpx errors, so classifiers must inspect the whole graph."""
|
|
seen: set[int] = set()
|
|
stack: list[BaseException] = [error]
|
|
while stack:
|
|
cur = stack.pop()
|
|
ident = id(cur)
|
|
if ident in seen:
|
|
continue
|
|
seen.add(ident)
|
|
yield cur
|
|
stack.extend(x for x in (getattr(cur, "__cause__", None), getattr(cur, "__context__", None)) if x is not None)
|
|
|
|
|
|
async def _shutdown_abandoned_app(app) -> None:
|
|
"""Release a half-built PTB app's httpx transports after an abandoned init: ``app.shutdown()``
|
|
no-ops when ``_initialized`` was never set, so the request transports are closed directly."""
|
|
if app is None:
|
|
return
|
|
try:
|
|
await app.shutdown()
|
|
except Exception:
|
|
logger.debug("Abandoned Telegram app.shutdown() failed", exc_info=True)
|
|
bot = getattr(app, "bot", None)
|
|
for request in (getattr(bot, "_request", None) if bot is not None else None) or ():
|
|
shutdown = getattr(request, "shutdown", None)
|
|
if shutdown is None:
|
|
continue
|
|
try:
|
|
result = shutdown()
|
|
if asyncio.iscoroutine(result) or asyncio.isfuture(result):
|
|
await result
|
|
except Exception:
|
|
logger.debug("Abandoned Telegram request shutdown failed", exc_info=True)
|
|
|
|
try:
|
|
from telegram import Update, Bot, Message, InlineKeyboardButton, InlineKeyboardMarkup
|
|
try:
|
|
from telegram import LinkPreviewOptions
|
|
except ImportError:
|
|
LinkPreviewOptions = None
|
|
from telegram.ext import (
|
|
Application, CommandHandler, CallbackQueryHandler, InlineQueryHandler, MessageHandler as TelegramMessageHandler,
|
|
ContextTypes, TypeHandler, filters)
|
|
from telegram.constants import ParseMode, ChatType
|
|
from telegram.request import HTTPXRequest
|
|
TELEGRAM_AVAILABLE = True
|
|
except ImportError:
|
|
TELEGRAM_AVAILABLE = False
|
|
Update = Bot = Message = InlineKeyboardButton = InlineKeyboardMarkup = Application = Any
|
|
CommandHandler = CallbackQueryHandler = InlineQueryHandler = TypeHandler = TelegramMessageHandler = HTTPXRequest = Any
|
|
LinkPreviewOptions = filters = ParseMode = ChatType = None
|
|
|
|
# Mock so ContextTypes.DEFAULT_TYPE annotations don't crash class definition without the lib.
|
|
class _MockContextTypes:
|
|
DEFAULT_TYPE = Any
|
|
ContextTypes = _MockContextTypes
|
|
|
|
import sys
|
|
from pathlib import Path as _Path
|
|
sys.path.insert(0, str(_Path(__file__).resolve().parents[3]))
|
|
|
|
from gateway.authz_mixin import _coerce_allow_set
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.base import (
|
|
BasePlatformAdapter, ExecApprovalPrompt, SendResult, classify_send_error,
|
|
cache_image_from_bytes_async, cache_audio_from_bytes_async, cache_video_from_bytes_async, resolve_proxy_url, SUPPORTED_VIDEO_TYPES,
|
|
SUPPORTED_DOCUMENT_TYPES, SUPPORTED_IMAGE_DOCUMENT_TYPES, _TEXT_INJECT_EXTENSIONS, utf16_len,
|
|
)
|
|
from gateway.platforms.event import MessageEvent, MessageType, ProcessingOutcome
|
|
from plugins.platforms.telegram.telegram_ids import normalize_telegram_chat_id
|
|
from plugins.platforms.telegram.telegram_network import (
|
|
SEED_FALLBACK_IPS, TelegramFallbackTransport, discover_fallback_ips, parse_fallback_ip_env, tcp_keepalive_socket_options)
|
|
from utils import env_float, env_int
|
|
|
|
_TELEGRAM_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
|
# Max seconds a send/edit may sleep inline on a flood-control RetryAfter; longer penalties fail
|
|
# closed with ``flood_control:{wait}`` so the caller's retry machinery owns the wait.
|
|
# Longer server penalties fail closed with a ``flood_control:{wait}`` SendResult so the caller's retry
|
|
# machinery (delivery ledger, streaming fallback) owns the wait instead of the coroutine pinning its worker
|
|
# — a 97-minute penalty on the boot path froze inbound on every platform (#91969).
|
|
_FLOOD_INLINE_WAIT_CAP_SECS = 5.0
|
|
|
|
|
|
def _flood_cap_result(wait: float) -> "SendResult":
|
|
"""The shared fail-closed SendResult for an over-cap flood wait."""
|
|
return SendResult(success=False, error=f"flood_control:{wait}", retry_after=float(wait))
|
|
|
|
|
|
_TELEGRAM_IMAGE_MIME_TO_EXT = {"image/png": ".png", "image/jpeg": ".jpg", "image/jpg": ".jpg", "image/webp": ".webp", "image/gif": ".gif"}
|
|
_TELEGRAM_IMAGE_EXT_TO_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".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 whole-second audio length (wave → mutagen → ffprobe; None if unreadable).
|
|
|
|
Telegram renders long clips as 0:00 without an explicit duration. Blocking: use ``to_thread``."""
|
|
if os.path.splitext(path)[1].lower() == ".wav":
|
|
try:
|
|
import wave
|
|
with wave.open(path, "rb") as wf:
|
|
rate = wf.getframerate() or 0
|
|
secs = _coerce_duration_seconds(wf.getnframes() / float(rate)) if rate else None
|
|
if secs is not None:
|
|
return secs
|
|
except Exception:
|
|
pass
|
|
try:
|
|
import mutagen
|
|
secs = _coerce_duration_seconds(getattr(getattr(mutagen.File(path), "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, encoding="utf-8", errors="replace", timeout=5)
|
|
if proc.returncode == 0:
|
|
return _coerce_duration_seconds(proc.stdout.strip())
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def telegram_deps_present() -> bool:
|
|
"""PASSIVE registry ``check_fn``: is python-telegram-bot importable? Never installs
|
|
(``check_telegram_requirements`` is the active ``ensure_deps_fn``).
|
|
|
|
Registry ``check_fn`` — called from status displays and config loading, so it must never install
|
|
anything. The ACTIVE lazy-installer (``check_telegram_requirements``) is registered as
|
|
``ensure_deps_fn`` and runs from ``create_adapter()`` when this returns False (#79812).
|
|
"""
|
|
return TELEGRAM_AVAILABLE
|
|
|
|
|
|
def check_telegram_requirements() -> bool:
|
|
"""Lazy-install python-telegram-bot if missing, then re-import and rebind the module aliases."""
|
|
global TELEGRAM_AVAILABLE, Update, Bot, Message, InlineKeyboardButton
|
|
global InlineKeyboardMarkup, LinkPreviewOptions, Application
|
|
global CommandHandler, CallbackQueryHandler, InlineQueryHandler, TelegramMessageHandler
|
|
global ContextTypes, filters, ParseMode, ChatType, HTTPXRequest, TypeHandler
|
|
if TELEGRAM_AVAILABLE:
|
|
return True
|
|
try:
|
|
from tools.lazy_deps import ensure as _lazy_ensure
|
|
_lazy_ensure("platform.telegram", prompt=False)
|
|
except Exception:
|
|
return False
|
|
try:
|
|
import importlib
|
|
_tg, _ext, _const, _req = (
|
|
importlib.import_module(m) for m in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"))
|
|
Update, Bot, Message, InlineKeyboardButton, InlineKeyboardMarkup = (
|
|
getattr(_tg, n) for n in ("Update", "Bot", "Message", "InlineKeyboardButton", "InlineKeyboardMarkup"))
|
|
LinkPreviewOptions = getattr(_tg, "LinkPreviewOptions", None)
|
|
Application, CommandHandler, CallbackQueryHandler, InlineQueryHandler, TelegramMessageHandler = (
|
|
getattr(_ext, n) for n in ("Application", "CommandHandler", "CallbackQueryHandler", "InlineQueryHandler", "MessageHandler"))
|
|
ContextTypes, filters, TypeHandler = _ext.ContextTypes, _ext.filters, _ext.TypeHandler
|
|
ParseMode, ChatType = _const.ParseMode, _const.ChatType
|
|
HTTPXRequest = _req.HTTPXRequest
|
|
except (ImportError, AttributeError):
|
|
return False
|
|
TELEGRAM_AVAILABLE = True
|
|
return True
|
|
|
|
|
|
# Every char MarkdownV2 requires backslash-escaped outside code spans/fences.
|
|
_MDV2_ESCAPE_RE = re.compile(r'([_*\[\]()~`>#\+\-=|{}.!\\])')
|
|
|
|
|
|
def _escape_mdv2(text: str) -> str:
|
|
"""Escape Telegram MarkdownV2 special characters with a preceding backslash."""
|
|
return _MDV2_ESCAPE_RE.sub(r'\\\1', text)
|
|
|
|
|
|
def _strip_mdv2(text: str) -> str:
|
|
"""Strip MarkdownV2 escapes and formatting markers for the plain-text fallback."""
|
|
cleaned = re.sub(r'\\([_*\[\]()~`>#\+\-=|{}.!\\])', r'\1', text) # escape backslashes
|
|
cleaned = re.sub(r'\*\*([^*]+)\*\*', r'\1', cleaned) # **bold** BEFORE MarkdownV2 *bold*
|
|
cleaned = re.sub(r'\*([^*]+)\*', r'\1', cleaned)
|
|
cleaned = re.sub(r'(?<!\w)_([^_]+)_(?!\w)', r'\1', cleaned) # italic; word-bounded so snake_case survives
|
|
cleaned = re.sub(r'~([^~]+)~', r'\1', cleaned) # strikethrough
|
|
cleaned = re.sub(r'\|\|([^|]+)\|\|', r'\1', cleaned) # spoiler
|
|
return cleaned
|
|
|
|
|
|
_CHUNK_INDICATOR_ON_FENCE_RE = re.compile(r'(?m)^``` (?P<indicator>(?:\\)?\(\d+/\d+(?:\\)?\))$')
|
|
|
|
|
|
def _separate_chunk_indicator_from_fence(text: str) -> str:
|
|
"""Move a ``(N/M)`` chunk marker that ``truncate_message()`` appended to a synthesized closing
|
|
fence onto its own line — Telegram rejects ````` \\(1/2\\)`` as a fence."""
|
|
return _CHUNK_INDICATOR_ON_FENCE_RE.sub(r'```\n\g<indicator>', text)
|
|
|
|
|
|
# MarkdownV2 has no table syntax, so pipe tables become bullet groups via convert_table_to_bullets().
|
|
from gateway.platforms.helpers import (
|
|
TABLE_SEPARATOR_RE as _TABLE_SEPARATOR_RE, compile_mention_patterns, convert_table_to_bullets as _wrap_markdown_tables)
|
|
from gateway.platforms.helpers import cancel_task
|
|
|
|
# Rich-message regions whose internal newlines must stay bare (Telegram renders them natively):
|
|
# fenced code blocks OR GFM pipe-table blocks (header row, delimiter row, data rows).
|
|
_RICH_PROTECTED_REGION_RE = re.compile(
|
|
r'(?:```[^\n]*\n[\s\S]*?```)' # fenced code block
|
|
r'|(?:^[^\n]*\|[^\n]*\n' # table header row (has a pipe)
|
|
r'[ \t]*\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)+\|?[ \t]*' # delimiter
|
|
r'(?:\n[^\n]*\|[^\n]*)*)', # data rows (newline-led, trailing \n left for prose)
|
|
re.MULTILINE)
|
|
|
|
|
|
def _rich_normalize_linebreaks(text: str) -> str:
|
|
"""Convert lone ``\\n`` (a Markdown soft break) to hard breaks for sendRichMessage; ``\\n\\n``,
|
|
fenced code and pipe tables are left untouched."""
|
|
if not text or '\n' not in text:
|
|
return text
|
|
out: list[str] = []
|
|
pos = 0
|
|
for m in _RICH_PROTECTED_REGION_RE.finditer(text):
|
|
out.append(re.sub(r'(?<!\n)\n(?!\n)', ' \n', text[pos:m.start()]))
|
|
out.append(m.group(0)) # protected region kept verbatim
|
|
pos = m.end()
|
|
out.append(re.sub(r'(?<!\n)\n(?!\n)', ' \n', text[pos:]))
|
|
return ''.join(out)
|
|
|
|
|
|
# Internal safety bounds (not user knobs): no reconnect/teardown path may hang on a dead CLOSE-WAIT
|
|
# socket PTB's polling task is blocked on in epoll.
|
|
_UPDATER_STOP_TIMEOUT = 15.0 # `await updater.stop()`, applied identically at every site
|
|
_DISCONNECT_STEP_TIMEOUT = 2.0 # other disconnect() steps: short, so a swallowed cancel can't burn the fatal budget
|
|
_UPDATER_START_TIMEOUT = 30.0 # start_polling() can hang on a degraded pool after a drain
|
|
# Initial connect is unhealthy until getUpdates completes one round trip; bootstrap fails closed so
|
|
# GatewayRunner disposes the adapter and retries fresh.
|
|
# Per-step bound for disconnect() awaits that are not updater.stop() itself. Kept short so a
|
|
# cancellation-swallowing lifecycle/PTB close cannot burn the gateway's whole fatal-handler budget before
|
|
# the reconnect queue is useful (#80598). updater.stop() keeps the longer _UPDATER_STOP_TIMEOUT.
|
|
# start_polling() can also hang when the connection pool is in a degraded state after
|
|
# _drain_polling_connections(), particularly when both primary and fallback Telegram endpoints are
|
|
# unreachable. Bounding start_polling() prevents the reconnect ladder from stalling indefinitely and allows
|
|
# the heartbeat loop to trigger its own recovery path. Refs: NousResearch/hermes-agent#59614
|
|
_INITIAL_POLLING_PROGRESS_TIMEOUT = 60.0
|
|
# Bounded drain (shutdown()/initialize() of the getUpdates request) so a wedged socket can't freeze
|
|
# _polling_error_task and gate every escalation path behind its in-flight guard.
|
|
# shutdown()/initialize() on the getUpdates httpx request close and rebuild the connection pool. When a
|
|
# connection is wedged on a stale CLOSE-WAIT socket that close can block forever, hanging
|
|
# _drain_polling_connections() and freezing the whole reconnect ladder (the tracked _polling_error_task
|
|
# never completes, so every escalation path stays gated behind its in-flight guard). Bound the drain so the
|
|
# ladder always advances toward the fatal-restart escalation. Matches _UPDATER_STOP_TIMEOUT. Refs:
|
|
# NousResearch/hermes-agent#66377
|
|
_DRAIN_TIMEOUT = 15.0
|
|
# Wedged-recovery watchdog: healthy worst case is stop + 2x drain + start + 60s backoff ≈ 135s, so
|
|
# 300s in flight is unambiguously stuck and the heartbeat force-escalates.
|
|
# Every recovery path (the reconnect ladder's re-entry, the pending-update probe, PTB's error callback)
|
|
# gates new recovery on ``_polling_error_task.done()``; if that task ever wedges on a hung await that no
|
|
# local bound covers, the whole gateway goes silently deaf with nothing retrying. The heartbeat loop
|
|
# force-escalates a recovery task that stays in-flight far longer than any healthy ladder attempt could take
|
|
# — stop (_UPDATER_STOP_TIMEOUT) + drain (2x_DRAIN_TIMEOUT) + start (_UPDATER_START_TIMEOUT) + max backoff
|
|
# (60s) is ~135s, so 300s is unambiguously stuck. See #66377.
|
|
_POLLING_ERROR_TASK_STUCK_TIMEOUT = 300.0
|
|
_POLLING_PROGRESS_TIMEOUT = 60.0 # generation unhealthy until getUpdates returns; exceeds one idle long-poll
|
|
# Telegram answers a long-poll within ~50s; no round-trip for ~3x that while get_me() is healthy and
|
|
# nothing is queued means a consumer wedged on a socket that never raises (CLOSE-WAIT behind a route flip).
|
|
# Telegram holds a long-poll open for at most ~50s before answering (empty or not), so a healthy idle poller
|
|
# completes a getUpdates round-trip well inside this window. If no round-trip has completed for longer than
|
|
# this — while get_me() on the general request path stays healthy and no updates are queued server-side —
|
|
# the long-poll consumer is wedged on a socket that never raises (CLOSE-WAIT behind a TUN/proxy route flip,
|
|
# #92991) and no other probe can see it. ~3x the worst-case poll window leaves ample margin against false
|
|
# positives while still recovering within a few heartbeat intervals.
|
|
_POLLING_STALL_TIMEOUT = 150.0
|
|
# Ingress dispatch stall (#102260): the transport probes prove getUpdates round-trips complete, not
|
|
# that PTB's dispatcher ever handed the fetched updates to a handler. Two heartbeats (180s) with a
|
|
# backlog and no dispatch progress: diagnostic only, never drives recovery (#71240 owns that).
|
|
_INGRESS_DISPATCH_STALL_HEARTBEATS = 2
|
|
# sendVideo transcodes before answering, outlasting the 20s read timeout; also how long a user waits
|
|
# to hear the attachment failed, so kept modest.
|
|
_MEDIA_SEND_READ_TIMEOUT = 60.0
|
|
_POLLING_GENERATION_CONTEXT: ContextVar[Optional[int]] = ContextVar("telegram_polling_generation", default=None)
|
|
|
|
|
|
class _PollingLifecycleAbort(RuntimeError):
|
|
"""Internal control flow for polling startup fenced by teardown."""
|
|
|
|
|
|
class TelegramAdapter(BasePlatformAdapter):
|
|
"""Telegram bot adapter: users/groups, MarkdownV2 replies, forum topics, media."""
|
|
|
|
MAX_MESSAGE_LENGTH = 4096
|
|
supports_code_blocks = True # MarkdownV2 renders fenced code blocks
|
|
splits_long_messages = True # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)
|
|
RICH_MESSAGE_MAX_CHARS = 32768 # Bot API 10.1 rich cap; above it use legacy chunking
|
|
_SPLIT_THRESHOLD = 4000 # chunk near this length ⇒ a client-side split continuation is almost certain
|
|
MEDIA_GROUP_WAIT_SECONDS = 0.8
|
|
HELD_INBOUND_MAX = 64 # inbound events held across a disconnect window; oldest dropped first
|
|
_GENERAL_TOPIC_THREAD_ID = "1"
|
|
# send() can race a disconnect blip; failing "Not connected" (retryable=False) parks the answer in the
|
|
# delivery ledger until next boot, so wait briefly for _bot (or a replacement adapter) instead.
|
|
_RECONNECT_WAIT_SECONDS = 15.0
|
|
_RECONNECT_POLL_INTERVAL = 0.5
|
|
|
|
# edit_message applies MarkdownV2 only on finalize=True; without this flag stream_consumer skips
|
|
# the final edit when raw text is unchanged.
|
|
# Fixes #25710.
|
|
REQUIRES_EDIT_FINALIZE: bool = True
|
|
FALLBACK_ON_FINAL_EDIT_FLOOD: bool = True # retrying a final edit burns the same flood budget
|
|
RESEND_FINAL_ON_EMPTY_STREAM_FALLBACK: bool = True # a failed final edit may leave a partial preview
|
|
|
|
# Adaptive text-batch ingress ("feels instant"): ≤320 codepoints settle in ~180ms, ≤1024 in ~240ms,
|
|
# longer waits the configured cap; always clamped to ``_text_batch_delay_seconds``.
|
|
_TEXT_BATCH_FAST_LEN = 320
|
|
_TEXT_BATCH_FAST_DELAY_S = 0.18
|
|
_TEXT_BATCH_SHORT_LEN = 1024
|
|
_TEXT_BATCH_SHORT_DELAY_S = 0.24
|
|
|
|
@staticmethod
|
|
def _env_float_clamped(name: str, default: float, *, min_value: Optional[float] = None, max_value: Optional[float] = None) -> float:
|
|
"""Read a float env var; non-finite → default; clamp to bounds (safe for asyncio.sleep)."""
|
|
import math
|
|
raw = os.getenv(name)
|
|
try:
|
|
value = float(raw) if raw is not None else float(default)
|
|
except (TypeError, ValueError):
|
|
value = float(default)
|
|
if not math.isfinite(value):
|
|
value = float(default)
|
|
if min_value is not None:
|
|
value = max(value, min_value)
|
|
if max_value is not None:
|
|
value = min(value, max_value)
|
|
return value
|
|
|
|
@property
|
|
def _teardown_started(self) -> bool:
|
|
"""True once disconnect() fenced polling (tolerates object.__new__ test adapters)."""
|
|
return getattr(self, "_polling_teardown_started", False)
|
|
|
|
@property
|
|
def message_len_fn(self):
|
|
"""Telegram measures message length in UTF-16 code units."""
|
|
return utf16_len
|
|
|
|
def __init__(self, config: PlatformConfig):
|
|
super().__init__(config, Platform.TELEGRAM)
|
|
extra = self.config.extra
|
|
self._app: Optional[Application] = None
|
|
self._bot: Optional[Bot] = None
|
|
self._webhook_mode: bool = False
|
|
self._mention_patterns = self._compile_mention_patterns()
|
|
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
|
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
|
|
# Bot API 10.1 Rich Messages render what MarkdownV2 degrades (tables, task lists, <details>, block
|
|
# math). Opt-in: current clients make rich messages hard to copy as plain text. rich_drafts is a
|
|
# separate opt-in (Desktop can leave rich draft frames overlaid): off keeps native draft transport
|
|
# but skips rich draft rendering; the final reply still lands via sendRichMessage.
|
|
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
|
|
self._rich_drafts_enabled: bool = self._coerce_bool_extra("rich_drafts", False)
|
|
self._rich_send_disabled = self._rich_draft_disabled = False # latched after a capability failure
|
|
# Transient sendChatAction failures recur on every keep-typing tick; back off per chat.
|
|
self._telegram_typing_cooldown_until: Dict[str, float] = {}
|
|
self._telegram_typing_cooldown_seconds: float = self._coerce_float_extra(
|
|
"typing_cooldown_seconds", 30.0, min_value=1.0, max_value=300.0)
|
|
# Buffer album/photo bursts into a single MessageEvent instead of self-interrupting turns.
|
|
self._media_batch_delay_seconds = env_float("HERMES_TELEGRAM_MEDIA_BATCH_DELAY_SECONDS", 0.8)
|
|
self._pending_photo_batches: Dict[str, MessageEvent] = {}
|
|
self._pending_photo_batch_tasks: Dict[str, asyncio.Task] = {}
|
|
self._media_group_events: Dict[str, MessageEvent] = {}
|
|
self._media_group_tasks: Dict[str, asyncio.Task] = {}
|
|
# Aggregate client-side splits of long messages into one MessageEvent; bounds are conservative
|
|
# for Telegram's ~1 edit/s flood envelope.
|
|
self._text_batch_delay_seconds = self._env_float_clamped(
|
|
"HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS", 0.3, min_value=0.08, max_value=2.0)
|
|
self._text_batch_split_delay_seconds = self._env_float_clamped(
|
|
"HERMES_TELEGRAM_TEXT_BATCH_SPLIT_DELAY_SECONDS", 1.0, min_value=self._text_batch_delay_seconds, max_value=4.0)
|
|
self._drop_delayed_deliveries = False
|
|
# Held across disconnect: PTB advances the offset before our drop-guard runs, so Telegram won't
|
|
# redeliver — dropping is permanent loss (see _hold_inbound_event).
|
|
self._held_inbound_events: List[MessageEvent] = []
|
|
self._held_inbound_redispatch_task: Optional[asyncio.Task] = None
|
|
self._polling_error_task: Optional[asyncio.Task] = None
|
|
self._polling_progress_verifier_task: Optional[asyncio.Task] = None
|
|
self._polling_heartbeat_task: Optional[asyncio.Task] = None
|
|
self._bot_identity_refresh_task: Optional[asyncio.Task] = None
|
|
self._post_connect_task: Optional[asyncio.Task] = None # command menu + DM topics, off the connect path
|
|
self._polling_conflict_count = self._polling_network_error_count = self._polling_generation = 0
|
|
self._polling_conflict_recovery_generation: Optional[int] = None
|
|
self._polling_progress_event = asyncio.Event()
|
|
self._polling_progress_accepting = self._polling_teardown_started = False
|
|
self._polling_error_callback_ref = None
|
|
# Stall watchdog: generation start and last successful getUpdates (None = unknown).
|
|
# Monotonic timestamps for the polling stall watchdog (#92991): when the current polling generation
|
|
# began, and when the last successful getUpdates round-trip completed.
|
|
self._polling_generation_started_monotonic: Optional[float] = None
|
|
self._polling_last_progress_monotonic: Optional[float] = None
|
|
# Ingress accounting (#102260): received (getUpdates wire) vs dispatched (PTB group-99 catch-all).
|
|
self._updates_received_total: int = 0
|
|
self._updates_dispatched_total: int = 0
|
|
self._ingress_dispatched_seen: int = 0
|
|
self._ingress_stalled_heartbeats: int = 0
|
|
# Live @username: PTB caches getMe() at initialize() and only rewrites it inside get_me(), so a
|
|
# BotFather rename leaves self._bot.username stale; routing reads _current_bot_username().
|
|
self._bot_username_observed: Optional[str] = None
|
|
# None = never checked. Must NOT be 0.0: compared against time.monotonic(), which on a fresh host
|
|
# starts near zero, so 0.0 would suppress the first refresh for a TTL.
|
|
self._bot_identity_checked_at: Optional[float] = None
|
|
# Consecutive heartbeat probes seeing queued updates the poller isn't consuming (get_me() can't
|
|
# see a wedged getUpdates) / finding the updater stopped with no reconnect in flight; escalate after two.
|
|
self._polling_pending_stuck_count = self._polling_not_running_count = 0
|
|
# Degraded until getUpdates makes progress; while True, send() short-circuits to failure so callers
|
|
# (cron live-adapter branch) fall through to standalone delivery.
|
|
# Consecutive heartbeat probes that saw queued updates the running poller is not consuming. get_me()
|
|
# can't see this — the send path is healthy while the getUpdates consumer is wedged — so the
|
|
# heartbeat also probes get_webhook_info().pending_update_count and escalates to recovery after two
|
|
# consecutive stuck probes (#42909).
|
|
# Consecutive heartbeat probes that found the updater stopped entirely (running=False) while we are
|
|
# in polling mode with no reconnect in flight. Distinct from the wedged-but-running case above: the
|
|
# long-poll task is simply gone, so neither the connectivity probe nor PTB's error_callback ever
|
|
# fires and the gateway silently stops receiving messages with the process still alive (#55769).
|
|
self._send_path_degraded: bool = False
|
|
self._general_request_drain_lock = asyncio.Lock()
|
|
self._dm_topics: Dict[str, int] = {} # topic_name -> message_thread_id
|
|
self._forum_command_registered: set[int] = set() # forum chats with commands registered
|
|
self._forum_lock = asyncio.Lock()
|
|
# Status indicator: bot short description "Online"/"Offline" on connect/clean disconnect. Off by
|
|
# default because it mutates the GLOBAL profile; opt in via extra.status_indicator.
|
|
self._status_indicator_enabled: bool = bool(extra.get("status_indicator", False))
|
|
self._status_online_text: str = str(extra.get("status_online", "Online"))
|
|
self._status_offline_text: str = str(extra.get("status_offline", "Offline"))
|
|
self._dm_topics_config: List[Dict[str, Any]] = extra.get("dm_topics", [])
|
|
# chat_ids with DM topics configured (O(1) root-DM ignore check)
|
|
self._dm_topic_chat_ids: Set[str] = {str(e["chat_id"]) for e in self._dm_topics_config if "chat_id" in e}
|
|
# getFile cap: 20MB on the public Bot API, 2GB on a local telegram-bot-api (base_url).
|
|
self._max_doc_bytes: int = 2 * 1024 * 1024 * 1024 if extra.get("base_url") else 20 * 1024 * 1024
|
|
self._model_picker_state: Dict[str, dict] = {} # per-chat interactive picker state
|
|
self._choice_picker_state: Dict[str, dict] = {}
|
|
self._approval_state: Dict[int, str] = {} # message_id → session_key
|
|
self._slash_confirm_state: Dict[str, str] = {} # confirm_id → session_key
|
|
self._clarify_state: Dict[str, str] = {} # clarify_id → session_key
|
|
# "important" (default): only final responses, approvals and slash confirmations notify;
|
|
# "all": every message notifies (display.platforms.telegram.notifications).
|
|
self._notifications_mode: str = "important"
|
|
# send_or_update_status(): {(chat_id, status_key) -> message_id} so repeat calls edit in place.
|
|
# send_or_update_status() bookkeeping: {(chat_id, status_key) -> bot message_id} Tracks status
|
|
# bubbles owned by this adapter so subsequent calls with the same key edit the same message instead
|
|
# of appending new ones (#30045).
|
|
self._status_message_ids: Dict[tuple, str] = {}
|
|
# Last truncated mid-stream preview per (chat_id, message_id): past the 4096 cap every edit
|
|
# truncates to the SAME text, and resending burns flood budget. Dropped on finalize.
|
|
self._last_overflow_preview: Dict[tuple, str] = {}
|
|
|
|
@property
|
|
def send_path_degraded(self) -> bool:
|
|
# True from polling-generation start until the first getUpdates
|
|
# round-trip is proven (_record_polling_progress), and again at every
|
|
# polling-death site. getattr: tests build adapters via object.__new__().
|
|
return bool(getattr(self, "_send_path_degraded", False))
|
|
|
|
def _mark_connected(self) -> None:
|
|
self._drop_delayed_deliveries = False
|
|
super()._mark_connected()
|
|
self._schedule_held_inbound_redispatch() # PTB will not redeliver these events
|
|
|
|
def _mark_disconnected(self) -> None:
|
|
self._drop_delayed_deliveries = True
|
|
super()._mark_disconnected()
|
|
|
|
def _set_fatal_error(self, code: str, message: str, *, retryable: bool) -> None:
|
|
self._drop_delayed_deliveries = True
|
|
super()._set_fatal_error(code, message, retryable=retryable)
|
|
# Permanent fatal: no reconnect will drain, so discard the hold queue (later holds are refused).
|
|
# Discard the hold queue now and refuse further holds (teardown salvage / late enqueue must not
|
|
# re-populate a queue that can never drain — review #83878).
|
|
if not retryable:
|
|
held = getattr(self, "_held_inbound_events", None)
|
|
n = len(held) if held else 0
|
|
if held:
|
|
held.clear()
|
|
if n:
|
|
logger.warning("[Telegram] Non-retryable fatal (%s); discarding %d held inbound message(s)", code, n)
|
|
|
|
def _is_permanent_fatal(self) -> bool:
|
|
"""True after non-retryable fatal — holds must discard, not queue."""
|
|
if not getattr(self, "_fatal_error_code", None):
|
|
return False
|
|
return not bool(getattr(self, "_fatal_error_retryable", True))
|
|
|
|
def _replacement_telegram_adapter(self) -> Optional["TelegramAdapter"]:
|
|
"""Live adapter if the reconnect watcher replaced us in ``runner.adapters`` (an in-flight
|
|
``send()`` still holds the old instance whose ``_bot`` stays None)."""
|
|
runner = getattr(self, "gateway_runner", None)
|
|
adapters = getattr(runner, "adapters", None) or {}
|
|
live = adapters.get(self.platform)
|
|
if live is not None and live is not self and getattr(live, "_bot", None):
|
|
return live
|
|
return None
|
|
|
|
async def _wait_for_reconnection(self) -> bool:
|
|
"""Wait for ``_bot`` or a replacement adapter; False on expiry or permanent fatal."""
|
|
if self._bot or self._replacement_telegram_adapter() is not None:
|
|
return True
|
|
if self._is_permanent_fatal():
|
|
return False
|
|
wait_s = float(getattr(self, "_RECONNECT_WAIT_SECONDS", 15.0))
|
|
poll_s = float(getattr(self, "_RECONNECT_POLL_INTERVAL", 0.5))
|
|
logger.info("[%s] Not connected — waiting for reconnection (up to %.0fs)", self.name, wait_s)
|
|
waited = 0.0
|
|
while waited < wait_s:
|
|
await asyncio.sleep(poll_s)
|
|
waited += poll_s
|
|
if self._is_permanent_fatal():
|
|
return False
|
|
if self._bot or self._replacement_telegram_adapter() is not None:
|
|
logger.info("[%s] Reconnected after %.1fs", self.name, waited)
|
|
return True
|
|
logger.warning("[%s] Still not connected after %.0fs", self.name, wait_s)
|
|
return False
|
|
|
|
def _should_drop_delayed_delivery(self) -> bool:
|
|
"""True once teardown/fatal started: delayed flushes must not dispatch onto a torn-down session.
|
|
Callers must NOT destroy the event (PTB already advanced the offset) — hold and redispatch."""
|
|
return bool(getattr(self, "_drop_delayed_deliveries", False))
|
|
|
|
def _schedule_held_inbound_redispatch(self) -> None:
|
|
"""Ensure a tracked drain runs when held events exist and delivery is live (no-op while
|
|
down or after permanent fatal; an in-flight drain schedules its own follow-up)."""
|
|
if self._is_permanent_fatal() or self._should_drop_delayed_delivery():
|
|
return
|
|
if not getattr(self, "_held_inbound_events", None):
|
|
return
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
return
|
|
prior = getattr(self, "_held_inbound_redispatch_task", None)
|
|
try:
|
|
current = asyncio.current_task()
|
|
except RuntimeError:
|
|
current = None
|
|
if prior is not None and not prior.done() and prior is not current:
|
|
return
|
|
self._held_inbound_redispatch_task = loop.create_task(self._redispatch_held_inbound(prior=None if prior is current else prior))
|
|
|
|
def _hold_inbound_event(self, event: "MessageEvent", *, where: str, schedule: bool = True) -> None:
|
|
"""Preserve an inbound event that cannot be dispatched now (PTB already acked the update, so dropping is silent loss).
|
|
Capped, identity-deduped; permanent fatal discards. ``schedule=False`` inside a drain avoids poison-event loops.
|
|
|
|
The disconnect drop-guard (#55971) correctly prevents dispatch into a torn-down session. Destroying
|
|
the event is wrong: by the time we reach enqueue/flush, python-telegram-bot has already acked the
|
|
update and advanced the offset — silent permanent loss, no log, no error.
|
|
"""
|
|
if self._is_permanent_fatal():
|
|
logger.warning(
|
|
"[Telegram] Discarding inbound under non-retryable fatal (%s, %d chars)", where, len(getattr(event, "text", None) or ""))
|
|
return
|
|
held = getattr(self, "_held_inbound_events", None)
|
|
if held is None:
|
|
self._held_inbound_events = held = []
|
|
if any(existing is event for existing in held):
|
|
return
|
|
max_n = int(getattr(self, "HELD_INBOUND_MAX", 64) or 64)
|
|
while len(held) >= max_n:
|
|
dropped = held.pop(0)
|
|
logger.warning(
|
|
"[Telegram] Held-inbound queue full (%d); dropping oldest (%d chars)", max_n, len(getattr(dropped, "text", None) or ""))
|
|
held.append(event)
|
|
logger.warning(
|
|
"[Telegram] Holding inbound (%s, %d chars, queue=%d)%s", where, len(getattr(event, "text", None) or ""), len(held),
|
|
" - will redispatch on reconnect" if self._should_drop_delayed_delivery() else (" - scheduling redispatch" if schedule else ""))
|
|
# A live-path hold must not orphan the event waiting for a reconnect that never comes.
|
|
if schedule and not self._should_drop_delayed_delivery():
|
|
self._schedule_held_inbound_redispatch()
|
|
|
|
def _rehold_from(self, events: list, idx: int, where: str) -> None:
|
|
"""Re-hold ``events[idx:]`` without rescheduling (drain interrupted / failed / cancelled)."""
|
|
for rest in events[idx:]:
|
|
self._hold_inbound_event(rest, where=where, schedule=False)
|
|
|
|
async def _redispatch_held_inbound(self, prior: Optional[asyncio.Task] = None) -> None:
|
|
"""Drain the hold queue after reconnect or a connected-path hold; ``prior`` (previous
|
|
redispatch task) is cancelled+awaited here so ``_mark_connected`` stays synchronous."""
|
|
if prior is not asyncio.current_task(): # a self-redispatch must not cancel itself
|
|
await cancel_task(prior)
|
|
held = getattr(self, "_held_inbound_events", None)
|
|
if self._is_permanent_fatal():
|
|
if held:
|
|
n = len(held)
|
|
held.clear()
|
|
logger.warning("[Telegram] Redispatch aborted; discarded %d held inbound under non-retryable fatal", n)
|
|
return
|
|
if not held:
|
|
return
|
|
# Take ownership atomically; concurrent holds append to the fresh list for a follow-up.
|
|
events = list(held)
|
|
held.clear()
|
|
logger.warning("[Telegram] Redispatching %d held inbound message(s)", len(events))
|
|
allow_followup_schedule = True
|
|
try:
|
|
for idx, event in enumerate(events):
|
|
if self._is_permanent_fatal() or self._should_drop_delayed_delivery():
|
|
self._rehold_from(events, idx, "redispatch-interrupted")
|
|
return
|
|
try:
|
|
await self.handle_message(event)
|
|
except asyncio.CancelledError:
|
|
self._rehold_from(events, idx, "redispatch-cancelled")
|
|
raise
|
|
except Exception:
|
|
# Retryable failure: re-hold but do NOT reschedule now (a poison event would
|
|
# tight-loop); the next mark_connected/live hold drains.
|
|
logger.exception(
|
|
"[Telegram] Failed to redispatch held inbound (%d chars); re-holding", len(getattr(event, "text", None) or ""))
|
|
self._rehold_from(events, idx, "redispatch-failed")
|
|
allow_followup_schedule = False
|
|
return
|
|
finally:
|
|
# Events that arrived mid-drain while still connected need another pass.
|
|
if (
|
|
allow_followup_schedule
|
|
and getattr(self, "_held_inbound_events", None)
|
|
and not self._should_drop_delayed_delivery()
|
|
and not self._is_permanent_fatal()):
|
|
self._schedule_held_inbound_redispatch()
|
|
|
|
def _notification_kwargs(self, metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
"""In "important" mode return disable_notification=True unless ``metadata["notify"]``."""
|
|
if getattr(self, "_notifications_mode", "important") != "important" or (metadata or {}).get("notify"):
|
|
return {}
|
|
return {"disable_notification": True}
|
|
|
|
@staticmethod
|
|
def _normalize_chat_type(chat_type: Any, *, is_forum: bool) -> str:
|
|
"""Telegram chat type → gateway chat type (``private``→``dm``, ``supergroup``→forum/group)."""
|
|
normalized = str(chat_type or "dm").strip().lower() or "dm"
|
|
if normalized == "private":
|
|
return "dm"
|
|
if normalized == "supergroup":
|
|
return "forum" if is_forum else "group"
|
|
return normalized
|
|
|
|
def _legacy_runner_auth_fn(self):
|
|
"""``runner._is_user_authorized`` resolved off the bound handler (bare-adapter tests, direct
|
|
embedding); None under multiplex where the handler is a profile closure."""
|
|
# Resolve through the runner's full auth chain (platform + group allowlists, pairing store,
|
|
# allow-all flags). Prefer the platform-bound callback registered via set_authorization_check: it
|
|
# routes to GatewayRunner._is_user_authorized AND survives multiplex handler wrapping, whereas the
|
|
# bound-handler __self__ lookup is None when the primary handler is a profile closure — which
|
|
# silently dropped the chat allowlist and default-denied allowlisted group members under
|
|
# multiplex_profiles (#87132). Fall back to the bound handler for setups without a registered
|
|
# callback.
|
|
runner = getattr(getattr(self, "_message_handler", None), "__self__", None)
|
|
auth_fn = getattr(runner, "_is_user_authorized", None)
|
|
return auth_fn if callable(auth_fn) else None
|
|
|
|
@staticmethod
|
|
def _env_allowlist_decision(user_id: str) -> Optional[bool]:
|
|
"""TELEGRAM_ALLOWED_USERS decision; None when no allowlist is configured."""
|
|
allowed_csv = _scoped_gate_env("TELEGRAM_ALLOWED_USERS").strip()
|
|
if not allowed_csv:
|
|
return None
|
|
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
|
|
return "*" in allowed_ids or user_id in allowed_ids
|
|
|
|
def _is_callback_user_authorized(
|
|
self, user_id: str, *, chat_id: Optional[str] = None, chat_type: Optional[str] = None,
|
|
thread_id: Optional[str] = None, user_name: Optional[str] = None) -> bool:
|
|
"""Return whether a Telegram inline-button caller may perform gated actions."""
|
|
normalized_user_id = str(user_id or "").strip()
|
|
if not normalized_user_id:
|
|
return False
|
|
normalized_chat_type = self._normalize_chat_type(chat_type, is_forum=thread_id is not None)
|
|
# Preferred: the auth callback GatewayRunner injects (set_authorization_check) → full
|
|
# _is_user_authorized chain; also works for a multiplexed adapter whose _message_handler is a
|
|
# profile closure. getattr tolerates partially-constructed adapters (object.__new__ in tests).
|
|
if getattr(self, "_authorization_check", None) is not None:
|
|
injected = self._is_sender_authorized(
|
|
normalized_user_id, chat_type=normalized_chat_type, chat_id=str(chat_id or normalized_user_id),
|
|
thread_id=str(thread_id) if thread_id is not None else None)
|
|
if injected is not None:
|
|
return injected
|
|
auth_fn = self._legacy_runner_auth_fn()
|
|
if auth_fn is not None:
|
|
try:
|
|
from gateway.session import SessionSource
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM, chat_id=str(chat_id or normalized_user_id), chat_type=normalized_chat_type,
|
|
user_id=normalized_user_id, user_name=str(user_name).strip() if user_name else None,
|
|
thread_id=str(thread_id) if thread_id is not None else None)
|
|
return bool(auth_fn(source))
|
|
except Exception:
|
|
logger.debug(
|
|
"[Telegram] Falling back to env-only callback auth for user %s", normalized_user_id, exc_info=True)
|
|
decision = self._env_allowlist_decision(normalized_user_id)
|
|
if decision is None:
|
|
# Fail-closed: no allowlist means deny unless GATEWAY_ALLOW_ALL_USERS is set.
|
|
# The runner auth path in _is_user_authorized() handles GATEWAY_ALLOW_ALL_USERS; this fallback
|
|
# must not silently allow everyone (fixes #24457).
|
|
return _scoped_gate_env("GATEWAY_ALLOW_ALL_USERS").lower() in {"true", "1", "yes"}
|
|
return decision
|
|
|
|
def _source_from_message_for_auth(self, message: Message):
|
|
"""Build the SessionSource the gateway auth path expects; identity comes from ``from_user``,
|
|
falling back to ``sender_chat`` for channel posts so an unauthorized channel can't inject."""
|
|
from gateway.session import SessionSource
|
|
user = getattr(message, "from_user", None)
|
|
chat = getattr(message, "chat", None)
|
|
user_id = str(getattr(user, "id", "")).strip() or None
|
|
# Carry is_bot so the runner's ``*_ALLOW_BOTS`` branch is reachable, as in build_source.
|
|
is_bot = bool(getattr(user, "is_bot", False)) if user is not None else False
|
|
user_name = str(getattr(user, "username", "") or getattr(user, "full_name", "") or "").strip() or None
|
|
if not user_id: # channel post — authorize the sender chat instead
|
|
sender_chat = getattr(message, "sender_chat", None)
|
|
if sender_chat is not None:
|
|
user_id = str(getattr(sender_chat, "id", "")).strip() or None
|
|
if not user_name:
|
|
user_name = str(getattr(sender_chat, "title", "") or "").strip() or None
|
|
chat_id = str(getattr(chat, "id", "")).strip() or user_id
|
|
thread_id_raw = getattr(message, "message_thread_id", None)
|
|
is_topic_message = bool(getattr(message, "is_topic_message", False))
|
|
is_forum_group = getattr(chat, "is_forum", False) is True
|
|
chat_type = self._normalize_chat_type(
|
|
getattr(chat, "type", "dm"), is_forum=thread_id_raw is not None and (is_topic_message or is_forum_group))
|
|
thread_id = None
|
|
if thread_id_raw is not None and (
|
|
(chat_type == "forum" and (is_topic_message or is_forum_group)) or (chat_type == "dm" and is_topic_message)):
|
|
thread_id = str(thread_id_raw)
|
|
return SessionSource(
|
|
platform=Platform.TELEGRAM, chat_id=chat_id or "", chat_type=chat_type, user_id=user_id,
|
|
user_name=user_name, thread_id=thread_id, is_bot=is_bot)
|
|
|
|
def _source_from_reaction_for_auth(self, update):
|
|
"""SessionSource for a ``message_reaction`` update's actor (``user`` or ``actor_chat``).
|
|
|
|
Raises ``ValueError`` when actor, chat or message identity is absent so the post-auth boundary fails closed."""
|
|
mr = getattr(update, "message_reaction", None)
|
|
if mr is None:
|
|
raise ValueError("gateway_platform_event source extraction requires a message_reaction update")
|
|
user = getattr(mr, "user", None) or getattr(mr, "actor_chat", None)
|
|
chat = getattr(mr, "chat", None)
|
|
user_id = str(getattr(user, "id", "")).strip() or None
|
|
user_name = str(getattr(user, "username", "") or getattr(user, "full_name", "") or getattr(user, "title", "")).strip() or None
|
|
chat_id = str(getattr(chat, "id", "")).strip() or None
|
|
message_id = getattr(mr, "message_id", None)
|
|
if not user_id or not chat_id or message_id is None or not str(message_id).strip():
|
|
raise ValueError("gateway_platform_event reaction requires actor, chat, and message identities")
|
|
# Reactions carry no message_thread_id; is_forum is the only forum signal.
|
|
chat_type = self._normalize_chat_type(getattr(chat, "type", "dm"), is_forum=getattr(chat, "is_forum", False) is True)
|
|
return self.build_source(
|
|
chat_id=chat_id, chat_type=chat_type, user_id=user_id, user_name=user_name, thread_id=None, message_id=str(message_id))
|
|
|
|
def _telegram_auth_env_configured(self) -> bool:
|
|
"""Return True when Telegram auth env vars make an early decision safe."""
|
|
keys = (
|
|
"TELEGRAM_ALLOWED_USERS", "TELEGRAM_GROUP_ALLOWED_USERS", "TELEGRAM_GROUP_ALLOWED_CHATS",
|
|
"TELEGRAM_ALLOW_ALL_USERS", "GATEWAY_ALLOWED_USERS", "GATEWAY_ALLOW_ALL_USERS")
|
|
return any(_scoped_gate_env(key).strip() for key in keys)
|
|
|
|
def _should_pass_unauthorized_dm_for_pairing(self, source) -> bool:
|
|
"""True when an unauthorized DM must still reach gateway pairing (``unauthorized_dm_behavior``
|
|
resolves to ``pair``, incl. an allowlist plus an explicit platform override)."""
|
|
if source.chat_type != "dm":
|
|
return False
|
|
# Bound-handler ``__self__`` is None under multiplex; ``gateway_runner`` survives that wrapping.
|
|
runner = getattr(getattr(self, "_message_handler", None), "__self__", None) or getattr(self, "gateway_runner", None)
|
|
behavior_fn = getattr(runner, "_get_unauthorized_dm_behavior", None)
|
|
if callable(behavior_fn):
|
|
try:
|
|
profile = getattr(source, "profile", None) or getattr(self, "_owner_profile", None)
|
|
return behavior_fn(Platform.TELEGRAM, profile=profile) == "pair"
|
|
except Exception:
|
|
logger.debug("[Telegram] Failed to resolve unauthorized DM behavior; falling back to adapter-local override", exc_info=True)
|
|
extra = getattr(getattr(self, "config", None), "extra", None) or {}
|
|
return str(extra.get("unauthorized_dm_behavior", "")).strip().lower() == "pair"
|
|
|
|
def _is_user_authorized_from_message(self, message: Message) -> bool:
|
|
"""Intake auth prefilter, run BEFORE batching/event construction/group observation.
|
|
|
|
Only rejects when it can make the same context-aware decision the runner would; unknown DMs pass through when
|
|
there is no allowlist or pairing is the unauthorized-DM behavior."""
|
|
source = self._source_from_message_for_auth(message)
|
|
user_id = source.user_id
|
|
# No identity → service message or channel post without sender_chat; defer to message gating.
|
|
if not user_id:
|
|
return True
|
|
authorized: Optional[bool] = None
|
|
# Adapter-level allow_from (DMs) / group_allow_from (groups) are the sole authority if set.
|
|
adapter_allow_from = self.config.extra.get(
|
|
"group_allow_from" if (source.chat_type or "") in ("group", "forum", "channel") else "allow_from")
|
|
if adapter_allow_from is not None:
|
|
allowed = _coerce_allow_set(adapter_allow_from)
|
|
authorized = user_id in allowed or "*" in allowed
|
|
# Instance-level override only (tests): the class method _is_callback_user_authorized is for
|
|
# inline buttons and must not become a user-id-only shortcut for real messages.
|
|
if authorized is None:
|
|
callback_auth = self.__dict__.get("_is_callback_user_authorized")
|
|
if callable(callback_auth):
|
|
with contextlib.suppress(Exception):
|
|
authorized = bool(callback_auth(
|
|
user_id, chat_id=source.chat_id, chat_type=source.chat_type, thread_id=source.thread_id,
|
|
user_name=source.user_name))
|
|
if authorized is None:
|
|
# Runner's full auth chain; prefer the set_authorization_check callback (survives multiplex
|
|
# handler wrapping, unlike bound-handler __self__).
|
|
auth_fn = self._legacy_runner_auth_fn()
|
|
has_callback = getattr(self, "_authorization_check", None) is not None
|
|
if has_callback or auth_fn is not None:
|
|
# No allowlist → unknown DMs must reach pairing, not be default-denied here.
|
|
if not self._telegram_auth_env_configured():
|
|
return True
|
|
decision = self._is_sender_authorized(
|
|
user_id, chat_type=source.chat_type, chat_id=source.chat_id, is_bot=source.is_bot,
|
|
thread_id=source.thread_id) if has_callback else None
|
|
if decision is not None:
|
|
authorized = decision
|
|
elif auth_fn is not None:
|
|
try:
|
|
authorized = bool(auth_fn(source))
|
|
except Exception:
|
|
logger.debug("[Telegram] Falling back to env-only auth for user %s", user_id, exc_info=True)
|
|
if authorized is None:
|
|
authorized = self._env_allowlist_decision(user_id)
|
|
if authorized is None:
|
|
return True
|
|
if authorized:
|
|
return True
|
|
# Unauthorized DM the gateway would pair: forward so pairing can run.
|
|
return self._should_pass_unauthorized_dm_for_pairing(source)
|
|
|
|
@classmethod
|
|
def _metadata_thread_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
thread_id = (metadata or {}).get("thread_id") or (metadata or {}).get("message_thread_id")
|
|
return str(thread_id) if thread_id is not None else None
|
|
|
|
@classmethod
|
|
def _metadata_direct_messages_topic_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
topic_id = (metadata or {}).get("direct_messages_topic_id") or (metadata or {}).get("telegram_direct_messages_topic_id")
|
|
return str(topic_id) if topic_id is not None else None
|
|
|
|
@classmethod
|
|
def _metadata_reply_to_message_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[int]:
|
|
reply_to = (metadata or {}).get("telegram_reply_to_message_id")
|
|
return int(reply_to) if reply_to is not None else None
|
|
|
|
@staticmethod
|
|
def _dm_topic_fallback(metadata: Optional[Dict[str, Any]]) -> bool:
|
|
"""True for Hermes private-chat topic lanes (``telegram_dm_topic_reply_fallback``)."""
|
|
return bool(metadata and metadata.get("telegram_dm_topic_reply_fallback"))
|
|
|
|
@classmethod
|
|
def _is_private_dm_topic_send(cls, chat_id: str, thread_id: Optional[str], metadata: Optional[Dict[str, Any]]) -> bool:
|
|
if cls._metadata_direct_messages_topic_id(metadata) is not None:
|
|
return cls._dm_topic_fallback(metadata) and cls._metadata_reply_to_message_id(metadata) is not None
|
|
if metadata and metadata.get("telegram_dm_topic_created_for_send"):
|
|
return False
|
|
return bool(thread_id) and cls._dm_topic_fallback(metadata)
|
|
|
|
@staticmethod
|
|
def _dm_topic_missing_anchor_error() -> str:
|
|
return "Telegram DM topic delivery requires a reply anchor; refusing to send outside the requested topic"
|
|
|
|
@classmethod
|
|
def _reply_to_message_id_for_send(
|
|
cls, reply_to: Optional[str], metadata: Optional[Dict[str, Any]] = None, reply_to_mode: Optional[str] = None) -> Optional[int]:
|
|
if reply_to:
|
|
return int(reply_to)
|
|
if cls._dm_topic_fallback(metadata) and reply_to_mode != "off":
|
|
return cls._metadata_reply_to_message_id(metadata)
|
|
return None
|
|
|
|
@classmethod
|
|
def _thread_kwargs_for_send(
|
|
cls, chat_id: str, thread_id: Optional[str], metadata: Optional[Dict[str, Any]] = None,
|
|
reply_to_message_id: Optional[int] = None, reply_to_mode: Optional[str] = None) -> Dict[str, Any]:
|
|
"""Telegram send kwargs for forum and direct-message topic routing.
|
|
|
|
Forum topics use ``message_thread_id``; native Bot API DM topics opt in via explicit ``direct_messages_topic_id``
|
|
metadata; Hermes private-chat topic lanes are marked ``telegram_dm_topic_reply_fallback``. Anchor-less synthetic sends
|
|
prefer the Hermes topic's ``message_thread_id`` (the native DM-topic id renders in a different chat lane).
|
|
``reply_to_mode="off"`` suppresses the anchor but keeps ``message_thread_id``.
|
|
|
|
Live replies send the private topic thread id together with a reply anchor. Synthetic/resumed sends
|
|
without an anchor (loop wakeups, background-process notifications, queued follow-ups after a gateway
|
|
restart) prefer the Hermes topic's ``message_thread_id`` so they stay in the active topic lane
|
|
(#87051); ``direct_messages_topic_id`` is only used when no topic thread resolves, since the native
|
|
DM-topic id does not match the Hermes topic lane and can render the message in a different chat
|
|
lane.
|
|
"""
|
|
fallback = cls._dm_topic_fallback(metadata)
|
|
if fallback and reply_to_mode != "off":
|
|
if reply_to_message_id is None:
|
|
reply_to_message_id = cls._metadata_reply_to_message_id(metadata)
|
|
if reply_to_message_id is None:
|
|
# Anchor-less synthetic send: prefer the Hermes topic thread id (see docstring).
|
|
# Anchor-less synthetic sends (loop wakeups, watch notifications, restart-resumed
|
|
# follow-ups) must stay in the active topic lane: prefer the Hermes topic thread id when it
|
|
# resolves (#87051). Routing via direct_messages_topic_id here sent these to a different
|
|
# lane than the topic the session runs in.
|
|
thread_message_id = cls._message_thread_id_for_send(thread_id)
|
|
if thread_message_id is not None:
|
|
return {"message_thread_id": thread_message_id}
|
|
return cls._direct_topic_kwargs(metadata) or {}
|
|
elif not fallback:
|
|
direct_kwargs = cls._direct_topic_kwargs(metadata)
|
|
if direct_kwargs is not None:
|
|
return direct_kwargs
|
|
return {"message_thread_id": cls._message_thread_id_for_send(thread_id)}
|
|
|
|
@classmethod
|
|
def _direct_topic_kwargs(cls, metadata: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
"""Native Bot API DM-topic routing kwargs, or None when no ``direct_messages_topic_id``."""
|
|
direct_topic_id = cls._metadata_direct_messages_topic_id(metadata)
|
|
if direct_topic_id is None:
|
|
return None
|
|
return {"message_thread_id": None, "direct_messages_topic_id": int(direct_topic_id)}
|
|
|
|
def _thread_kwargs_for_draft(self, chat_id: str, metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
"""Routing kwargs for ``sendMessageDraft`` / ``sendRichMessageDraft`` (integer
|
|
``message_thread_id`` for DM topics — Telegram rejects the raw string ``thread_id``)."""
|
|
kwargs = self._thread_kwargs_for_send(
|
|
chat_id, self._metadata_thread_id(metadata), metadata, reply_to_message_id=self._reply_to_message_id_for_send(None, metadata),
|
|
reply_to_mode=getattr(self, "_reply_to_mode", None))
|
|
return {k: v for k, v in kwargs.items() if v is not None}
|
|
|
|
@classmethod
|
|
def _message_thread_id_for_send(cls, thread_id: Optional[str]) -> Optional[int]:
|
|
if not thread_id or str(thread_id) == cls._GENERAL_TOPIC_THREAD_ID:
|
|
return None
|
|
return int(thread_id)
|
|
|
|
@classmethod
|
|
def _message_thread_id_for_typing(cls, thread_id: Optional[str]) -> Optional[int]:
|
|
# Deliberately asymmetric with _message_thread_id_for_send: sendMessage rejects message_thread_id=1
|
|
# (forum General), but sendChatAction NEEDS it to place the typing bubble in General.
|
|
return int(thread_id) if thread_id else None
|
|
|
|
@staticmethod
|
|
def _is_thread_not_found_error(error: Exception) -> bool:
|
|
return "thread not found" in str(error).lower()
|
|
|
|
def _prune_stale_dm_topic_binding(self, chat_id: Any, thread_id: Any, *, metadata: Optional[Dict[str, Any]] = None) -> None:
|
|
"""Drop the stale ``telegram_dm_topic_bindings`` row for a topic Telegram confirmed deleted, else
|
|
``_recover_telegram_topic_thread_id`` keeps steering inbound to the dead thread. Best-effort.
|
|
Rows are namespaced by profile: the send's ``hermes_profile`` wins over the adapter's stamp.
|
|
|
|
Without this prune the recovery logic in ``gateway.run._recover_telegram_topic_thread_id`` keeps
|
|
steering future inbound messages to the dead thread (the bug behind #31501 — tool progress,
|
|
approvals, replies all end up in the wrong place even though the user has moved on to a fresh
|
|
topic). Best-effort: we never raise from a send-fallback path — a failed cleanup must not turn into
|
|
a failed user-facing send.
|
|
Under ``gateway.profile_routes`` the transport adapter may not be the profile that wrote the
|
|
binding, so the send's ``hermes_profile`` metadata wins over the adapter's own profile stamp;
|
|
single-profile bots fall back to ``"default"``. See #76423.
|
|
"""
|
|
if chat_id is None or thread_id is None:
|
|
return
|
|
db = getattr(getattr(self, "_session_store", None), "_db", None)
|
|
if db is None or not hasattr(db, "delete_telegram_topic_binding"):
|
|
return
|
|
try:
|
|
profile_name = (metadata or {}).get("hermes_profile") or getattr(self, "_hermes_profile_name", None) or "default"
|
|
removed = db.delete_telegram_topic_binding(chat_id=str(chat_id), thread_id=str(thread_id), profile_name=profile_name)
|
|
except Exception:
|
|
logger.debug(
|
|
"[%s] delete_telegram_topic_binding failed for chat=%s thread=%s — skipping prune",
|
|
self.name, chat_id, thread_id, exc_info=True)
|
|
return
|
|
if removed:
|
|
logger.info(
|
|
"[%s] Pruned stale Telegram DM topic binding chat=%s thread=%s (Bot API: thread not found)", self.name, chat_id, thread_id)
|
|
|
|
@staticmethod
|
|
def _is_bad_request_error(error: Exception) -> bool:
|
|
name = error.__class__.__name__.lower()
|
|
if name == "badrequest" or name.endswith("badrequest"):
|
|
return True
|
|
try:
|
|
from telegram.error import BadRequest
|
|
return isinstance(error, BadRequest)
|
|
except ImportError:
|
|
return False
|
|
|
|
@classmethod
|
|
def _should_retry_without_dm_topic_reply_anchor(
|
|
cls, error: Exception, metadata: Optional[Dict[str, Any]], reply_to_message_id: Optional[int]) -> bool:
|
|
"""True when a DM-topic send should be retried with routing stripped: (1) stale anchor — reply
|
|
target deleted; (2) anchor-less synthetic send whose ``direct_messages_topic_id`` Bot API rejects.
|
|
|
|
2. The synthetic-event case (added when #27937 introduced ``direct_messages_topic_id`` fallback for
|
|
sends without an anchor): if Bot API rejects the topic id itself with any BadRequest that mentions
|
|
topic/thread routing, we retry without routing rather than dropping the message.
|
|
"""
|
|
if not cls._dm_topic_fallback(metadata) or not cls._is_bad_request_error(error):
|
|
return False
|
|
err_lower = str(error).lower()
|
|
if reply_to_message_id is not None and "message to be replied not found" in err_lower:
|
|
return True
|
|
if not metadata.get("direct_messages_topic_id"): # topic id rejected → plain DM send
|
|
return False
|
|
topic_markers = (
|
|
"direct_messages_topic", "message thread not found", "thread not found", "topic_closed", "topic_deleted", "topic not found")
|
|
return any(marker in err_lower for marker in topic_markers)
|
|
|
|
async def _send_with_dm_topic_reply_anchor_retry(
|
|
self, send_fn: Any, send_kwargs: Dict[str, Any], metadata: Optional[Dict[str, Any]],
|
|
reply_to_message_id: Optional[int], media_label: str, reset_media: Optional[Any] = None) -> Any:
|
|
"""Retry stale private-topic media replies once without the topic anchor."""
|
|
try:
|
|
return await send_fn(**send_kwargs)
|
|
except Exception as send_err:
|
|
if not self._should_retry_without_dm_topic_reply_anchor(send_err, metadata, reply_to_message_id):
|
|
raise
|
|
logger.warning(
|
|
"[%s] Reply target deleted for Telegram %s, retrying without reply/topic anchor: %s",
|
|
self.name, media_label, _redact_telegram_error_text(send_err))
|
|
if reset_media is not None:
|
|
reset_media()
|
|
retry_kwargs = dict(send_kwargs)
|
|
retry_kwargs["reply_to_message_id"] = None
|
|
retry_kwargs.pop("message_thread_id", None)
|
|
retry_kwargs.pop("direct_messages_topic_id", None)
|
|
return await send_fn(**retry_kwargs)
|
|
|
|
def _fallback_ips(self) -> list[str]:
|
|
"""Return validated fallback IPs from config (populated by _apply_env_overrides)."""
|
|
configured = self.config.extra.get("fallback_ips", []) if getattr(self.config, "extra", None) else []
|
|
if isinstance(configured, str):
|
|
configured = configured.split(",")
|
|
return parse_fallback_ip_env(",".join(str(v) for v in configured) if configured else None)
|
|
|
|
@staticmethod
|
|
def _looks_like_polling_conflict(error: Exception) -> bool:
|
|
text = str(error).lower()
|
|
return (
|
|
error.__class__.__name__.lower() == "conflict"
|
|
or "terminated by other getupdates request" in text
|
|
or "another bot instance is running" in text)
|
|
|
|
@staticmethod
|
|
def _looks_like_auth_error(error: Exception) -> bool:
|
|
"""True for terminal credential failures (InvalidToken, Forbidden) → retryable=False. Type-based
|
|
only, never message text; BadRequest/RetryAfter are transient at connect time."""
|
|
if error.__class__.__name__.lower() in {"invalidtoken", "forbidden"}:
|
|
return True
|
|
try:
|
|
from telegram.error import Forbidden, InvalidToken
|
|
return isinstance(error, (InvalidToken, Forbidden))
|
|
except ImportError:
|
|
return False
|
|
|
|
@staticmethod
|
|
def _looks_like_network_error(error: Exception) -> bool:
|
|
"""Return True for transient transport failures that warrant reconnect."""
|
|
name = error.__class__.__name__.lower()
|
|
if name in {"badrequest", "invalidtoken", "forbidden", "retryafter"}:
|
|
return False
|
|
if name in {"networkerror", "timedout", "connectionerror"}:
|
|
return True
|
|
try:
|
|
from telegram.error import BadRequest, Forbidden, InvalidToken, NetworkError, RetryAfter, TimedOut
|
|
if isinstance(error, (BadRequest, InvalidToken, Forbidden, RetryAfter)):
|
|
return False
|
|
if isinstance(error, (NetworkError, TimedOut)):
|
|
return True
|
|
except ImportError:
|
|
pass
|
|
return isinstance(error, OSError)
|
|
|
|
@staticmethod
|
|
def _exception_graph_matches(error: Exception, name_marker: str, *text_markers: str) -> bool:
|
|
"""True when any exception in ``error``'s cause/context graph matches by class name or text."""
|
|
for cur in _iter_exception_graph(error):
|
|
text = str(cur).lower()
|
|
if name_marker in cur.__class__.__name__.lower() or any(m in text for m in text_markers):
|
|
return True
|
|
return False
|
|
|
|
@classmethod
|
|
def _looks_like_connect_timeout(cls, error: Exception) -> bool:
|
|
"""True when a TimedOut wraps a ConnectTimeout: TCP never connected, so re-sending is safe
|
|
(a plain TimedOut may have reached Telegram and must not be re-sent)."""
|
|
return cls._exception_graph_matches(error, "connecttimeout", "connect timeout", "connect timed out")
|
|
|
|
@staticmethod
|
|
def _looks_like_pool_timeout(error: Exception) -> bool:
|
|
"""True when a TimedOut wraps ``httpx.PoolTimeout``: PTB says "Request was *not* sent", so
|
|
re-sending cannot duplicate. Matches class AND text to survive rewording."""
|
|
for cur in _iter_exception_graph(error):
|
|
name = cur.__class__.__name__.lower()
|
|
text = str(cur).lower()
|
|
if "pooltimeout" in name or "pool timeout" in text or ("connection pool" in text and "occupied" in text):
|
|
return True
|
|
return False
|
|
|
|
def _coerce_bool_extra(self, key: str, default: bool = False) -> bool:
|
|
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, str):
|
|
lowered = value.strip().lower()
|
|
if lowered in {"true", "1", "yes", "on"}:
|
|
return True
|
|
if lowered in {"false", "0", "no", "off"}:
|
|
return False
|
|
return default
|
|
return bool(value)
|
|
|
|
def _coerce_float_extra(
|
|
self, key: str, default: float, *, min_value: Optional[float] = None, max_value: Optional[float] = None) -> float:
|
|
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
|
|
if value is None:
|
|
return default
|
|
try:
|
|
parsed = float(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
if min_value is not None:
|
|
parsed = max(parsed, min_value)
|
|
if max_value is not None:
|
|
parsed = min(parsed, max_value)
|
|
return parsed
|
|
|
|
def _link_preview_kwargs(self) -> Dict[str, Any]:
|
|
if not getattr(self, "_disable_link_previews", False):
|
|
return {}
|
|
if LinkPreviewOptions is not None:
|
|
return {"link_preview_options": LinkPreviewOptions(is_disabled=True)}
|
|
return {"disable_web_page_preview": True}
|
|
|
|
# --- Bot API 10.1 Rich Messages (sendRichMessage): final/new-message replies opportunistically send
|
|
# RAW agent markdown so tables, task lists, <details>, math render natively; legacy MarkdownV2 send()
|
|
# is the fallback. Streaming edits stay on the MarkdownV2 edit path.
|
|
def _content_fits_rich_limits(self, content: str) -> bool:
|
|
"""Pre-check the 32,768-char cap only; other rich limits surface as BadRequest (permanent)."""
|
|
return len(content) <= self.RICH_MESSAGE_MAX_CHARS
|
|
|
|
def _bot_supports_rich(self) -> bool:
|
|
"""True when ``do_api_request`` is an *async* callable (real Bot or AsyncMock); plain MagicMock
|
|
and SimpleNamespace bots resolve False → legacy path."""
|
|
return inspect.iscoroutinefunction(getattr(self._bot, "do_api_request", None))
|
|
|
|
_RICH_DETAILS_RE = re.compile(r"<details\b[^>]*>.*?</details>", re.IGNORECASE | re.DOTALL)
|
|
_RICH_MATH_IN_DETAILS_RE = re.compile(
|
|
r"(\$\$.*?\$\$|\\\[.*?\\\]|\\\(.*?\\\)|"
|
|
r"\\(?:sum|frac|alpha|beta|gamma|delta|theta|lambda|mu|pi|sigma|"
|
|
r"int|prod|sqrt|lim|infty|begin\{(?:equation|align|matrix|cases)\}))",
|
|
re.IGNORECASE | re.DOTALL)
|
|
# Hiragana/Katakana, CJK Ext A, CJK Unified, Hangul, CJK Compatibility, CJK ext/compat supplement.
|
|
_RICH_CJK_RE = re.compile("[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\U00020000-\U000323af]")
|
|
|
|
def _has_telegram_desktop_details_math_crash_shape(self, content: str) -> bool:
|
|
"""Math inside <details> crashes Telegram Desktop 6.9.1 (tdesktop#30808); the Bot API accepts
|
|
the payload, so rich delivery must be skipped up front."""
|
|
if not content:
|
|
return False
|
|
return any(self._RICH_MATH_IN_DETAILS_RE.search(block) for block in self._RICH_DETAILS_RE.findall(content))
|
|
|
|
def _has_telegram_desktop_cjk_rich_garble_shape(self, content: str) -> bool:
|
|
"""True for CJK content: Telegram Mac/Desktop rich rendering leaves overlapping glyphs.
|
|
|
|
Telegram Mac/Desktop Bot API 10.1 rich-message rendering currently leaves overlapping draft/overlay
|
|
glyph artifacts for CJK text (#47653). The legacy MarkdownV2 path renders the same text cleanly, so
|
|
skip rich delivery up front until affected clients age out.
|
|
"""
|
|
return bool(content and self._RICH_CJK_RE.search(content))
|
|
|
|
def _needs_rich_rendering(self, content: str) -> bool:
|
|
"""True for constructs MarkdownV2 degrades: pipe tables, task lists, <details>, block math.
|
|
Ordinary replies stay on MarkdownV2 so clients render consistent font weight/spacing.
|
|
|
|
The rich endpoint is reserved for constructs where raw markdown materially improves output: pipe
|
|
tables (MarkdownV2 has no table syntax and rewrites them into bullet lists), GFM task lists,
|
|
collapsible ``<details>`` blocks, and block math. Adapted from #45995 (@YonganZhang).
|
|
"""
|
|
if not content:
|
|
return False
|
|
if any(_TABLE_SEPARATOR_RE.match(line) for line in content.splitlines()):
|
|
return True
|
|
if re.search(r"(?m)^\s*[-*]\s+\[[ xX]\]\s+", content):
|
|
return True
|
|
if re.search(r"(?m)^<details\b|^</details>|^<summary\b|^</summary>", content):
|
|
return True
|
|
return "$$" in content
|
|
|
|
def _rich_delivery_enabled(self) -> bool:
|
|
"""Whether rich delivery is allowed (``rich_messages`` opt-in)."""
|
|
return bool(getattr(self, "_rich_messages_enabled", True))
|
|
|
|
def _rich_content_ok(self, content: str) -> bool:
|
|
"""Shape checks shared by rich sends and rich drafts (non-blank, no Desktop crash/garble
|
|
shapes, under the cap, async-capable bot)."""
|
|
return bool(
|
|
content and content.strip()
|
|
and not self._has_telegram_desktop_details_math_crash_shape(content)
|
|
and not self._has_telegram_desktop_cjk_rich_garble_shape(content)
|
|
and self._content_fits_rich_limits(content)
|
|
and self._bot_supports_rich())
|
|
|
|
def _rich_eligible(self, content: str) -> bool:
|
|
"""Rich eligibility ignoring ``expect_edits`` (a streamed preview's FINAL edit still upgrades)."""
|
|
return bool(
|
|
self._rich_delivery_enabled()
|
|
and not getattr(self, "_rich_send_disabled", False)
|
|
and content and content.strip()
|
|
and self._needs_rich_rendering(content)
|
|
and self._rich_content_ok(content))
|
|
|
|
def _should_attempt_rich(self, content: str, metadata: Optional[Dict[str, Any]] = None) -> bool:
|
|
return bool(not (metadata or {}).get("expect_edits") and self._rich_eligible(content))
|
|
|
|
def prefers_fresh_final_streaming(self, content: str, metadata: Optional[Dict[str, Any]] = None) -> bool:
|
|
"""Replace a streamed preview with a fresh rich final — DM topics only. Root DMs stay off (a live
|
|
draft has no preview id); DM *topics* degrade to edit-in-place whose MarkdownV2 preview Telegram
|
|
refuses to rich-edit, so a fresh sendRichMessage + delete is the only way to keep native tables.
|
|
|
|
Root DMs keep this off (#46206 / #47048): successful draft streaming has no preview ``message_id``,
|
|
so the hook is not consulted, and in-place ``editMessageText.rich_message`` would duplicate a live
|
|
draft turn. Private DM *topics* often reject ``sendMessageDraft``; the consumer then degrades to
|
|
edit-in-place. Telegram rejects a rich edit of that plain MarkdownV2 preview, and the fallback
|
|
formatter permanently turns pipe tables into bullet lists.
|
|
"""
|
|
metadata = metadata or {}
|
|
if not (metadata.get("telegram_dm_topic_reply_fallback") or self._metadata_direct_messages_topic_id(metadata)):
|
|
return False
|
|
return self._rich_eligible(content)
|
|
|
|
def _rich_transport_available(self) -> bool:
|
|
return bool(
|
|
getattr(self, "_rich_messages_enabled", True) and not getattr(self, "_rich_send_disabled", False) and self._bot_supports_rich())
|
|
|
|
def streaming_overflow_limit(self) -> Optional[int]:
|
|
"""Let the stream consumer accumulate up to the rich cap so a reply that fits one sendRichMessage
|
|
isn't fragmented at 4,096. None (→ legacy limit) if rich is unavailable."""
|
|
return self.RICH_MESSAGE_MAX_CHARS if self._rich_transport_available() else None
|
|
|
|
def _rich_message_payload(self, content: str, *, skip_entity_detection: bool = False) -> Dict[str, Any]:
|
|
"""``InputRichMessage`` from RAW markdown — never ``format_message(content)``, whose MarkdownV2
|
|
escaping destroys table pipes."""
|
|
payload: Dict[str, Any] = {"markdown": _rich_normalize_linebreaks(content)}
|
|
if skip_entity_detection:
|
|
payload["skip_entity_detection"] = True
|
|
return payload
|
|
|
|
def _is_rich_capability_error(self, exc: Exception) -> bool:
|
|
"""True ⇒ the rich endpoint itself is unavailable (old PTB/server); latches rich off.
|
|
Per-message BadRequests (parser/limit) are NOT capability errors."""
|
|
if exc.__class__.__name__.lower() in {"endpointnotfound", "invalidtoken"}:
|
|
return True
|
|
if isinstance(exc, (AttributeError, TypeError, NotImplementedError)) or getattr(exc, "error_code", None) == 404:
|
|
return True
|
|
s = str(exc).lower()
|
|
if ("method" in s or "endpoint" in s) and ("not found" in s or "does not exist" in s):
|
|
return True
|
|
return "no such method" in s
|
|
|
|
def _is_rich_fallback_error(self, exc: Exception) -> bool:
|
|
"""True ⇒ permanent/capability error ⇒ safe to fall back to legacy. Conservative: anything not
|
|
clearly permanent is transient — the rich request may have reached Telegram (duplicate risk)."""
|
|
if self._is_bad_request_error(exc) or self._is_rich_capability_error(exc):
|
|
return True
|
|
s = str(exc).lower()
|
|
return "unsupported" in s or "not implemented" in s
|
|
|
|
def _chunk_reply_routing(
|
|
self, chat_id: str, reply_to: Optional[str], metadata: Optional[Dict[str, Any]], thread_id: Optional[str], index: int) -> tuple:
|
|
"""Reply-anchor routing for chunk ``index``: ``(private_dm_topic_send, anchor_off, reply_to_id)``.
|
|
``anchor_off``: reply_to_mode="off" on the DM-topic fallback path opts into "message_thread_id
|
|
alone is enough" — don't fail loud because the anchor was suppressed by config."""
|
|
metadata_reply_to = self._metadata_reply_to_message_id(metadata)
|
|
private_dm_topic_send = self._is_private_dm_topic_send(chat_id, thread_id, metadata)
|
|
dm_topic_reply_to_off = private_dm_topic_send and self._reply_to_mode == "off" and self._dm_topic_fallback(metadata)
|
|
reply_to_source = reply_to or (str(metadata_reply_to) if private_dm_topic_send and metadata_reply_to is not None else None)
|
|
if private_dm_topic_send:
|
|
should_thread = reply_to_source is not None and self._reply_to_mode != "off"
|
|
else:
|
|
should_thread = self._should_thread_reply(reply_to_source, index)
|
|
reply_to_id = int(reply_to_source) if should_thread and reply_to_source else None
|
|
return private_dm_topic_send, dm_topic_reply_to_off, reply_to_id
|
|
|
|
def _compute_single_send_routing(
|
|
self, chat_id: str, reply_to: Optional[str], metadata: Optional[Dict[str, Any]], thread_id: Optional[str]) -> Optional[tuple]:
|
|
"""Routing for a single (rich) send — mirrors send()'s index-0 block. Returns ``(reply_to_id,
|
|
thread_kwargs)`` or ``None`` = skip rich, legacy owns the DM-topic fail-loud SendResult."""
|
|
private_dm_topic_send, dm_topic_reply_to_off, reply_to_id = self._chunk_reply_routing(chat_id, reply_to, metadata, thread_id, 0)
|
|
thread_kwargs = self._thread_kwargs_for_send(
|
|
chat_id, thread_id, metadata, reply_to_message_id=reply_to_id, reply_to_mode=self._reply_to_mode)
|
|
# Synthetic/resumed sends via direct_messages_topic_id need no reply anchor.
|
|
if (
|
|
private_dm_topic_send and reply_to_id is None and not dm_topic_reply_to_off
|
|
and not thread_kwargs.get("direct_messages_topic_id")):
|
|
return None
|
|
return reply_to_id, thread_kwargs
|
|
|
|
@staticmethod
|
|
def _is_timed_out(exc: Exception) -> bool:
|
|
"""PTB ``TimedOut`` (when importable) or a "timed out" message."""
|
|
try:
|
|
from telegram.error import TimedOut as _TimedOut
|
|
except (ImportError, AttributeError):
|
|
_TimedOut = None
|
|
return bool((_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in str(exc).lower())
|
|
|
|
def _rich_transient_result(self, exc: Exception, what: str, *, retry_after: Any = None) -> SendResult:
|
|
"""SendResult for a transient/unknown rich-API failure (request may have reached Telegram, so the
|
|
caller must NOT legacy-resend); retry semantics mirror legacy send()."""
|
|
safe_error = _redact_telegram_error_text(exc)
|
|
logger.warning("[%s] %s transient failure (no legacy resend): %s", self.name, what, safe_error)
|
|
return SendResult(
|
|
success=False, error=safe_error,
|
|
retryable=(self._looks_like_connect_timeout(exc) or not self._is_timed_out(exc)), retry_after=retry_after)
|
|
|
|
@staticmethod
|
|
def _record_rich_sent(chat_id: Any, message_id: Any, content: str) -> None:
|
|
"""Index rich content we sent: Telegram won't echo it back in reply_to_message."""
|
|
try:
|
|
from gateway import rich_sent_store
|
|
rich_sent_store.record(str(chat_id), str(message_id), content)
|
|
except Exception:
|
|
pass
|
|
|
|
async def _try_send_rich(
|
|
self, chat_id: str, content: str, reply_to: Optional[str], metadata: Optional[Dict[str, Any]]) -> Optional[SendResult]:
|
|
"""Attempt a single ``sendRichMessage``. Returns a SendResult (success, or a transient failure the
|
|
caller must NOT legacy-resend), or ``None`` = fall back to legacy MarkdownV2."""
|
|
thread_id = self._metadata_thread_id(metadata)
|
|
routing = self._compute_single_send_routing(chat_id, reply_to, metadata, thread_id)
|
|
if routing is None:
|
|
return None
|
|
reply_to_id, thread_kwargs = routing
|
|
payload = self._rich_payload_base(chat_id, content)
|
|
# Only non-None routing keys: direct_messages_topic_id is paired with message_thread_id=None.
|
|
payload.update({k: v for k, v in thread_kwargs.items() if v is not None})
|
|
payload.update(self._notification_kwargs(metadata))
|
|
if reply_to_id is not None:
|
|
# sendRichMessage takes reply_parameters, NOT reply_to_message_id (silently ignored → anchor dropped).
|
|
payload["reply_parameters"] = {"message_id": reply_to_id}
|
|
try:
|
|
# Raw Bot API result: return_type=Message would make PTB deserialize a 10.1 shape it doesn't
|
|
# fully model; a post-delivery parse error ≠ send failure.
|
|
msg = await self._bot.do_api_request("sendRichMessage", api_kwargs=payload)
|
|
except Exception as exc:
|
|
if self._rich_rejected(exc, "sendRichMessage", "MarkdownV2"):
|
|
return None
|
|
# Honor Telegram's flood-control retry_after over the base retry schedule.
|
|
_retry_after = getattr(exc, "retry_after", None)
|
|
if _retry_after is None:
|
|
_m = re.search(r"retry\s+(?:in\s+)?(\d+)", str(exc).lower(), re.IGNORECASE)
|
|
if _m:
|
|
_retry_after = float(_m.group(1))
|
|
return self._rich_transient_result(exc, "sendRichMessage", retry_after=_retry_after)
|
|
if isinstance(msg, dict):
|
|
message_id = msg.get("message_id")
|
|
if message_id is None:
|
|
message_id = (msg.get("result") or {}).get("message_id")
|
|
else:
|
|
message_id = getattr(msg, "message_id", None)
|
|
if message_id is not None:
|
|
self._record_rich_sent(chat_id, message_id, content)
|
|
return SendResult(success=True, message_id=str(message_id) if message_id is not None else None)
|
|
|
|
def _rich_payload_base(self, chat_id: str, content: str) -> Dict[str, Any]:
|
|
payload: Dict[str, Any] = {"chat_id": normalize_telegram_chat_id(chat_id), "rich_message": self._rich_message_payload(content)}
|
|
if getattr(self, "_disable_link_previews", False):
|
|
payload["link_preview_options"] = {"is_disabled": True}
|
|
return payload
|
|
|
|
def _rich_rejected(self, exc: Exception, what: str, fallback: str) -> bool:
|
|
"""True for a permanent/capability rich-API failure (caller falls back to legacy); capability
|
|
errors latch rich off so no doomed roundtrip repeats per send."""
|
|
if not self._is_rich_fallback_error(exc):
|
|
return False
|
|
if self._is_rich_capability_error(exc):
|
|
self._rich_send_disabled = True
|
|
logger.debug("[%s] %s rejected (%s) — falling back to %s", self.name, what, _redact_telegram_error_text(exc), fallback)
|
|
return True
|
|
|
|
async def _try_edit_rich(
|
|
self, chat_id: str, message_id: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Optional[SendResult]:
|
|
"""Edit a message in place as rich (``editMessageText`` + ``rich_message``) so a streamed preview
|
|
finalizes without send+delete. Same contract as :meth:`_try_send_rich`."""
|
|
# No topic routing on edits: message_thread_id/direct_messages_topic_id make Telegram reject it.
|
|
payload = {**self._rich_payload_base(chat_id, content), "message_id": int(message_id)}
|
|
try:
|
|
await self._bot.do_api_request("editMessageText", api_kwargs=payload)
|
|
except Exception as exc:
|
|
# "Message is not modified" = successful no-op; skip the redundant legacy edit.
|
|
if "not modified" in str(exc).lower():
|
|
if self._is_rich_fallback_error(exc) and self._is_rich_capability_error(exc):
|
|
self._rich_send_disabled = True
|
|
return SendResult(success=True, message_id=message_id)
|
|
if self._rich_rejected(exc, "rich editMessageText", "MarkdownV2 edit"):
|
|
return None
|
|
return self._rich_transient_result(exc, "rich editMessageText")
|
|
# Mirror the fresh-send index: a streamed final finalized via edit is otherwise never recorded.
|
|
self._record_rich_sent(chat_id, message_id, content)
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
def _should_attempt_rich_draft(self, content: str) -> bool:
|
|
return bool(
|
|
getattr(self, "_rich_messages_enabled", True)
|
|
and getattr(self, "_rich_drafts_enabled", False)
|
|
and not getattr(self, "_rich_send_disabled", False)
|
|
and not getattr(self, "_rich_draft_disabled", False)
|
|
and self._rich_content_ok(content))
|
|
|
|
async def _try_send_rich_draft(self, chat_id: str, draft_id: int, content: str, metadata: Optional[Dict[str, Any]]) -> bool:
|
|
"""Emit one ``sendRichMessageDraft`` frame; True on success. Frames are ephemeral, so any failure
|
|
returns False and the caller renders the legacy draft; capability failures latch off."""
|
|
payload: Dict[str, Any] = {
|
|
"chat_id": normalize_telegram_chat_id(chat_id), "draft_id": int(draft_id), "rich_message": self._rich_message_payload(content)}
|
|
payload.update(self._thread_kwargs_for_draft(chat_id, metadata))
|
|
try:
|
|
return bool(await self._bot.do_api_request("sendRichMessageDraft", api_kwargs=payload))
|
|
except Exception as exc:
|
|
if self._is_rich_capability_error(exc):
|
|
self._rich_draft_disabled = True
|
|
logger.debug(
|
|
"[%s] sendRichMessageDraft unsupported (%s) — using legacy drafts", self.name, _redact_telegram_error_text(exc))
|
|
else:
|
|
logger.debug(
|
|
"[%s] sendRichMessageDraft transient failure (%s) — legacy draft this frame", self.name,
|
|
_redact_telegram_error_text(exc))
|
|
return False
|
|
|
|
async def _drain_polling_connections(self) -> None:
|
|
"""Reset the httpx pool used for getUpdates polling before a reconnect.
|
|
|
|
Half-closed connections (esp. via proxies) occupy pool slots until "Pool timeout: All connections in the connection pool
|
|
are occupied". Only ``_request[0]`` (getUpdates) is reset; the general request stays untouched so concurrent sends are
|
|
never interrupted. Relies on PTB 22.x's private ``(get_updates, general)`` tuple — review on PTB 23+."""
|
|
if not (self._app and self._app.bot):
|
|
return
|
|
try:
|
|
polling_req = self._app.bot._request[0] # noqa: SLF001
|
|
except Exception:
|
|
return
|
|
# Bounded wall-clock deadline (not asyncio.wait_for): httpcore's pool close runs under
|
|
# AsyncShieldCancellation and a wedged CLOSE-WAIT socket can hang it forever.
|
|
if not await self._bounded_request_step(polling_req.shutdown(), "Polling request shutdown failed/timed out (non-fatal)"):
|
|
# initialize() only rebuilds the client when ``client.is_closed``; an abandoned aclose()
|
|
# leaves it false, so start_polling would reuse the CLOSE-WAIT socket (alive but deaf).
|
|
# Swap in a fresh client before initialize(). See #87057.
|
|
self._orphan_and_rebuild_polling_client(polling_req)
|
|
if await self._bounded_request_step(polling_req.initialize(), "Polling request re-initialize failed/timed out (non-fatal)"):
|
|
logger.debug("[%s] Polling request pool drained before reconnect", self.name)
|
|
else:
|
|
self._orphan_and_rebuild_polling_client(polling_req)
|
|
|
|
async def _bounded_request_step(self, awaitable, failure_msg: str) -> bool:
|
|
"""Await a request shutdown()/initialize() under ``_DRAIN_TIMEOUT``; False (debug-logged) on failure."""
|
|
try:
|
|
await _await_with_thread_deadline(awaitable, timeout=_DRAIN_TIMEOUT)
|
|
return True
|
|
except Exception:
|
|
logger.debug("[%s] " + failure_msg, self.name, exc_info=True)
|
|
return False
|
|
|
|
def _orphan_and_rebuild_polling_client(self, polling_req) -> None:
|
|
"""Replace a wedged HTTPXRequest client after a hung aclose(): swap in a fresh client and close
|
|
the old one in a detached, bounded task so it can't block the reconnect ladder.
|
|
|
|
PTB's ``HTTPXRequest.initialize()`` only calls ``_build_client()`` when the current client reports
|
|
``is_closed``. If ``shutdown()`` was abandoned on a CLOSE-WAIT socket, that flag stays false and the
|
|
next ``start_polling()`` reuses the dead getUpdates connection (#87057).
|
|
"""
|
|
old = getattr(polling_req, "_client", None)
|
|
build = getattr(polling_req, "_build_client", None)
|
|
if old is None or not callable(build) or getattr(old, "is_closed", True):
|
|
return
|
|
try:
|
|
polling_req._client = build() # noqa: SLF001
|
|
except Exception:
|
|
logger.debug("[%s] Failed to rebuild polling HTTP client after hung drain", self.name, exc_info=True)
|
|
return
|
|
logger.warning("[%s] Replaced wedged getUpdates HTTP client after drain timeout (likely CLOSE-WAIT socket)", self.name)
|
|
|
|
async def _orphan_aclose() -> None:
|
|
try:
|
|
aclose = getattr(old, "aclose", None)
|
|
if not callable(aclose):
|
|
return
|
|
# Same cancellation-swallowing httpcore scope as shutdown(): wall-clock deadline.
|
|
await _await_with_thread_deadline(aclose(), timeout=_DRAIN_TIMEOUT)
|
|
except Exception:
|
|
logger.debug("[%s] Orphan polling client aclose failed (non-fatal)", self.name, exc_info=True)
|
|
|
|
try:
|
|
task = asyncio.ensure_future(_orphan_aclose())
|
|
self._background_tasks.add(task)
|
|
task.add_done_callback(self._background_tasks.discard)
|
|
task.add_done_callback(_consume_abandoned_task)
|
|
except Exception:
|
|
pass
|
|
|
|
def _fence_polling(self) -> None:
|
|
"""Mark polling closed: no progress accepted, send path degraded."""
|
|
self._polling_progress_accepting = False
|
|
self._send_path_degraded = True
|
|
|
|
def _begin_polling_generation(self) -> tuple[int, asyncio.Event]:
|
|
"""Start accepting progress for a new getUpdates polling generation."""
|
|
if self._teardown_started:
|
|
self._fence_polling()
|
|
progress = getattr(self, "_polling_progress_event", None)
|
|
if progress is None:
|
|
progress = self._polling_progress_event = asyncio.Event()
|
|
return getattr(self, "_polling_generation", 0), progress
|
|
verifier = getattr(self, "_polling_progress_verifier_task", None)
|
|
if verifier is not None and not verifier.done():
|
|
verifier.cancel()
|
|
self._polling_progress_verifier_task = None
|
|
self._polling_generation = getattr(self, "_polling_generation", 0) + 1
|
|
self._polling_progress_event = asyncio.Event()
|
|
self._polling_progress_accepting = True
|
|
self._send_path_degraded = True
|
|
# Reset stall-watchdog timestamps: no proven progress yet, age measured from here.
|
|
# See #92991.
|
|
self._polling_generation_started_monotonic = time.monotonic()
|
|
self._polling_last_progress_monotonic = None
|
|
# Re-base the backlog per generation. On an in-place updater restart PTB keeps the old
|
|
# update_queue, so old dispatches can briefly exceed received; the check treats that as no backlog.
|
|
self._updates_received_total = self._updates_dispatched_total = 0
|
|
self._ingress_dispatched_seen = self._ingress_stalled_heartbeats = 0
|
|
return self._polling_generation, self._polling_progress_event
|
|
|
|
def _record_polling_progress(self, generation: int) -> bool:
|
|
"""Record successful getUpdates I/O for the current generation only; True when accepted."""
|
|
if self._teardown_started or not self._polling_progress_accepting or generation != self._polling_generation:
|
|
return False
|
|
if not self._polling_progress_event.is_set():
|
|
# First confirmed round-trip resolves the "health pending" line both reconnect paths end on.
|
|
logger.info("[%s] Telegram polling confirmed healthy: getUpdates progressing (generation %d)", self.name, generation)
|
|
self._polling_progress_event.set()
|
|
self._polling_last_progress_monotonic = time.monotonic()
|
|
self._polling_network_error_count = 0
|
|
if generation == self._polling_conflict_recovery_generation:
|
|
self._polling_conflict_recovery_generation = None
|
|
else:
|
|
self._polling_conflict_count = 0
|
|
# First proof getUpdates is flowing for this generation: flip a
|
|
# published "retrying" (degraded connect, reconnect stamp, or the
|
|
# mid-session recovery below) back to "connected" (#101391).
|
|
if self._send_path_degraded and getattr(self, "_running", False) and not self.has_fatal_error:
|
|
self._write_runtime_status_safe(
|
|
"connected", platform_state="connected", error_code=None, error_message=None,
|
|
)
|
|
self._send_path_degraded = False
|
|
return True
|
|
|
|
def _observe_polling_request_result(self, request, generation, result):
|
|
"""Record getUpdates progress from an observed do_request result (purely observational: PTB still
|
|
parses the untouched payload and owns any resulting exception)."""
|
|
status_code, payload = result
|
|
if generation is None or not (200 <= status_code < 300):
|
|
return
|
|
try:
|
|
# The request's own parser keeps health observation in agreement with PTB.
|
|
envelope = request.parse_json_payload(payload)
|
|
except Exception:
|
|
return
|
|
if isinstance(envelope, dict) and envelope.get("ok") is True and "result" in envelope:
|
|
if self._record_polling_progress(generation):
|
|
self._record_updates_received(envelope.get("result"))
|
|
|
|
def _record_updates_received(self, result) -> None:
|
|
"""Count updates Telegram handed us on the getUpdates wire (#102260). Only reached for the
|
|
accepted generation, so a late response from a fenced poll cannot inflate the backlog."""
|
|
if isinstance(result, list) and result:
|
|
self._updates_received_total += len(result)
|
|
|
|
def _instrument_polling_request(self, request):
|
|
"""Instrument one dedicated PTB getUpdates request with progress tracking.
|
|
|
|
PTB request classes use ``__slots__`` (no ``__dict__`` on 3.13), so re-tag the instance to a thin ``__slots__ = ()``
|
|
subclass overriding ``do_request`` — identical layout makes the ``__class__`` swap legal; works for test doubles too.
|
|
|
|
On Python 3.13 their instances no longer carry a ``__dict__`` (the ``AbstractAsyncContextManager``
|
|
MRO stopped yielding one), so ``request.do_request = wrapper`` raises ``AttributeError:
|
|
'HTTPXRequest' object attribute 'do_request' is read-only`` and the whole Telegram connect fails
|
|
(#64482). It only appeared to work on Python 3.12, where those instances still had a ``__dict__``.
|
|
"""
|
|
adapter = self
|
|
|
|
class _InstrumentedPollingRequest(type(request)):
|
|
__slots__ = ()
|
|
|
|
async def do_request(self, *args, **kwargs):
|
|
generation = _POLLING_GENERATION_CONTEXT.get()
|
|
result = await super().do_request(*args, **kwargs)
|
|
adapter._observe_polling_request_result(self, generation, result)
|
|
return result
|
|
|
|
request.__class__ = _InstrumentedPollingRequest
|
|
return request
|
|
|
|
async def _start_polling_once(
|
|
self, app, *, drop_pending_updates: bool, error_callback, abandon_app_on_timeout: bool = False,
|
|
schedule_verifier: bool = True) -> tuple[int, asyncio.Event]:
|
|
"""Start one generation and verify real getUpdates progress. Returns this generation's
|
|
``(generation, progress_event)`` so readiness-gating callers bind to exactly it."""
|
|
if self._teardown_started:
|
|
raise _PollingLifecycleAbort("Telegram polling teardown started")
|
|
generation, progress = self._begin_polling_generation()
|
|
if not self._polling_progress_accepting:
|
|
raise _PollingLifecycleAbort("Telegram polling teardown started")
|
|
|
|
def _generation_error_callback(error: Exception) -> None:
|
|
if self._teardown_started or generation != self._polling_generation or error_callback is None:
|
|
return
|
|
callback_context_token = _POLLING_GENERATION_CONTEXT.set(None)
|
|
try:
|
|
error_callback(error)
|
|
finally:
|
|
_POLLING_GENERATION_CONTEXT.reset(callback_context_token)
|
|
|
|
context_token = _POLLING_GENERATION_CONTEXT.set(generation)
|
|
try:
|
|
# asyncio.wait_for can wait forever on httpcore/AnyIO shielded scopes; use the wall-deadline
|
|
# helper and abandon the partial updater (caller rebuilds).
|
|
await _await_with_thread_deadline(
|
|
app.updater.start_polling(
|
|
allowed_updates=Update.ALL_TYPES, drop_pending_updates=drop_pending_updates, error_callback=_generation_error_callback),
|
|
timeout=_UPDATER_START_TIMEOUT,
|
|
on_abandon=((lambda app=app: _shutdown_abandoned_app(app)) if abandon_app_on_timeout else None))
|
|
finally:
|
|
_POLLING_GENERATION_CONTEXT.reset(context_token)
|
|
if self._teardown_started:
|
|
self._fence_polling()
|
|
raise _PollingLifecycleAbort("Telegram polling teardown started")
|
|
if schedule_verifier:
|
|
self._schedule_polling_progress_verifier(generation, progress)
|
|
return generation, progress
|
|
|
|
def _schedule_polling_progress_verifier(self, generation: int, progress: asyncio.Event) -> None:
|
|
"""Own exactly one tracked verifier for the current generation."""
|
|
if self._teardown_started:
|
|
self._fence_polling()
|
|
return
|
|
previous = getattr(self, "_polling_progress_verifier_task", None)
|
|
if previous is not None and not previous.done():
|
|
previous.cancel()
|
|
task = asyncio.get_running_loop().create_task(self._verify_polling_after_reconnect(generation, progress))
|
|
self._polling_progress_verifier_task = task
|
|
self._background_tasks.add(task)
|
|
|
|
def _clear_finished_verifier(finished: asyncio.Task) -> None:
|
|
self._background_tasks.discard(finished)
|
|
if self._polling_progress_verifier_task is finished:
|
|
self._polling_progress_verifier_task = None
|
|
|
|
task.add_done_callback(_clear_finished_verifier)
|
|
|
|
def _get_general_request_drain_lock(self) -> asyncio.Lock:
|
|
lock = getattr(self, "_general_request_drain_lock", None)
|
|
if lock is None:
|
|
lock = self._general_request_drain_lock = asyncio.Lock()
|
|
return lock
|
|
|
|
async def _drain_general_connections_after_pool_timeout(self) -> None:
|
|
"""Reset the general Bot API pool (``_request[1]``) after a confirmed send pool timeout — PTB
|
|
guarantees the request was not sent, so resetting before retrying is safe."""
|
|
bot = getattr(getattr(self, "_app", None), "bot", None)
|
|
if bot is None:
|
|
bot = getattr(self, "_bot", None)
|
|
if bot is None:
|
|
return
|
|
try:
|
|
general_req = bot._request[1] # noqa: SLF001
|
|
except Exception:
|
|
return
|
|
async with self._get_general_request_drain_lock():
|
|
await self._bounded_request_step(
|
|
general_req.shutdown(), "General request shutdown failed/timed out after pool timeout (non-fatal)")
|
|
if await self._bounded_request_step(
|
|
general_req.initialize(), "General request re-initialize failed/timed out after pool timeout (non-fatal)"):
|
|
logger.warning("[%s] General request pool drained after Telegram pool timeout", self.name)
|
|
|
|
def _spawn_polling_recovery(self, loop, coro) -> None:
|
|
"""Start ``coro`` as the tracked in-flight recovery task (reentrancy guard)."""
|
|
self._polling_error_task = loop.create_task(coro)
|
|
self._background_tasks.add(self._polling_error_task)
|
|
self._polling_error_task.add_done_callback(self._background_tasks.discard)
|
|
|
|
def _recovery_in_flight(self) -> bool:
|
|
return bool(self._polling_error_task and not self._polling_error_task.done())
|
|
|
|
def _schedule_polling_recovery(self, error: Exception, *, reason: str) -> None:
|
|
"""Schedule background polling recovery without failing gateway startup: a transient bootstrap
|
|
failure degrades only this adapter; the reconnect ladder recovers in the background."""
|
|
if self._teardown_started or self.has_fatal_error:
|
|
return
|
|
if self._recovery_in_flight():
|
|
logger.debug(
|
|
"[%s] Telegram polling recovery already scheduled; ignoring %s: %s", self.name, reason, _redact_telegram_error_text(error))
|
|
return
|
|
self._send_path_degraded = True
|
|
# Polling died mid-session on an adapter that published "connected"
|
|
# at connect time. Without this, gateway_state.json keeps saying
|
|
# connected for as long as the recovery ladder runs (#101391: 11 h).
|
|
if getattr(self, "_running", False):
|
|
self._mark_degraded()
|
|
logger.warning(
|
|
"[%s] Telegram polling degraded (%s); gateway stays alive and will retry. Error: %s", self.name, reason,
|
|
_redact_telegram_error_text(error))
|
|
self._spawn_polling_recovery(asyncio.get_running_loop(), self._handle_polling_network_error(error))
|
|
|
|
async def _delete_webhook_best_effort(self, *, require_success: bool = False) -> bool:
|
|
"""Clear a stale webhook; ``require_success`` (cold start) raises so GatewayRunner disposes the
|
|
partial adapter, while reconnects recover transient errors in background."""
|
|
if not self._bot:
|
|
return False
|
|
delete_webhook = getattr(self._bot, "delete_webhook", None)
|
|
if not callable(delete_webhook):
|
|
return True
|
|
try:
|
|
# Same shielded-cancellation class as initialize/start_polling: never let it pin connect.
|
|
await _await_with_thread_deadline(delete_webhook(drop_pending_updates=False), timeout=_UPDATER_START_TIMEOUT)
|
|
return True
|
|
except Exception as err:
|
|
if not self._looks_like_network_error(err):
|
|
raise
|
|
if require_success:
|
|
raise OSError("Telegram deleteWebhook did not complete during initial connect") from err
|
|
logger.warning(
|
|
"[%s] deleteWebhook failed with a recoverable network error; continuing to polling so getUpdates/retry can recover: %s",
|
|
self.name, _redact_telegram_error_text(err))
|
|
self._send_path_degraded = True
|
|
return False
|
|
|
|
async def _await_cold_start_readiness(self, progress: asyncio.Event, strict_error_event: asyncio.Event, strict_error: list) -> None:
|
|
"""Cold start: wait for THIS generation's first getUpdates success or the first polling error;
|
|
raises OSError so GatewayRunner disposes the partial adapter and retries fresh."""
|
|
progress_wait = asyncio.ensure_future(progress.wait())
|
|
error_wait = asyncio.ensure_future(strict_error_event.wait())
|
|
try:
|
|
# Losers are NOT cancelled here; the finally below does it.
|
|
await _await_with_thread_deadline(
|
|
asyncio.wait({progress_wait, error_wait}, return_when=asyncio.FIRST_COMPLETED), timeout=_INITIAL_POLLING_PROGRESS_TIMEOUT)
|
|
except asyncio.TimeoutError as exc:
|
|
raise OSError(
|
|
"Telegram getUpdates made no progress within "
|
|
f"{_INITIAL_POLLING_PROGRESS_TIMEOUT:.0f}s during initial "
|
|
"connect — failing startup so the gateway retries with a fresh adapter (#67498)"
|
|
) from exc
|
|
finally:
|
|
for fut in (progress_wait, error_wait):
|
|
if not fut.done():
|
|
fut.cancel()
|
|
await asyncio.gather(progress_wait, error_wait, return_exceptions=True)
|
|
if strict_error and not progress.is_set():
|
|
raise OSError(
|
|
"Telegram polling errored before first getUpdates success during initial connect: "
|
|
f"{_redact_telegram_error_text(strict_error[0])}"
|
|
) from strict_error[0]
|
|
if not progress.is_set():
|
|
raise OSError("Telegram getUpdates did not become ready during initial connect")
|
|
|
|
async def _start_polling_resilient(self, *, drop_pending_updates: bool, error_callback, require_progress: bool = False) -> bool:
|
|
"""Start PTB polling; ``require_progress`` (initial connect) demands real readiness. Reconnects
|
|
may recover in background; on cold start a bootstrap failure raises (see _await_cold_start_readiness)."""
|
|
if self._teardown_started:
|
|
return False
|
|
if not (self._app and self._app.updater):
|
|
raise RuntimeError("Telegram application/updater not initialized")
|
|
# Strict cold start: background recovery must not run while the readiness gate waits, else a G1
|
|
# error starts G2 on the same partial app and GatewayRunner never disposes it.
|
|
strict_error: list[BaseException] = []
|
|
strict_error_event = asyncio.Event()
|
|
strict_gate_open = True
|
|
effective_callback = error_callback
|
|
if require_progress:
|
|
loop = asyncio.get_running_loop()
|
|
|
|
def _strict_error_callback(error: Exception) -> None:
|
|
# Once the gate closes, delegate so later errors still reach background recovery.
|
|
if not strict_gate_open:
|
|
if error_callback is not None:
|
|
error_callback(error)
|
|
return
|
|
if not strict_error:
|
|
strict_error.append(error)
|
|
# Called from the polling task; set on the loop to wake the strict waiter.
|
|
loop.call_soon_threadsafe(strict_error_event.set)
|
|
|
|
effective_callback = _strict_error_callback
|
|
try:
|
|
# Same watchdog bound as the reconnect ladders; the TimeoutError is an OSError subclass, so
|
|
# the except below classifies it as a network error → background recovery.
|
|
# Same watchdog bound as the reconnect ladders: a wedged httpx connection pool can hang
|
|
# start_polling() forever at bootstrap too (#59614).
|
|
generation, progress = await self._start_polling_once(
|
|
self._app, drop_pending_updates=drop_pending_updates, error_callback=effective_callback,
|
|
abandon_app_on_timeout=require_progress,
|
|
# The strict gate IS the cold-start verifier; a background one would race it.
|
|
schedule_verifier=not require_progress)
|
|
if require_progress:
|
|
await self._await_cold_start_readiness(progress, strict_error_event, strict_error)
|
|
# Readiness proven — close the gate so later errors reach background recovery.
|
|
strict_gate_open = False
|
|
self._polling_error_callback_ref = error_callback
|
|
return True
|
|
except _PollingLifecycleAbort:
|
|
return False
|
|
except Exception as err:
|
|
if self._teardown_started:
|
|
return False
|
|
if require_progress:
|
|
raise
|
|
if self._looks_like_polling_conflict(err):
|
|
logger.warning(
|
|
"[%s] Telegram polling bootstrap conflict; gateway stays alive while conflict retry runs: %s",
|
|
self.name, _redact_telegram_error_text(err))
|
|
self._spawn_polling_recovery(asyncio.get_running_loop(), self._handle_polling_conflict(err))
|
|
return False
|
|
if self._looks_like_network_error(err):
|
|
self._schedule_polling_recovery(err, reason="polling bootstrap")
|
|
return False
|
|
raise
|
|
|
|
async def _go_fatal_network(self, message: str, log_message: str, *log_args) -> None:
|
|
"""Retryable ``telegram_network_error`` fatal + runner handoff (supervisor rebuilds the adapter)."""
|
|
logger.error(log_message, *log_args)
|
|
self._set_fatal_error("telegram_network_error", message, retryable=True)
|
|
await self._handoff_polling_fatal_error()
|
|
|
|
async def _stop_updater_or_go_fatal(self, app, what: str) -> bool:
|
|
"""Bounded ``updater.stop()`` before a recovery restart; False = went fatal, caller returns.
|
|
|
|
Wall-clock deadline, not asyncio.wait_for: a CLOSE-WAIT socket wedges stop() on epoll and PTB/AnyIO shielded cleanup
|
|
hangs wait_for. On timeout the Updater's lifecycle lock may still be held, so rebuild the adapter instead."""
|
|
try:
|
|
if app and app.updater and app.updater.running:
|
|
try:
|
|
await _await_with_thread_deadline(app.updater.stop(), timeout=_UPDATER_STOP_TIMEOUT)
|
|
except asyncio.TimeoutError:
|
|
message = (
|
|
f"Telegram updater.stop() did not finish before the {what} deadline; "
|
|
"rebuilding the adapter instead of reusing an Updater whose lifecycle lock may still be held.")
|
|
await self._go_fatal_network(message, "[%s] %s (likely CLOSE-WAIT socket)", self.name, message)
|
|
return False
|
|
except Exception:
|
|
pass
|
|
return True
|
|
|
|
def _restart_polling_in_task(self, coro) -> None:
|
|
"""Run a recovery coroutine as the tracked in-flight ``_polling_error_task``."""
|
|
self._polling_error_task = asyncio.get_running_loop().create_task(coro)
|
|
|
|
async def _handle_polling_network_error(self, error: Exception) -> None:
|
|
"""Reconnect polling after a transient network interruption (NetworkError/TimedOut).
|
|
|
|
Host connectivity loss (sleep, WiFi switch, VPN) kills the long-poll silently. Exponential back-off (5s→60s
|
|
cap) up to MAX_NETWORK_RETRIES, then retryable-fatal so the supervisor restarts the gateway."""
|
|
if self._teardown_started or self.has_fatal_error:
|
|
return
|
|
MAX_NETWORK_RETRIES = 10
|
|
BASE_DELAY = 5
|
|
MAX_DELAY = 60
|
|
self._polling_network_error_count += 1
|
|
self._send_path_degraded = True
|
|
attempt = self._polling_network_error_count
|
|
if attempt > MAX_NETWORK_RETRIES:
|
|
message = (
|
|
"Telegram polling could not reconnect after %d network error retries. "
|
|
"Escalating to gateway recovery." % MAX_NETWORK_RETRIES)
|
|
await self._go_fatal_network(message, "[%s] %s Last error: %s", self.name, message, _redact_telegram_error_text(error))
|
|
return
|
|
delay = min(BASE_DELAY * (2 ** (attempt - 1)), MAX_DELAY)
|
|
logger.warning(
|
|
"[%s] Telegram network error (attempt %d/%d), reconnecting in %ds. Error: %s", self.name, attempt,
|
|
MAX_NETWORK_RETRIES, delay, _redact_telegram_error_text(error))
|
|
await asyncio.sleep(delay)
|
|
if self._teardown_started:
|
|
return
|
|
# Stable local ref: a concurrent disconnect() may set self._app = None while we await.
|
|
app = self._app
|
|
# Unguarded stop() on a CLOSE-WAIT socket would leave _polling_error_task perpetually
|
|
# "in-flight" so every probe skips reconnect for hours.
|
|
if not await self._stop_updater_or_go_fatal(app, "network-recovery") or self._teardown_started:
|
|
return
|
|
# start_polling() bootstraps through the *general* pool before getUpdates; a confirmed pool timeout means the request
|
|
# was never sent, so rebuilding that pool is safe. Generic network errors stay polling-only (sends untouched).
|
|
if self._looks_like_pool_timeout(error):
|
|
await self._drain_general_connections_after_pool_timeout()
|
|
if self._teardown_started:
|
|
return
|
|
await self._drain_polling_connections()
|
|
if self._teardown_started:
|
|
return
|
|
try:
|
|
if not app:
|
|
raise RuntimeError("Telegram application was torn down during reconnect")
|
|
await self._start_polling_once(app, drop_pending_updates=False, error_callback=self._polling_error_callback_ref)
|
|
logger.info(
|
|
"[%s] Telegram polling restarted after network error (attempt %d); health pending getUpdates progress", self.name, attempt)
|
|
except _PollingLifecycleAbort:
|
|
return
|
|
except Exception as retry_err:
|
|
if self._teardown_started:
|
|
return
|
|
logger.warning("[%s] Telegram polling reconnect failed: %s", self.name, _redact_telegram_error_text(retry_err))
|
|
# Polling is dead and no more error callbacks will fire — chain the retry ourselves.
|
|
if not self.has_fatal_error and not self._teardown_started:
|
|
task = asyncio.ensure_future(self._handle_polling_network_error(retry_err))
|
|
self._background_tasks.add(task)
|
|
task.add_done_callback(self._background_tasks.discard)
|
|
# The chained retry IS the in-flight recovery: it must replace the reentrancy guard.
|
|
self._polling_error_task = task
|
|
|
|
async def _polling_heartbeat_loop(self) -> None:
|
|
"""Detect dead Telegram TCP sockets (CLOSE-WAIT) by periodic probing.
|
|
|
|
In CLOSE-WAIT epoll still reports the long-poll socket readable and nothing raises, so PTB's
|
|
``error_callback`` never fires. Probe ``get_me()`` on the *general* path (never the getUpdates pool);
|
|
connect-level failures feed ``_handle_polling_network_error``. Runs for the connection's lifetime, catching
|
|
steady-state wedges the one-shot verifier can't."""
|
|
HEARTBEAT_INTERVAL = 90 # seconds between probes
|
|
PROBE_TIMEOUT = 15 # seconds before declaring the path dead
|
|
# Wedged-recovery watchdog: note when a recovery task is first seen in-flight and force-escalate
|
|
# if the *same* task object still runs past the stuck timeout.
|
|
# Tracked locally so no _polling_error_task assignment site needs to stamp a timestamp: the
|
|
# heartbeat notes when it first observes a given recovery task still in-flight, and force-escalates
|
|
# if the *same* task object is still running after _POLLING_ERROR_TASK_STUCK_TIMEOUT. A healthy
|
|
# ladder attempt completes (task done) or chains to a new task well before then, so a single
|
|
# long-lived task is unambiguously wedged. See #66377.
|
|
stuck_task_ref: Optional[asyncio.Task] = None
|
|
stuck_task_since = 0.0
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
|
if self._teardown_started or self.has_fatal_error:
|
|
return
|
|
# A recovery task hung on an unbounded await gates every other recovery path forever
|
|
# (alive but deaf): force retryable-fatal so the reconnector rebuilds the adapter.
|
|
# Independent wedged-recovery watchdog (#66377): if the tracked recovery task has hung (any
|
|
# await no local bound covers), every other recovery path is gated behind it and returns
|
|
# early forever — the gateway stays alive but deaf.
|
|
recovery_task = self._polling_error_task
|
|
if recovery_task is not None and not recovery_task.done():
|
|
now = time.monotonic()
|
|
if recovery_task is not stuck_task_ref:
|
|
stuck_task_ref = recovery_task
|
|
stuck_task_since = now
|
|
elif now - stuck_task_since > _POLLING_ERROR_TASK_STUCK_TIMEOUT:
|
|
stuck_for = now - stuck_task_since
|
|
logger.error(
|
|
"[%s] Telegram reconnect task wedged for %.0fs with no ladder progress; forcing retryable-fatal so the gateway "
|
|
"reconnects instead of staying silently deaf.",
|
|
self.name, stuck_for)
|
|
with contextlib.suppress(Exception):
|
|
recovery_task.cancel()
|
|
self._set_fatal_error(
|
|
"telegram_network_error",
|
|
"Telegram reconnect task wedged for %.0fs; forcing gateway reconnect." % stuck_for,
|
|
retryable=True)
|
|
await self._handoff_polling_fatal_error()
|
|
return
|
|
else:
|
|
stuck_task_ref = None
|
|
bot = self._app.bot if self._app else None
|
|
if bot is None:
|
|
continue
|
|
# No get_me() ⇒ not a live polling client (torn down / test double): exit, don't spin.
|
|
if not callable(getattr(bot, "get_me", None)):
|
|
return
|
|
await asyncio.wait_for(bot.get_me(), PROBE_TIMEOUT)
|
|
# get_me() refreshes PTB's cached bot user: adopt a BotFather rename before routing on it.
|
|
self._bot_identity_checked_at = time.monotonic()
|
|
self._note_bot_username(getattr(bot, "username", None))
|
|
# get_me() OK proves only the send path; a wedged long-poll shows as server-side queue.
|
|
# get_me() succeeded — the general/send request path is healthy. That does NOT prove the
|
|
# getUpdates consumer is alive: PTB can report updater.running=True while the long-poll task
|
|
# is wedged, so DMs queue in the Bot API and never reach handlers (#42909). get_me() is
|
|
# blind to this; get_webhook_info() exposes it via pending_update_count. Escalate only after
|
|
# two consecutive probes see a non-zero queue while we believe we're polling, so a single
|
|
# in-flight update (consumed before the next probe) never trips recovery.
|
|
await self._probe_pending_updates(bot, PROBE_TIMEOUT)
|
|
# An empty queue can't hide a wedge forever: no round-trip past the stall threshold ⇒ dead.
|
|
# Even an empty queue cannot hide a wedged long-poll forever: Telegram answers within ~50s,
|
|
# so a consumer with no successful round-trip past the stall threshold is dead (#92991).
|
|
# Pure local-state check — no Bot API call needed.
|
|
await self._check_polling_stall()
|
|
# Transport health is not dispatch health (#102260). Pure local-state check.
|
|
self._check_ingress_dispatch_stall()
|
|
except asyncio.CancelledError:
|
|
return
|
|
except (asyncio.TimeoutError, OSError) as probe_err:
|
|
self._schedule_polling_recovery(probe_err, reason="heartbeat probe")
|
|
except Exception as probe_err:
|
|
# Non-connectivity errors (e.g. TelegramError 401) aren't CLOSE-WAIT symptoms.
|
|
if self._looks_like_network_error(probe_err):
|
|
self._schedule_polling_recovery(probe_err, reason="heartbeat probe")
|
|
|
|
async def _probe_pending_updates(self, bot, probe_timeout: float) -> None:
|
|
"""Detect a wedged or stopped getUpdates consumer via pending_update_count.
|
|
|
|
PTB can report ``updater.running`` while the long-poll is stuck; get_me() stays healthy yet DMs queue in the
|
|
Bot API. A stuck queue over two consecutive probes ⇒ dead consumer. Also covers the updater having stopped
|
|
entirely (``running=False``, no reconnect in flight).
|
|
|
|
PTB can report ``updater.running == True`` while its long-poll task is silently stuck (e.g. a socket
|
|
that epoll keeps reporting readable on WSL2). ``get_me()`` stays healthy because it uses the general
|
|
request path, so the CLOSE-WAIT heartbeat never fires — yet DMs queue in the Bot API and never reach
|
|
handlers (#42909).
|
|
We detect the stopped updater directly and feed the same ladder (#55769).
|
|
"""
|
|
# Polling mode only: in webhook mode Telegram pushes and holds no server-side queue.
|
|
if self._teardown_started or self._webhook_mode:
|
|
return
|
|
# An in-flight reconnect owns recovery — don't double-trigger, and don't misread its brief
|
|
# stop()->start_polling() window (updater.running transiently False) as dead.
|
|
if self._recovery_in_flight():
|
|
self._polling_not_running_count = 0
|
|
return
|
|
updater = getattr(self._app, "updater", None) if self._app else None
|
|
if updater is None:
|
|
self._polling_pending_stuck_count = 0
|
|
return
|
|
if not getattr(updater, "running", False):
|
|
# Long-poll task gone, general-path calls still succeed, so no error_callback/probe ever
|
|
# fires. Debounced over two probes so a just-starting updater never trips it.
|
|
self._polling_pending_stuck_count = 0
|
|
# We are in polling mode with no reconnect in flight, yet PTB's updater has stopped entirely.
|
|
# This is distinct from the wedged-but-running consumer handled below: the long-poll task is
|
|
# gone, get_me()/get_webhook_info() on the general request path still succeed, so no
|
|
# error_callback or connectivity probe ever fires and the gateway silently stops receiving
|
|
# messages while the process stays alive (#55769).
|
|
self._polling_not_running_count += 1
|
|
logger.warning(
|
|
"[%s] Telegram polling heartbeat: updater stopped while in polling mode (stuck probe %d/2)", self.name,
|
|
self._polling_not_running_count)
|
|
if self._polling_not_running_count >= 2:
|
|
self._polling_not_running_count = 0
|
|
self._escalate_stuck_consumer(
|
|
"[%s] Telegram updater is not running (long-poll task gone); triggering polling restart",
|
|
"Telegram updater stopped while in polling mode")
|
|
return
|
|
self._polling_not_running_count = 0
|
|
get_webhook_info = getattr(bot, "get_webhook_info", None)
|
|
if not callable(get_webhook_info):
|
|
return
|
|
try:
|
|
info = await asyncio.wait_for(get_webhook_info(), probe_timeout) # type: ignore[arg-type]
|
|
except (asyncio.TimeoutError, OSError):
|
|
return # connectivity symptom for the get_me() path, not a stuck-queue signal
|
|
pending = int(getattr(info, "pending_update_count", 0) or 0)
|
|
if pending <= 0:
|
|
self._polling_pending_stuck_count = 0
|
|
return
|
|
self._polling_pending_stuck_count += 1
|
|
logger.warning(
|
|
"[%s] Telegram polling heartbeat: %d update(s) queued but not consumed (stuck probe %d/2)", self.name,
|
|
pending, self._polling_pending_stuck_count)
|
|
if self._polling_pending_stuck_count >= 2:
|
|
self._polling_pending_stuck_count = 0
|
|
self._escalate_stuck_consumer(
|
|
"[%s] getUpdates consumer appears wedged (queue not draining); triggering polling restart",
|
|
"getUpdates consumer wedged: pending updates not draining")
|
|
|
|
def _escalate_stuck_consumer(self, log_message: str, reason: str) -> None:
|
|
"""Second consecutive stuck probe: restart polling via the network-error ladder (unless tearing down)."""
|
|
if self._teardown_started:
|
|
return
|
|
logger.warning(log_message, self.name)
|
|
self._polling_error_task = asyncio.get_running_loop().create_task(self._handle_polling_network_error(RuntimeError(reason)))
|
|
|
|
def _check_ingress_dispatch_stall(self) -> None:
|
|
"""Report fetched updates PTB's dispatcher is not handing to handlers (#102260).
|
|
|
|
``received`` and ``dispatched`` count the same population (every fetched update reaches the
|
|
group-99 catch-all: no handler raises ApplicationHandlerStop, no error handler is registered),
|
|
so a backlog with no dispatch progress across ``_INGRESS_DISPATCH_STALL_HEARTBEATS`` heartbeats
|
|
is a wedged dispatcher at any traffic rate. Reports once per stall, re-arms on progress.
|
|
"""
|
|
if self._webhook_mode or self._teardown_started or self.has_fatal_error:
|
|
return
|
|
received = getattr(self, "_updates_received_total", 0)
|
|
dispatched = getattr(self, "_updates_dispatched_total", 0)
|
|
if received <= dispatched or dispatched != getattr(self, "_ingress_dispatched_seen", 0):
|
|
self._ingress_dispatched_seen = dispatched
|
|
self._ingress_stalled_heartbeats = 0
|
|
return
|
|
stalled = getattr(self, "_ingress_stalled_heartbeats", 0)
|
|
if stalled >= _INGRESS_DISPATCH_STALL_HEARTBEATS:
|
|
return # already reported this stall
|
|
self._ingress_stalled_heartbeats = stalled + 1
|
|
if stalled + 1 < _INGRESS_DISPATCH_STALL_HEARTBEATS:
|
|
return
|
|
logger.warning(
|
|
"[%s] Telegram ingress is healthy but deaf: %d update(s) fetched by getUpdates have not been "
|
|
"dispatched to any handler across %d heartbeats (%d received, %d dispatched, generation %d). "
|
|
"Polling is fine; PTB's dispatcher is not draining its queue.",
|
|
self.name, received - dispatched, _INGRESS_DISPATCH_STALL_HEARTBEATS, received, dispatched,
|
|
getattr(self, "_polling_generation", 0))
|
|
|
|
async def _check_polling_stall(self) -> None:
|
|
"""Watchdog the last successful getUpdates round-trip: a long-poll can wedge without raising
|
|
(CLOSE-WAIT after a route flip) while every other probe stays blind; no round-trip for
|
|
``_POLLING_STALL_TIMEOUT`` ⇒ escalate through the bounded reconnect ladder.
|
|
|
|
See #92991.
|
|
"""
|
|
if self._webhook_mode or self._teardown_started or self.has_fatal_error or self._recovery_in_flight():
|
|
return
|
|
now = time.monotonic()
|
|
last_progress = getattr(self, "_polling_last_progress_monotonic", None)
|
|
generation_started = getattr(self, "_polling_generation_started_monotonic", None)
|
|
if last_progress is not None:
|
|
stalled_for = now - last_progress
|
|
elif generation_started is not None:
|
|
# No round-trip yet this generation: fallback for when the one-shot verifier could not run.
|
|
stalled_for = now - generation_started
|
|
else:
|
|
return
|
|
if stalled_for <= _POLLING_STALL_TIMEOUT:
|
|
return
|
|
logger.error(
|
|
"[%s] Telegram polling stalled: no getUpdates progress for %.0fs "
|
|
"(generation %d). Rebuilding the long-poll consumer through the reconnect ladder instead of staying silently deaf.",
|
|
self.name, stalled_for, getattr(self, "_polling_generation", 0))
|
|
self._spawn_polling_recovery(
|
|
asyncio.get_running_loop(),
|
|
self._handle_polling_network_error(
|
|
RuntimeError("getUpdates made no progress for %.0fs (polling stall watchdog)" % stalled_for)))
|
|
|
|
def _verifier_stale(self, generation: int, progress: asyncio.Event) -> bool:
|
|
"""True when a verifier's generation no longer matters (progressed, fatal, replaced, torn down)."""
|
|
return (
|
|
self._teardown_started or progress.is_set() or self.has_fatal_error
|
|
or not self._polling_progress_accepting or generation != self._polling_generation
|
|
or progress is not self._polling_progress_event)
|
|
|
|
async def _verify_polling_after_reconnect(self, generation: Optional[int] = None, progress: Optional[asyncio.Event] = None) -> None:
|
|
"""Require getUpdates progress, using getMe only to classify failure: a general-path getMe
|
|
success cannot heal polling health. Connectivity failures enter the guarded recovery ladder."""
|
|
PROBE_TIMEOUT = 10
|
|
if self._teardown_started:
|
|
return
|
|
if generation is None:
|
|
generation = self._polling_generation
|
|
if progress is None:
|
|
progress = self._polling_progress_event
|
|
with contextlib.suppress(asyncio.TimeoutError):
|
|
await asyncio.wait_for(progress.wait(), timeout=_POLLING_PROGRESS_TIMEOUT)
|
|
if self._verifier_stale(generation, progress):
|
|
return
|
|
app = self._app
|
|
if not (app and app.updater and app.updater.running):
|
|
logger.warning("[%s] Updater made no getUpdates progress and is not running", self.name)
|
|
self._schedule_polling_recovery(
|
|
RuntimeError("Updater not running after polling progress deadline"),
|
|
reason="polling progress verifier: updater not running")
|
|
return
|
|
try:
|
|
await asyncio.wait_for(app.bot.get_me(), PROBE_TIMEOUT)
|
|
except Exception as probe_err:
|
|
if self._verifier_stale(generation, progress):
|
|
return
|
|
if not self._looks_like_network_error(probe_err):
|
|
logger.warning(
|
|
"[%s] Polling progress verifier hit a non-connectivity error (not retrying): %s", self.name,
|
|
_redact_telegram_error_text(probe_err))
|
|
return
|
|
logger.warning(
|
|
"[%s] Polling progress verifier connectivity probe failed: %s", self.name, _redact_telegram_error_text(probe_err))
|
|
self._schedule_polling_recovery(probe_err, reason="polling progress verifier connectivity failure")
|
|
return
|
|
if self._verifier_stale(generation, progress):
|
|
return
|
|
self._schedule_polling_recovery(
|
|
RuntimeError("getUpdates made no progress before verifier deadline"),
|
|
reason="polling progress verifier: general path healthy but getUpdates stalled")
|
|
|
|
def _disarm_ptb_retry_loop(self) -> None:
|
|
"""Synchronously stop PTB's internal polling retry loop.
|
|
|
|
PTB's ``network_retry_loop`` calls our ``error_callback`` *synchronously* on a 409 Conflict then polls again; our
|
|
callback only schedules async recovery, so two sessions overlap and Telegram 409s on a ~31s cadence. Setting PTB's
|
|
private ``stop_event`` makes its loop exit on the next tick; ``updater.stop()`` + drain + ``start_polling()`` then build
|
|
a fresh one. Best-effort across PTB spellings. Deliberately NOT flipping ``updater._running``: stop() raises when
|
|
already False, which would skip the real teardown and poison the next start."""
|
|
updater = getattr(self._app, "updater", None) if self._app else None
|
|
if updater is None:
|
|
return
|
|
for attr in ("_Updater__polling_task_stop_event", "_polling_task_stop_event"):
|
|
stop_event = getattr(updater, attr, None)
|
|
if isinstance(stop_event, asyncio.Event):
|
|
if not stop_event.is_set():
|
|
stop_event.set()
|
|
logger.debug("[%s] Disarmed PTB polling retry loop via %s", self.name, attr)
|
|
return
|
|
logger.debug(
|
|
"[%s] Could not disarm PTB polling retry loop (stop_event not found on this PTB version); falling back to async stop()",
|
|
self.name)
|
|
|
|
async def _handle_polling_conflict(self, error: Exception) -> None:
|
|
"""Recover a 409 Conflict: the previous gateway process was killed but Telegram holds its
|
|
getUpdates session ~30s. Stop, wait (growing delay), drain, restart — MAX_CONFLICT_RETRIES
|
|
times before going fatal; a failed retry must never return silently (limbo)."""
|
|
if self._teardown_started:
|
|
return
|
|
if self.has_fatal_error and self.fatal_error_code == "telegram_polling_conflict":
|
|
return
|
|
self._polling_conflict_count += 1
|
|
MAX_CONFLICT_RETRIES = 5
|
|
# 15s, 25s, 35s, 45s, 55s — clears Telegram's ~30s session window without hammering the API.
|
|
RETRY_DELAY = 10 + (self._polling_conflict_count * 10) # seconds
|
|
if self._polling_conflict_count <= MAX_CONFLICT_RETRIES:
|
|
logger.warning(
|
|
"[%s] Telegram polling conflict (%d/%d) — previous session still "
|
|
"held open on Telegram's servers. Waiting %ds for it to expire. Error: %s",
|
|
self.name, self._polling_conflict_count, MAX_CONFLICT_RETRIES,
|
|
RETRY_DELAY, _redact_telegram_error_text(error))
|
|
# Stop the updater before sleeping (no-op if PTB raised before running was set).
|
|
if not await self._stop_updater_or_go_fatal(self._app, "conflict-retry"):
|
|
return
|
|
await asyncio.sleep(RETRY_DELAY)
|
|
if self._teardown_started:
|
|
return
|
|
await self._drain_polling_connections()
|
|
if self._teardown_started:
|
|
return
|
|
# Stable local ref: a concurrent disconnect() may null self._app across the awaits above.
|
|
app = self._app
|
|
# Capture a stable local reference: self._app can be reassigned to None by a concurrent
|
|
# disconnect() while we're suspended across the awaits above (same race #55992 fixed on the
|
|
# network path). Re-reading self._app after that point would raise AttributeError deep inside
|
|
# start_polling instead of failing fast here, where the except below reschedules or escalates to
|
|
# fatal.
|
|
expected_generation = self._polling_generation + 1
|
|
if not app:
|
|
raise RuntimeError("Telegram application was torn down during conflict reconnect")
|
|
# drop_pending_updates=True makes Telegram terminate any other getUpdates session for this
|
|
# token (zombie or our own prior retry); without it each retry is immediately 409'd.
|
|
# The competing session is either a zombie from the previous gateway process (whose long-poll
|
|
# hasn't expired server-side yet) or our own previous retry's still-expiring session. Without
|
|
# this, each retry starts a new getUpdates session that immediately gets 409'd by the previous
|
|
# one, creating the very conflict we are trying to recover from (#75017).
|
|
self._polling_conflict_recovery_generation = expected_generation
|
|
try:
|
|
await self._start_polling_once(app, drop_pending_updates=True, error_callback=self._polling_error_callback_ref)
|
|
logger.info(
|
|
"[%s] Telegram polling restarted after conflict retry %d/%d; health pending getUpdates progress",
|
|
self.name, self._polling_conflict_count, MAX_CONFLICT_RETRIES)
|
|
return
|
|
except _PollingLifecycleAbort:
|
|
return
|
|
except Exception as retry_err:
|
|
if self._teardown_started:
|
|
return
|
|
logger.warning(
|
|
"[%s] Telegram polling retry %d/%d failed: %s. Scheduling next attempt.", self.name,
|
|
self._polling_conflict_count, MAX_CONFLICT_RETRIES, _redact_telegram_error_text(retry_err))
|
|
# Never return silently: alive-and-"connected" with no polling is limbo.
|
|
if self._polling_conflict_count < MAX_CONFLICT_RETRIES and not self._teardown_started:
|
|
# get_running_loop(): get_event_loop() raises on 3.10+ from PTB's callback context.
|
|
self._restart_polling_in_task(self._handle_polling_conflict(retry_err))
|
|
return
|
|
# Fall through to fatal on the last retry.
|
|
finally:
|
|
if self._polling_conflict_recovery_generation == expected_generation:
|
|
self._polling_conflict_recovery_generation = None
|
|
if self._teardown_started:
|
|
return
|
|
# Retries exhausted — fatal so the runner surfaces it and the user knows to act.
|
|
message = (
|
|
"Telegram polling could not recover after %d retries (%ds total wait). "
|
|
"The previous gateway session is still held open on Telegram's servers, "
|
|
"or another process is using the same bot token. To recover: ensure no other Hermes or OpenClaw instance is running "
|
|
"with this token, then restart the gateway with 'hermes gateway restart'."
|
|
% (MAX_CONFLICT_RETRIES, sum(10 + i * 10 for i in range(1, MAX_CONFLICT_RETRIES + 1))))
|
|
logger.error("[%s] %s Original error: %s", self.name, message, _redact_telegram_error_text(error))
|
|
# Snapshot whether WE transition to fatal: a concurrent retry task suspended past the entry
|
|
# guard reaches this branch too. Only the first transition notifies.
|
|
_already_fatal = self.has_fatal_error and self.fatal_error_code == "telegram_polling_conflict"
|
|
self._set_fatal_error("telegram_polling_conflict", message, retryable=False)
|
|
try:
|
|
if self._app and self._app.updater:
|
|
await _await_with_thread_deadline(self._app.updater.stop(), timeout=_UPDATER_STOP_TIMEOUT)
|
|
except asyncio.TimeoutError:
|
|
logger.warning("[%s] updater.stop() timed out after exhausting conflict retries (likely CLOSE-WAIT socket); proceeding to fatal notify", self.name)
|
|
except Exception as stop_error:
|
|
logger.warning(
|
|
"[%s] Failed stopping Telegram updater after exhausting conflict retries: %s", self.name, stop_error,
|
|
exc_info=True,
|
|
)
|
|
if not _already_fatal:
|
|
await self._handoff_polling_fatal_error()
|
|
|
|
async def _handoff_polling_fatal_error(self) -> None:
|
|
"""Notify the runner without letting child teardown cancel this owner: ``disconnect()`` cancels
|
|
the tracked recovery/heartbeat tasks, so release only the current owner from its field."""
|
|
current_task = asyncio.current_task()
|
|
if self._polling_error_task is current_task:
|
|
self._polling_error_task = None
|
|
if getattr(self, "_polling_heartbeat_task", None) is current_task:
|
|
self._polling_heartbeat_task = None
|
|
await self._notify_fatal_error()
|
|
|
|
async def _create_dm_topic(
|
|
self, chat_id: int, name: str, icon_color: Optional[int] = None, icon_custom_emoji_id: Optional[str] = None) -> Optional[int]:
|
|
"""Create a forum topic in a private (DM) chat (Bot API 9.4+); message_thread_id or None."""
|
|
if not self._bot:
|
|
return None
|
|
try:
|
|
kwargs: Dict[str, Any] = {"chat_id": chat_id, "name": name}
|
|
if icon_color is not None:
|
|
kwargs["icon_color"] = icon_color
|
|
if icon_custom_emoji_id:
|
|
kwargs["icon_custom_emoji_id"] = icon_custom_emoji_id
|
|
topic = await self._bot.create_forum_topic(**kwargs)
|
|
thread_id = topic.message_thread_id
|
|
logger.info("[%s] Created DM topic '%s' in chat %s -> thread_id=%s", self.name, name, chat_id, thread_id)
|
|
return thread_id
|
|
except Exception as e:
|
|
error_text = str(e).lower()
|
|
# Telegram has no "list topics" API: an existing topic is mapped from incoming messages.
|
|
if "topic_name_duplicate" in error_text or "already" in error_text:
|
|
logger.info(
|
|
"[%s] DM topic '%s' already exists in chat %s (will be mapped from incoming messages)", self.name, name, chat_id)
|
|
elif "not a forum" in error_text or "forums_disabled" in error_text:
|
|
logger.warning(
|
|
"[%s] Cannot create DM topic '%s' in chat %s: Topics mode is not enabled. "
|
|
"The user must open the DM with this bot in Telegram, tap the bot name "
|
|
"at the top, and enable 'Topics' in chat settings before topics can be created.",
|
|
self.name, name, chat_id)
|
|
else:
|
|
logger.warning(
|
|
"[%s] Failed to create DM topic '%s' in chat %s: %s", self.name, name, chat_id, _redact_telegram_error_text(e))
|
|
return None
|
|
|
|
async def create_handoff_thread(self, parent_chat_id: str, name: str) -> Optional[str]:
|
|
"""Create a forum topic for a session handoff; ``message_thread_id`` as str, or None."""
|
|
try:
|
|
chat_id_int = int(parent_chat_id)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
thread_id = await self._create_dm_topic(chat_id_int, name=name)
|
|
return str(thread_id) if thread_id else None
|
|
|
|
async def ensure_dm_topic(self, chat_id: str, topic_name: str, force_create: bool = False) -> Optional[str]:
|
|
"""Return a private DM topic thread id, creating and persisting it if needed."""
|
|
name = str(topic_name or "").strip()
|
|
if not name:
|
|
return None
|
|
try:
|
|
chat_id_int = int(chat_id)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
cache_key = f"{chat_id_int}:{name}"
|
|
cached = self._dm_topics.get(cache_key)
|
|
if cached and not force_create:
|
|
return str(cached)
|
|
topic_conf: Optional[Dict[str, Any]] = None
|
|
chat_entry: Optional[Dict[str, Any]] = None
|
|
for entry in self._dm_topics_config:
|
|
if str(entry.get("chat_id")) != str(chat_id_int):
|
|
continue
|
|
chat_entry = entry
|
|
topic_conf = next((c for c in entry.get("topics", []) if c.get("name") == name), None)
|
|
break
|
|
if topic_conf and topic_conf.get("thread_id") and not force_create:
|
|
thread_id = int(topic_conf["thread_id"])
|
|
self._dm_topics[cache_key] = thread_id
|
|
return str(thread_id)
|
|
if chat_entry is None:
|
|
chat_entry = {"chat_id": chat_id_int, "topics": []}
|
|
self._dm_topics_config.append(chat_entry)
|
|
if topic_conf is None:
|
|
topic_conf = {"name": name}
|
|
chat_entry.setdefault("topics", []).append(topic_conf)
|
|
thread_id = await self._create_dm_topic(
|
|
chat_id_int, name=name, icon_color=topic_conf.get("icon_color"), icon_custom_emoji_id=topic_conf.get("icon_custom_emoji_id"))
|
|
if not thread_id:
|
|
return None
|
|
topic_conf["thread_id"] = thread_id
|
|
self._dm_topics[cache_key] = int(thread_id)
|
|
self._persist_dm_topic_thread_id(chat_id_int, name, int(thread_id), replace_existing=force_create)
|
|
return str(thread_id)
|
|
|
|
async def rename_dm_topic(self, chat_id: int, thread_id: int, name: str) -> None:
|
|
"""Rename a forum topic in a private (DM) chat."""
|
|
if not self._bot:
|
|
return
|
|
try:
|
|
chat_id_arg = int(chat_id)
|
|
except (TypeError, ValueError):
|
|
chat_id_arg = chat_id
|
|
await self._bot.edit_forum_topic(chat_id=chat_id_arg, message_thread_id=int(thread_id), name=name)
|
|
logger.info("[%s] Renamed DM topic in chat %s thread_id=%s -> '%s'", self.name, chat_id, thread_id, name)
|
|
|
|
def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int, replace_existing: bool = False) -> None:
|
|
"""Save a newly created thread_id back into config.yaml so it survives restarts."""
|
|
try:
|
|
from hermes_constants import get_hermes_home
|
|
config_path = get_hermes_home() / "config.yaml"
|
|
if not config_path.exists():
|
|
logger.warning("[%s] Config file not found at %s, cannot persist thread_id", self.name, config_path)
|
|
return
|
|
from hermes_cli.config import atomic_config_write, read_user_config_raw
|
|
config = read_user_config_raw(config_path)
|
|
# platforms.telegram.extra.dm_topics — create the path for topics not predeclared in config.yaml.
|
|
dm_topics = config.setdefault("platforms", {}).setdefault("telegram", {}).setdefault("extra", {}).setdefault("dm_topics", [])
|
|
changed = False
|
|
matching_chat_entry = None
|
|
for chat_entry in dm_topics:
|
|
try:
|
|
if int(chat_entry.get("chat_id", 0)) != int(chat_id):
|
|
continue
|
|
except (TypeError, ValueError):
|
|
continue
|
|
matching_chat_entry = chat_entry
|
|
topics = chat_entry.setdefault("topics", [])
|
|
t = next((t for t in topics if t.get("name") == topic_name), None)
|
|
if t is None:
|
|
topics.append({"name": topic_name, "thread_id": thread_id})
|
|
changed = True
|
|
elif (replace_existing or not t.get("thread_id")) and t.get("thread_id") != thread_id:
|
|
t["thread_id"] = thread_id
|
|
changed = True
|
|
break
|
|
if matching_chat_entry is None:
|
|
dm_topics.append({"chat_id": chat_id, "topics": [{"name": topic_name, "thread_id": thread_id}]})
|
|
changed = True
|
|
if changed:
|
|
atomic_config_write(config_path, config, default_flow_style=False, sort_keys=False)
|
|
logger.info("[%s] Persisted thread_id=%s for topic '%s' in config.yaml", self.name, thread_id, topic_name)
|
|
except Exception as e:
|
|
logger.warning("[%s] Failed to persist thread_id to config: %s", self.name, e, exc_info=True)
|
|
|
|
async def _setup_dm_topics(self) -> None:
|
|
"""Load or create configured DM topics: ``extra['dm_topics']`` is ``[{"chat_id", "topics": [{"name",
|
|
"icon_color", "thread_id"?, "skill"?}]}]``; persisted thread_ids are cached without an API call."""
|
|
for chat_entry in self._dm_topics_config or ():
|
|
chat_id = chat_entry.get("chat_id")
|
|
topics = chat_entry.get("topics", [])
|
|
if not chat_id or not topics:
|
|
continue
|
|
logger.info("[%s] Setting up %d DM topic(s) for chat %s", self.name, len(topics), chat_id)
|
|
for topic_conf in topics:
|
|
topic_name = topic_conf.get("name")
|
|
if not topic_name:
|
|
continue
|
|
cache_key = f"{chat_id}:{topic_name}"
|
|
existing_thread_id = topic_conf.get("thread_id")
|
|
if existing_thread_id:
|
|
self._dm_topics[cache_key] = int(existing_thread_id)
|
|
logger.info("[%s] DM topic loaded from config: %s -> thread_id=%s", self.name, cache_key, existing_thread_id)
|
|
continue
|
|
thread_id = await self._create_dm_topic(
|
|
chat_id=normalize_telegram_chat_id(chat_id), name=topic_name, icon_color=topic_conf.get("icon_color"),
|
|
icon_custom_emoji_id=topic_conf.get("icon_custom_emoji_id"))
|
|
if not thread_id:
|
|
continue
|
|
self._dm_topics[cache_key] = thread_id
|
|
logger.info("[%s] DM topic cached: %s -> thread_id=%s", self.name, cache_key, thread_id)
|
|
self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id)
|
|
# Seed message: Telegram's client hides empty topics until they contain one.
|
|
try:
|
|
await self._bot.send_message(
|
|
chat_id=normalize_telegram_chat_id(chat_id), message_thread_id=thread_id, text=f"\U0001f4cc {topic_name}")
|
|
except Exception as seed_err:
|
|
logger.debug("[%s] Could not send seed message to topic '%s': %s", self.name, topic_name, seed_err)
|
|
|
|
async def _bot_identity_refresh_loop(self) -> None:
|
|
"""Keep the cached @username fresh in webhook mode (no heartbeat calls ``get_me()`` there)."""
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(self._BOT_IDENTITY_TTL_SECONDS)
|
|
if self._teardown_started or self.has_fatal_error:
|
|
return
|
|
await self._refresh_bot_identity(force=True)
|
|
except asyncio.CancelledError:
|
|
return
|
|
except Exception:
|
|
logger.debug("[%s] Telegram identity refresh loop iteration failed", self.name, exc_info=True)
|
|
|
|
def _start_post_connect_housekeeping(self) -> None:
|
|
"""Kick off deferred post-connect housekeeping; idempotent while a task is still running."""
|
|
task = self._post_connect_task
|
|
if task and not task.done():
|
|
return
|
|
self._post_connect_task = asyncio.ensure_future(self._run_post_connect_housekeeping())
|
|
|
|
async def _register_command_menu(self) -> None:
|
|
"""Register the command menu (from COMMAND_REGISTRY) in every scope — Telegram picks the
|
|
narrowest matching one per chat type; forum topics are handled lazily by _ensure_forum_commands."""
|
|
from telegram import BotCommand, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats, BotCommandScopeDefault
|
|
from hermes_cli.commands_platforms import telegram_menu_commands, telegram_menu_max_commands
|
|
if not self._bot:
|
|
return
|
|
# Telegram allows 100 commands but has an undocumented ~4KB payload limit; default cap 60.
|
|
max_commands = telegram_menu_max_commands()
|
|
menu_commands, hidden_count = telegram_menu_commands(max_commands=max_commands)
|
|
bot_commands = [BotCommand(name, desc) for name, desc in menu_commands]
|
|
for scope_cls in (BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats):
|
|
scope_name = getattr(scope_cls, "__name__", str(scope_cls))
|
|
try:
|
|
await self._bot.set_my_commands(bot_commands, scope=scope_cls())
|
|
logger.info("[%s] set_my_commands OK for scope %s (%d cmds)", self.name, scope_name, len(bot_commands))
|
|
except Exception as scope_err:
|
|
logger.warning("[%s] set_my_commands FAILED for scope %s: %s", self.name, scope_name, scope_err)
|
|
if hidden_count:
|
|
logger.info(
|
|
"[%s] Telegram menu: %d commands registered, %d hidden (over %d limit). Use /commands for full list.",
|
|
self.name, len(menu_commands), hidden_count, max_commands)
|
|
|
|
async def _run_post_connect_housekeeping(self) -> None:
|
|
"""Command menu, status indicator and DM topics off the connect path; every step is non-fatal.
|
|
|
|
DM topics — all off the connect path so a slow Bot API call cannot blow the gateway connect timeout
|
|
(#46298).
|
|
"""
|
|
try:
|
|
try:
|
|
await self._register_command_menu()
|
|
except Exception as e:
|
|
logger.warning(
|
|
"[%s] Could not register Telegram command menu: %s", self.name, _redact_telegram_error_text(e), exc_info=True)
|
|
with contextlib.suppress(Exception):
|
|
await self._set_status_indicator(online=True)
|
|
try:
|
|
await self._setup_dm_topics()
|
|
except Exception as topics_err:
|
|
logger.warning("[%s] DM topics setup failed (non-fatal): %s", self.name, topics_err, exc_info=True)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
finally:
|
|
if self._post_connect_task is asyncio.current_task():
|
|
self._post_connect_task = None
|
|
|
|
async def _on_platform_update(self, update, context) -> None:
|
|
"""Catch-all PTB handler (group 99) firing ``gateway_platform_event`` per inbound update with a
|
|
stable envelope (no raw SDK objects) and an internal auth source. Never raises into PTB."""
|
|
# Last handler group PTB runs: stamp before any early return so the dispatch counter covers
|
|
# gateways without the plugin hook (#102260).
|
|
self._updates_dispatched_total = getattr(self, "_updates_dispatched_total", 0) + 1
|
|
handler: Optional[Callable[[Dict[str, Any], Any], Awaitable[None]]] = getattr(self, "_platform_event_handler", None)
|
|
if handler is None:
|
|
return
|
|
try:
|
|
from hermes_cli.lifecycle import has_hook
|
|
if not has_hook("gateway_platform_event"):
|
|
return
|
|
event = self._normalize_platform_event(update)
|
|
except Exception:
|
|
logger.debug("[%s] gateway_platform_event normalize error", self.name, exc_info=True)
|
|
return
|
|
if event is None:
|
|
return
|
|
# The gateway-owned boundary runs the full profile-scoped auth chain before plugin dispatch.
|
|
try:
|
|
source = self._source_for_platform_event_auth(update)
|
|
await handler(event, source)
|
|
except Exception:
|
|
logger.debug("[%s] gateway_platform_event dispatch error", self.name, exc_info=True)
|
|
|
|
def _source_for_platform_event_auth(self, update):
|
|
"""Route a supported update to its event-specific auth-source extractor (reactor / editor);
|
|
raises ``ValueError`` for updates without one so the boundary fails closed."""
|
|
if getattr(update, "message_reaction", None) is not None:
|
|
return self._source_from_reaction_for_auth(update)
|
|
edited = getattr(update, "edited_message", None)
|
|
if edited is not None:
|
|
source = self._source_from_message_for_auth(edited)
|
|
# Tolerates missing identities for pairing-flow callers; this boundary must not.
|
|
if not source.user_id or not source.chat_id:
|
|
raise ValueError("gateway_platform_event message_edited requires editor and chat identities")
|
|
return source
|
|
raise ValueError("gateway_platform_event source extraction has no extractor for this update type")
|
|
|
|
def _normalize_platform_event(self, update) -> Optional[Dict[str, Any]]:
|
|
"""Map a PTB update to a ``{platform, event_type, payload}`` envelope (hooks.md contracts), or
|
|
``None`` for types without one."""
|
|
if getattr(update, "message_reaction", None) is not None:
|
|
return self._normalize_reaction_event(update)
|
|
if getattr(update, "edited_message", None) is not None:
|
|
return self._normalize_message_edited_event(update)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _is_id_like(value: Any) -> bool:
|
|
return not isinstance(value, bool) and isinstance(value, (str, int))
|
|
|
|
def _normalize_reaction_event(self, update) -> Optional[Dict[str, Any]]:
|
|
"""``message_reaction`` → ``reaction`` event: emojis (unicode), custom_emoji_ids, chat_id,
|
|
message_id, thread_id (always None — reactions carry none)."""
|
|
mr = getattr(update, "message_reaction", None)
|
|
if mr is None:
|
|
return None
|
|
chat = getattr(mr, "chat", None)
|
|
new_reaction = getattr(mr, "new_reaction", None) or []
|
|
if not isinstance(new_reaction, (list, tuple)):
|
|
return None
|
|
chat_id = getattr(chat, "id", None) if chat is not None else None
|
|
message_id = getattr(mr, "message_id", None)
|
|
if not self._is_id_like(chat_id) or not self._is_id_like(message_id):
|
|
return None
|
|
emojis: List[str] = []
|
|
custom_emoji_ids: List[str] = []
|
|
for r in new_reaction[:64]:
|
|
emoji = getattr(r, "emoji", None)
|
|
if isinstance(emoji, str) and emoji:
|
|
emojis.append(emoji[:64])
|
|
custom_id = getattr(r, "custom_emoji_id", None)
|
|
if self._is_id_like(custom_id):
|
|
custom_emoji_ids.append(str(custom_id)[:128])
|
|
return {
|
|
"platform": "telegram",
|
|
"event_type": "reaction",
|
|
"payload": {
|
|
"emojis": emojis, "custom_emoji_ids": custom_emoji_ids, "chat_id": str(chat_id)[:128],
|
|
"message_id": str(message_id)[:128], "thread_id": None},
|
|
}
|
|
|
|
def _normalize_message_edited_event(self, update) -> Optional[Dict[str, Any]]:
|
|
"""``edited_message`` → ``message_edited`` event (v1, additive): chat_id, message_id, thread_id
|
|
(forum topic), text (edited text or caption, bounded), edited_at (ISO 8601 UTC or None)."""
|
|
message = getattr(update, "edited_message", None)
|
|
if message is None:
|
|
return None
|
|
chat = getattr(message, "chat", None)
|
|
chat_id = getattr(chat, "id", None) if chat is not None else None
|
|
message_id = getattr(message, "message_id", None)
|
|
if not self._is_id_like(chat_id) or not self._is_id_like(message_id):
|
|
return None
|
|
text = getattr(message, "text", None) or getattr(message, "caption", None)
|
|
if not isinstance(text, str):
|
|
text = None
|
|
thread_id = None
|
|
thread_id_raw = getattr(message, "message_thread_id", None)
|
|
if self._is_id_like(thread_id_raw) and bool(getattr(message, "is_topic_message", False)):
|
|
thread_id = str(thread_id_raw)[:128]
|
|
edited_at = None
|
|
edit_date = getattr(message, "edit_date", None)
|
|
try:
|
|
if edit_date is not None and hasattr(edit_date, "isoformat"):
|
|
edited_at = str(edit_date.isoformat())[:64]
|
|
except Exception:
|
|
edited_at = None
|
|
return {
|
|
"platform": "telegram",
|
|
"event_type": "message_edited",
|
|
"payload": {
|
|
"chat_id": str(chat_id)[:128], "message_id": str(message_id)[:128], "thread_id": thread_id,
|
|
"text": text[:8192] if text is not None else None, "edited_at": edited_at},
|
|
}
|
|
|
|
def _register_handlers(self, app) -> None:
|
|
"""Register every PTB handler on ``app`` (initial connect and the transient-init rebuild)."""
|
|
app.add_handler(TelegramMessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_text_message))
|
|
app.add_handler(TelegramMessageHandler(filters.COMMAND, self._handle_command))
|
|
app.add_handler(TelegramMessageHandler(
|
|
filters.LOCATION | getattr(filters, "VENUE", filters.LOCATION), self._handle_location_message))
|
|
app.add_handler(TelegramMessageHandler(
|
|
filters.PHOTO | filters.VIDEO | filters.AUDIO | filters.VOICE | filters.Document.ALL | filters.Sticker.ALL,
|
|
self._handle_media_message))
|
|
app.add_handler(CallbackQueryHandler(self._handle_callback_query))
|
|
# Inline command picker; inert until the owner enables inline mode via BotFather /setinline.
|
|
app.add_handler(InlineQueryHandler(self._handle_inline_query))
|
|
# gateway_platform_event observer: group 99 observes alongside, never displaces, core handlers.
|
|
app.add_handler(TypeHandler(Update, self._on_platform_update), group=99)
|
|
|
|
async def _build_ptb_requests(self) -> tuple:
|
|
"""Build the (general, getUpdates) HTTPXRequest pair: fallback-IP transport, explicit proxy, or
|
|
direct DNS; the getUpdates request is instrumented for polling-progress tracking."""
|
|
# PTB's pool_timeout=1s default trips "Pool timeout" on flaky networks; safer defaults + env overrides.
|
|
request_kwargs = {
|
|
"connection_pool_size": env_int("HERMES_TELEGRAM_HTTP_POOL_SIZE", 512),
|
|
"pool_timeout": env_float("HERMES_TELEGRAM_HTTP_POOL_TIMEOUT", 8.0),
|
|
"connect_timeout": env_float("HERMES_TELEGRAM_HTTP_CONNECT_TIMEOUT", 10.0),
|
|
"read_timeout": env_float("HERMES_TELEGRAM_HTTP_READ_TIMEOUT", 20.0),
|
|
"write_timeout": env_float("HERMES_TELEGRAM_HTTP_WRITE_TIMEOUT", 20.0),
|
|
# PTB routes file requests to media_write_timeout; httpx budgets it per socket write (stall
|
|
# tolerance, not bandwidth), so 60s rides out congested-link buffer stalls.
|
|
"media_write_timeout": 60.0,
|
|
}
|
|
# CLOSE_WAIT fd leak: PTB's httpx.AsyncClient has no keepalive tuning; inject platform_httpx_limits()
|
|
# while preserving PTB's max_connections (httpx_kwargs is spread last, so `limits` here wins).
|
|
# CLOSE_WAIT fd leak (#31599, same class as #18451): PTB's HTTPXRequest builds the underlying
|
|
# httpx.AsyncClient with `limits = httpx.Limits(max_connections=connection_pool_size)` and *no*
|
|
# keepalive tuning, so httpx's default keepalive_expiry=5.0 applies. Behind an HTTP proxy
|
|
# (Cloudflare Warp etc.) a peer-initiated FIN can sit in CLOSE_WAIT longer than that, leaking fds in
|
|
# the general request pool (_request[1]) which _drain_polling_connections never resets.
|
|
from gateway.platforms._http_client_limits import platform_httpx_limits
|
|
_base_limits = platform_httpx_limits()
|
|
if _base_limits is not None:
|
|
import httpx as _httpx
|
|
_pool_limits = _httpx.Limits(
|
|
max_connections=request_kwargs["connection_pool_size"],
|
|
max_keepalive_connections=_base_limits.max_keepalive_connections, keepalive_expiry=_base_limits.keepalive_expiry)
|
|
# A long-poll is continuously active, so keepalive expiry can't protect it from a server-side
|
|
# close: never hand getUpdates a pooled socket from a previous poll.
|
|
_updates_limits = _httpx.Limits(
|
|
max_connections=request_kwargs["connection_pool_size"], max_keepalive_connections=0,
|
|
keepalive_expiry=_base_limits.keepalive_expiry)
|
|
else: # pragma: no cover — httpx always present alongside PTB
|
|
_pool_limits = _updates_limits = None
|
|
|
|
def _with_limits(httpx_kwargs: Optional[dict] = None) -> dict:
|
|
"""Merge tuned limits into httpx client kwargs (proxy/direct branches only; the fallback-IP
|
|
branch must pass limits straight into the transport — httpx ignores client `limits` then)."""
|
|
kwargs = dict(httpx_kwargs or {})
|
|
if _pool_limits is not None and "limits" not in kwargs:
|
|
kwargs["limits"] = _pool_limits
|
|
return kwargs
|
|
|
|
disable_fallback = os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
fallback_ips = [] if disable_fallback else self._fallback_ips()
|
|
if not fallback_ips and not disable_fallback:
|
|
discovery_timeout = self._env_float_clamped("HERMES_TELEGRAM_FALLBACK_DISCOVERY_TIMEOUT", 5.0, min_value=0.0)
|
|
logger.warning("[%s] Discovering Telegram API fallback IPs via DNS-over-HTTPS…", self.name)
|
|
try:
|
|
fallback_ips = await _await_with_thread_deadline(discover_fallback_ips(), timeout=discovery_timeout)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"[%s] Telegram fallback-IP discovery failed after %.0fs; "
|
|
"using seed IPv4 Telegram API IPs so a blackholed IPv6 hostname path cannot hang initialize() (#87015): %s",
|
|
self.name, discovery_timeout, _redact_telegram_error_text(exc))
|
|
fallback_ips = list(SEED_FALLBACK_IPS)
|
|
else:
|
|
logger.info("[%s] Auto-discovered Telegram fallback IPs: %s", self.name, ", ".join(fallback_ips))
|
|
proxy_url = resolve_proxy_url("TELEGRAM_PROXY", target_hosts=["api.telegram.org", *fallback_ips])
|
|
|
|
def _pair(general_httpx: dict, updates_httpx: dict, **extra) -> tuple:
|
|
return (HTTPXRequest(**request_kwargs, **extra, httpx_kwargs=general_httpx),
|
|
HTTPXRequest(**request_kwargs, **extra, httpx_kwargs=updates_httpx))
|
|
|
|
if fallback_ips and not proxy_url and not disable_fallback:
|
|
logger.info("[%s] Telegram fallback IPs active: %s", self.name, ", ".join(fallback_ips))
|
|
# Separate request/update pools reduce contention during polling reconnect + bootstrap calls.
|
|
_transport_kwargs: dict = {"socket_options": tcp_keepalive_socket_options()}
|
|
# Keep request/update pools separate to reduce contention during polling reconnect + bot API
|
|
# bootstrap/delete_webhook calls. httpx ignores the client-level `limits` kwarg when a custom
|
|
# `transport` is supplied (#58790). Unlike the proxy/direct branches (which inject limits at the
|
|
# client level via `_with_limits`), this branch MUST pass the tuned limits directly into
|
|
# TelegramFallbackTransport so its inner AsyncHTTPTransport instances honour keepalive_expiry —
|
|
# do not route this through `_with_limits`, httpx would discard it.
|
|
if _pool_limits is not None:
|
|
_transport_kwargs["limits"] = _pool_limits
|
|
_updates_transport_kwargs = dict(_transport_kwargs)
|
|
if _updates_limits is not None:
|
|
_updates_transport_kwargs["limits"] = _updates_limits
|
|
request, get_updates_request = _pair(
|
|
{"transport": TelegramFallbackTransport(fallback_ips, **_transport_kwargs)},
|
|
{"transport": TelegramFallbackTransport(fallback_ips, **_updates_transport_kwargs)})
|
|
elif proxy_url:
|
|
logger.info("[%s] Proxy detected; passing explicitly to HTTPXRequest: %s", self.name, proxy_url)
|
|
request, get_updates_request = _pair(_with_limits(), {"limits": _updates_limits}, proxy=proxy_url)
|
|
else:
|
|
if disable_fallback:
|
|
logger.info("[%s] Telegram fallback-IP transport disabled via env", self.name)
|
|
request, get_updates_request = _pair(_with_limits(), {"limits": _updates_limits})
|
|
return request, self._instrument_polling_request(get_updates_request)
|
|
|
|
async def _initialize_app_with_retries(self, builder) -> None:
|
|
"""``app.initialize()`` with a bounded retry ladder; rebuilds ``self._app``/``self._bot`` from
|
|
``builder`` after each failed attempt; OSError when the per-attempt or total watchdog expires."""
|
|
_max_connect = 8
|
|
_init_timeout = env_float("HERMES_TELEGRAM_INIT_TIMEOUT", 30.0) # per attempt
|
|
# Total watchdog: bounds the whole connect loop even if the retry loop silently stalls.
|
|
_total_deadline = asyncio.get_running_loop().time() + _init_timeout * _max_connect + 120.0
|
|
_timed_out = f"Telegram initialization timed out after {_max_connect} attempts ({_init_timeout:.0f}s each)"
|
|
for _attempt in range(_max_connect):
|
|
rebuild_app = False
|
|
try:
|
|
if asyncio.get_running_loop().time() >= _total_deadline:
|
|
raise OSError(
|
|
f"{_timed_out} — total connect watchdog deadline ({_init_timeout * _max_connect + 120.0:.0f}s) exceeded. "
|
|
f"Check network connectivity to api.telegram.org or set HERMES_TELEGRAM_HTTP_CONNECT_TIMEOUT / "
|
|
f"HERMES_TELEGRAM_INIT_TIMEOUT to a lower value.")
|
|
logger.warning("[%s] Connecting to Telegram (attempt %d/%d)…", self.name, _attempt + 1, _max_connect)
|
|
# On timeout the (possibly shielded) initialize() task is abandoned; release the half-built
|
|
# app's httpx client so it isn't leaked across the ladder.
|
|
await _await_with_thread_deadline(
|
|
self._app.initialize(), timeout=_init_timeout, on_abandon=lambda app=self._app: _shutdown_abandoned_app(app))
|
|
break
|
|
except asyncio.TimeoutError:
|
|
rebuild_app = True
|
|
if _attempt >= _max_connect - 1:
|
|
raise OSError(
|
|
f"{_timed_out}. Check network connectivity to api.telegram.org "
|
|
f"or set HERMES_TELEGRAM_HTTP_CONNECT_TIMEOUT to a lower value.")
|
|
wait = min(2 ** _attempt, 15)
|
|
logger.warning(
|
|
"[%s] Connect attempt %d/%d timed out after %.0fs — retrying in %ds", self.name, _attempt + 1,
|
|
_max_connect, _init_timeout, wait)
|
|
await asyncio.sleep(wait)
|
|
except Exception as init_err:
|
|
# OSError always retries; anything else only when it looks like a network error.
|
|
rebuild_app = True
|
|
if (not isinstance(init_err, OSError) and not self._looks_like_network_error(init_err)) or _attempt >= _max_connect - 1:
|
|
raise
|
|
wait = min(2 ** _attempt, 15)
|
|
logger.warning(
|
|
"[%s] Connect attempt %d/%d failed: %s — retrying in %ds", self.name, _attempt + 1, _max_connect, init_err, wait)
|
|
await asyncio.sleep(wait)
|
|
except BaseException:
|
|
# CancelledError etc.: log for the operator, then reraise. LAST so the Exception handlers win.
|
|
logger.warning(
|
|
"[%s] Connect attempt %d/%d interrupted by %s — propagating", self.name, _attempt + 1, _max_connect,
|
|
"CancelledError" if isinstance(sys.exc_info()[1], asyncio.CancelledError) else type(sys.exc_info()[1]).__name__)
|
|
raise
|
|
finally:
|
|
# A failed attempt may leave the app half-initialized: rebuild a fresh Application from the
|
|
# same builder for the next attempt and discard the old one.
|
|
if rebuild_app and _attempt < _max_connect - 1:
|
|
old_app = self._app
|
|
self._app = builder.build()
|
|
self._bot = self._app.bot
|
|
self._register_handlers(self._app) # keep core and observer handlers in lockstep
|
|
with contextlib.suppress(Exception):
|
|
await _shutdown_abandoned_app(old_app)
|
|
|
|
async def _start_webhook_mode(self, webhook_url: str, *, is_reconnect: bool) -> None:
|
|
"""Start PTB's webhook server (Telegram pushes updates; lets cloud platforms auto-wake suspended
|
|
machines). SECURITY: TELEGRAM_WEBHOOK_SECRET is REQUIRED — without it the endpoint accepts forged
|
|
updates (GHSA-3vpc-7q5r-276h); refuse to start rather than run fail-open."""
|
|
webhook_port = env_int("TELEGRAM_WEBHOOK_PORT", 8443)
|
|
# Default "" → tornado listens on IPv4 + IPv6; "0.0.0.0" is unreachable on IPv6-only networks.
|
|
webhook_host = (os.getenv("TELEGRAM_WEBHOOK_HOST", "").strip() or str((self.config.extra or {}).get("webhook_host") or "").strip())
|
|
webhook_secret = (_get_scoped_secret("TELEGRAM_WEBHOOK_SECRET") or "").strip()
|
|
if not webhook_secret:
|
|
raise RuntimeError(
|
|
"TELEGRAM_WEBHOOK_SECRET is required when TELEGRAM_WEBHOOK_URL is set. Without it, the "
|
|
"webhook endpoint accepts forged updates from anyone who can reach it — see "
|
|
"https://github.com/NousResearch/hermes-agent/security/advisories/GHSA-3vpc-7q5r-276h.\n\n"
|
|
"Generate a secret and set it in your .env:\n export TELEGRAM_WEBHOOK_SECRET=\"$(openssl rand -hex 32)\"\n\n"
|
|
"Then register it with Telegram when setting the webhook via setWebhook's secret_token parameter.")
|
|
from urllib.parse import urlparse
|
|
webhook_path = urlparse(webhook_url).path or "/telegram"
|
|
await self._app.updater.start_webhook(
|
|
listen=webhook_host, port=webhook_port, url_path=webhook_path, webhook_url=webhook_url,
|
|
secret_token=webhook_secret, allowed_updates=Update.ALL_TYPES,
|
|
drop_pending_updates=not is_reconnect, # push-based ⇒ practically a no-op; mirrors polling
|
|
)
|
|
self._webhook_mode = True
|
|
self._polling_progress_accepting = False
|
|
self._send_path_degraded = False
|
|
logger.info(
|
|
"[%s] Webhook server listening on %s:%d%s", self.name, webhook_host or "* (all interfaces, IPv4+IPv6)",
|
|
webhook_port, webhook_path)
|
|
|
|
async def _start_polling_mode(self, *, is_reconnect: bool) -> None:
|
|
"""Clear any stale webhook and start resilient long polling."""
|
|
# Best-effort: a transient Bot API error must not fail gateway startup — degrade to recovery.
|
|
await self._delete_webhook_best_effort(require_success=not is_reconnect)
|
|
loop = asyncio.get_running_loop()
|
|
|
|
def _polling_error_callback(error: Exception) -> None:
|
|
if self._teardown_started or self._recovery_in_flight():
|
|
return
|
|
if self._looks_like_polling_conflict(error):
|
|
# Stop PTB's network_retry_loop synchronously BEFORE scheduling async recovery, else PTB's
|
|
# retry and our stop->restart overlap and produce a fresh 409.
|
|
self._disarm_ptb_retry_loop()
|
|
self._spawn_polling_recovery(loop, self._handle_polling_conflict(error))
|
|
elif self._looks_like_network_error(error):
|
|
logger.warning("[%s] Telegram network _redact_telegram_error_text(error), scheduling reconnect: %s", self.name, error)
|
|
self._spawn_polling_recovery(loop, self._handle_polling_network_error(error))
|
|
else:
|
|
logger.error("[%s] Telegram polling _redact_telegram_error_text(error): %s", self.name, error, exc_info=True)
|
|
|
|
self._polling_error_callback_ref = _polling_error_callback # reused by _handle_polling_conflict
|
|
polling_started = await self._start_polling_resilient(
|
|
# Cold first boot drops the stale Bot API queue; a watcher reconnect preserves it.
|
|
drop_pending_updates=not is_reconnect, error_callback=_polling_error_callback, require_progress=not is_reconnect)
|
|
if not polling_started:
|
|
logger.warning(
|
|
"[%s] Connected in degraded Telegram mode: gateway is alive, polling will be retried in the background", self.name)
|
|
|
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
|
"""Connect via long polling, or a webhook server if ``TELEGRAM_WEBHOOK_URL`` is set.
|
|
|
|
``is_reconnect``: False = cold boot (drop the stale Bot API queue); True = watcher reconnect (preserve queued
|
|
updates, else every message sent during the outage is lost). Webhook env: TELEGRAM_WEBHOOK_URL,
|
|
TELEGRAM_WEBHOOK_PORT (8443), TELEGRAM_WEBHOOK_HOST, TELEGRAM_WEBHOOK_SECRET."""
|
|
# Explicit connect() is the only operation allowed to reopen polling after a completed teardown.
|
|
self._polling_teardown_started = False
|
|
self._webhook_mode = False # re-evaluated on every explicit connection
|
|
if not TELEGRAM_AVAILABLE:
|
|
logger.error("[%s] python-telegram-bot not installed. Run: pip install python-telegram-bot", self.name)
|
|
self._set_fatal_error("missing_dependency", "python-telegram-bot not installed", retryable=False)
|
|
return False
|
|
if not self.config.token:
|
|
logger.error("[%s] No bot token configured", self.name)
|
|
self._set_fatal_error("missing_credentials", "No bot token configured", retryable=False)
|
|
return False
|
|
try:
|
|
if not self._acquire_platform_lock('telegram-bot-token', self.config.token, 'Telegram bot token'):
|
|
return False
|
|
builder = Application.builder().token(self.config.token)
|
|
custom_base_url = self.config.extra.get("base_url")
|
|
if custom_base_url:
|
|
builder = builder.base_url(custom_base_url)
|
|
builder = builder.base_file_url(self.config.extra.get("base_file_url", custom_base_url))
|
|
logger.info("[%s] Using custom Telegram base_url: %s", self.name, custom_base_url)
|
|
# Local-mode telegram-bot-api returns absolute server-side file paths; PTB needs local_mode=True
|
|
# so download_*() reads from disk instead of a 404ing HTTP GET.
|
|
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)
|
|
request, get_updates_request = await self._build_ptb_requests()
|
|
builder = builder.request(request).get_updates_request(get_updates_request)
|
|
self._app = builder.build()
|
|
self._bot = self._app.bot
|
|
# Plugin PTB handlers go BEFORE core: PTB dispatches the first matching handler per group.
|
|
self._wire_plugin_handlers(self._app)
|
|
self._register_handlers(self._app)
|
|
await self._initialize_app_with_retries(builder)
|
|
await self._app.start()
|
|
# Profile-scoped like TELEGRAM_WEBHOOK_SECRET: under multiplex os.environ holds the DEFAULT
|
|
# profile's URL, and registering it on a secondary bot pushes that bot's updates to the
|
|
# default's listener (and stops polling for it).
|
|
webhook_url = (_get_scoped_secret("TELEGRAM_WEBHOOK_URL") or "").strip()
|
|
if webhook_url:
|
|
await self._start_webhook_mode(webhook_url, is_reconnect=is_reconnect)
|
|
else:
|
|
await self._start_polling_mode(is_reconnect=is_reconnect)
|
|
self._mark_connected()
|
|
# WARNING, not INFO: "Connecting…" above is WARNING and reaches the terminal; an INFO success
|
|
# line made healthy startups look stalled at "attempt 1/8".
|
|
logger.warning("[%s] Connected to Telegram (%s mode)", self.name, "webhook" if self._webhook_mode else "polling")
|
|
# Heartbeat only in polling mode: webhook mode has no long-poll socket to wedge in CLOSE-WAIT.
|
|
# WARNING, not INFO: the "Connecting to Telegram (attempt N/8)…" line above is emitted at
|
|
# WARNING and reaches the terminal (the gateway's default stderr handler is WARNING-only), but
|
|
# this success line was INFO and went to the log file only. A healthy startup therefore looked
|
|
# permanently stalled at "attempt 1/8" on the console — the logging illusion in #90835. Both
|
|
# sides of the connect transition must share a terminal-visible level so a real hang is the
|
|
# *absence* of this line, not ambiguity.
|
|
if not self._webhook_mode:
|
|
self._restart_task_attr("_polling_heartbeat_task", self._polling_heartbeat_loop())
|
|
# Seed the live identity from PTB's initialize() cache; polling rides the heartbeat's get_me(),
|
|
# webhook mode gets a low-frequency refresh loop (else a BotFather rename breaks routing).
|
|
self._note_bot_username(getattr(self._bot, "username", None))
|
|
self._bot_identity_checked_at = time.monotonic()
|
|
if self._webhook_mode:
|
|
self._restart_task_attr("_bot_identity_refresh_task", self._bot_identity_refresh_loop())
|
|
# Command menu / DM topics / status indicator can stall for some tokens: defer to a cancellable
|
|
# task so one slow call can't sink the (gateway-timed) connect while transport is live.
|
|
# Command-menu registration, DM-topic setup, and the status indicator each make Bot API calls
|
|
# that can stall for certain tokens. Running them here — inside the connect() coroutine that the
|
|
# gateway wraps in a connect timeout — means one slow call blows the whole connect and the
|
|
# adapter never comes up, even though polling/webhook is already live (#46298).
|
|
self._start_post_connect_housekeeping()
|
|
return True
|
|
except Exception as e:
|
|
self._release_platform_lock()
|
|
safe_error = _redact_telegram_error_text(e)
|
|
# Classify by exception TYPE (never message text): auth failures can never self-heal, so
|
|
# marking them retryable put agents into a silent eternal reconnect loop.
|
|
if self._looks_like_auth_error(e):
|
|
message = (
|
|
f"Telegram bot token rejected: {safe_error}. "
|
|
"The token is invalid or was revoked — generate a new one "
|
|
"with @BotFather and update TELEGRAM_BOT_TOKEN.")
|
|
self._set_fatal_error("telegram_auth_error", message, retryable=False)
|
|
else:
|
|
self._set_fatal_error("telegram_connect_error", f"Telegram startup failed: {safe_error}", retryable=True)
|
|
logger.error("[%s] Failed to connect to Telegram: %s", self.name, safe_error)
|
|
return False
|
|
|
|
async def _set_status_indicator(self, online: bool) -> None:
|
|
"""Set the bot's short description to the online/offline text (closest Bot API surface to
|
|
presence). No-op unless ``extra.status_indicator``; failures are debug-logged."""
|
|
if not getattr(self, "_status_indicator_enabled", False):
|
|
return
|
|
bot = self._bot
|
|
if bot is None:
|
|
return
|
|
text = (self._status_online_text if online else self._status_offline_text)[:120] # Telegram cap
|
|
try:
|
|
await bot.set_my_short_description(short_description=text)
|
|
logger.info("[%s] Set bot status indicator to %r", self.name, text)
|
|
except Exception as e:
|
|
logger.debug("[%s] Failed to set bot status indicator to %r: %s", self.name, text, _redact_telegram_error_text(e))
|
|
|
|
@staticmethod
|
|
def _collect_live_tasks(candidates, current_task) -> list:
|
|
"""Unique, unfinished tasks from ``candidates`` excluding ``current_task`` (so teardown never cancels itself)."""
|
|
seen: set[int] = set()
|
|
out: list[asyncio.Task] = []
|
|
for task in candidates:
|
|
if not task or task.done() or task is current_task or id(task) in seen:
|
|
continue
|
|
seen.add(id(task))
|
|
out.append(task)
|
|
return out
|
|
|
|
def _clear_task_attrs_except(self, current_task, *attrs: str) -> None:
|
|
for attr in attrs:
|
|
if getattr(self, attr, None) is not current_task:
|
|
setattr(self, attr, None)
|
|
|
|
async def _cancel_pending_delivery_tasks(self) -> None:
|
|
"""Cancel every delayed-delivery task family before disconnect completes (media-group, photo-batch, text-batch flushes plus
|
|
polling recovery all sit behind ``asyncio.sleep()`` and would dispatch ``handle_message`` into a torn-down session)."""
|
|
current_task = asyncio.current_task()
|
|
pending_tasks = self._collect_live_tasks(
|
|
[
|
|
*self._media_group_tasks.values(), *self._pending_photo_batch_tasks.values(), *self._pending_text_batch_tasks.values(),
|
|
getattr(self, "_polling_error_task", None), getattr(self, "_polling_progress_verifier_task", None),
|
|
# Hold-queue redispatch must be cancellable+awaitable on teardown too.
|
|
getattr(self, "_held_inbound_redispatch_task", None),
|
|
],
|
|
current_task)
|
|
awaitable_tasks = [t for t in pending_tasks if asyncio.isfuture(t) or asyncio.iscoroutine(t)]
|
|
# Hold-queue redispatch must be cancellable+awaitable on teardown so it cannot dispatch
|
|
# handle_message into a torn-down session (same lifecycle rule teknium called out on #72037 for
|
|
# shielded flush dispatch).
|
|
for task in pending_tasks:
|
|
task.cancel()
|
|
if awaitable_tasks:
|
|
await asyncio.gather(*awaitable_tasks, return_exceptions=True)
|
|
# Salvage buffered inbound events before clearing maps — unless permanent fatal, where no
|
|
# reconnect can drain and hold would re-orphan them.
|
|
if self._is_permanent_fatal():
|
|
n_pending = len(self._pending_text_batches) + len(self._pending_photo_batches) + len(self._media_group_events)
|
|
if n_pending:
|
|
logger.warning("[Telegram] Non-retryable fatal teardown; discarding %d pending inbound batch(es)", n_pending)
|
|
else:
|
|
for events, where in (
|
|
(self._pending_text_batches, "text-batch-teardown"), (self._pending_photo_batches, "photo-batch-teardown"),
|
|
(self._media_group_events, "media-group-teardown")):
|
|
for event in list(events.values()):
|
|
self._hold_inbound_event(event, where=where)
|
|
for d in (
|
|
self._media_group_tasks, self._media_group_events, self._pending_photo_batch_tasks,
|
|
self._pending_photo_batches, self._pending_text_batch_tasks, self._pending_text_batches):
|
|
d.clear()
|
|
self._clear_task_attrs_except(
|
|
current_task, "_polling_error_task", "_polling_progress_verifier_task", "_held_inbound_redispatch_task")
|
|
|
|
async def _await_disconnect_step(self, awaitable, timeout: float, step: str) -> bool:
|
|
"""Await one disconnect step; detach on timeout so teardown advances (``wait_for`` would wait for a
|
|
PTB close that swallows ``CancelledError`` on a half-dead socket). Abandoned tasks are observed.
|
|
|
|
``asyncio.wait_for`` cancels an overdue child but then waits for it to exit. Detach at the deadline
|
|
and continue — the abandoned task is observed via ``_consume_abandoned_task``. See #80598.
|
|
"""
|
|
task = asyncio.ensure_future(awaitable)
|
|
try:
|
|
done, _pending = await asyncio.wait({task}, timeout=timeout if timeout > 0 else None)
|
|
except asyncio.CancelledError:
|
|
# asyncio.wait does NOT cancel its futures when itself cancelled; don't orphan the inner task.
|
|
task.cancel()
|
|
# Mirror the pattern used by GatewayRunner._await_adapter_cleanup_with_timeout. See #80598.
|
|
task.add_done_callback(_consume_abandoned_task)
|
|
raise
|
|
if task in done:
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
return True
|
|
task.cancel()
|
|
task.add_done_callback(_consume_abandoned_task)
|
|
logger.warning("[%s] %s timed out after %.1fs during disconnect; continuing teardown", self.name, step, timeout)
|
|
return False
|
|
|
|
def _restart_task_attr(self, attr: str, coro) -> None:
|
|
"""Cancel any live task stored at ``self.<attr>`` and start ``coro`` in its place."""
|
|
prior = getattr(self, attr, None)
|
|
if prior and not prior.done():
|
|
prior.cancel()
|
|
setattr(self, attr, asyncio.ensure_future(coro))
|
|
|
|
async def _cancel_task_attr(self, attr: str, label: str) -> None:
|
|
"""Cancel + bounded-await the task stored at ``self.<attr>`` (may be missing: object.__new__ tests), then clear it."""
|
|
task = getattr(self, attr, None)
|
|
if task and not task.done():
|
|
task.cancel()
|
|
await self._await_disconnect_step(task, _DISCONNECT_STEP_TIMEOUT, label)
|
|
setattr(self, attr, None)
|
|
|
|
async def disconnect(self) -> None:
|
|
"""Stop polling/webhook, cancel pending delayed deliveries, and disconnect."""
|
|
# Mark disconnected first so the drop guard short-circuits any flush that wins the race.
|
|
self._mark_disconnected()
|
|
self._polling_teardown_started = True
|
|
self._polling_progress_accepting = False
|
|
self._polling_generation = getattr(self, "_polling_generation", 0) + 1
|
|
self._polling_progress_event = asyncio.Event()
|
|
self._send_path_degraded = True
|
|
# Release the bot-token lock immediately so a wedged close cannot block the reconnect watcher.
|
|
# The rest of teardown is best-effort against a half-dead transport. See #80598.
|
|
self._release_platform_lock()
|
|
# Cancel and await both polling lifecycle owners right after the fence, before any other teardown
|
|
# await lets them start a new generation.
|
|
current_task = asyncio.current_task()
|
|
lifecycle_tasks = self._collect_live_tasks(
|
|
[getattr(self, "_polling_error_task", None), getattr(self, "_polling_progress_verifier_task", None)], current_task)
|
|
for task in lifecycle_tasks:
|
|
task.cancel()
|
|
lifecycle_tasks = [t for t in lifecycle_tasks if asyncio.isfuture(t) or asyncio.iscoroutine(t)]
|
|
if lifecycle_tasks:
|
|
await self._await_disconnect_step(
|
|
asyncio.gather(*lifecycle_tasks, return_exceptions=True), _DISCONNECT_STEP_TIMEOUT, "lifecycle-task cancel")
|
|
self._clear_task_attrs_except(current_task, "_polling_error_task", "_polling_progress_verifier_task")
|
|
# Cancellation callbacks may have run while awaited; the fence stays authoritative.
|
|
self._polling_progress_accepting = False
|
|
self._send_path_degraded = True
|
|
# Cancel deferred post-connect housekeeping so it cannot fire into a half-torn-down bot client.
|
|
# Cancel deferred post-connect housekeeping (command-menu / DM-topic / status-indicator Bot API
|
|
# calls) so it cannot fire into a half-torn-down bot client (#46298). getattr guards the
|
|
# object.__new__ test pattern where __init__ (which sets this attr) is never called.
|
|
post_connect_task = getattr(self, "_post_connect_task", None)
|
|
if post_connect_task and not post_connect_task.done():
|
|
post_connect_task.cancel()
|
|
await self._await_disconnect_step(
|
|
asyncio.gather(post_connect_task, return_exceptions=True), _DISCONNECT_STEP_TIMEOUT, "post-connect cancel")
|
|
self._post_connect_task = None
|
|
# Cancel the heartbeat (and webhook-mode identity loop) before tearing down the app.
|
|
await self._cancel_task_attr("_polling_heartbeat_task", "heartbeat cancel")
|
|
await self._cancel_task_attr("_bot_identity_refresh_task", "identity-refresh cancel")
|
|
# Mark the bot "Offline" while its HTTP client is still alive. Opt-in, non-fatal.
|
|
with contextlib.suppress(Exception):
|
|
await self._await_disconnect_step(self._set_status_indicator(online=False), _DISCONNECT_STEP_TIMEOUT, "status-indicator update")
|
|
await self._await_disconnect_step(self._cancel_pending_delivery_tasks(), _DISCONNECT_STEP_TIMEOUT, "pending-delivery cancel")
|
|
if self._app:
|
|
try:
|
|
# Bounded: a CLOSE-WAIT socket can wedge updater.stop() forever; fall through on timeout.
|
|
if self._app.updater and self._app.updater.running:
|
|
try:
|
|
await self._await_disconnect_step(self._app.updater.stop(), _UPDATER_STOP_TIMEOUT, "updater.stop()")
|
|
except Exception as stop_error:
|
|
logger.warning(
|
|
"[%s] updater.stop() failed during disconnect: %s", self.name, _redact_telegram_error_text(stop_error))
|
|
# app.stop()/shutdown() can also block on a half-dead httpx pool.
|
|
# Detach-on-timeout so disconnect always returns (#80598).
|
|
if self._app.running:
|
|
await self._await_disconnect_step(self._app.stop(), _DISCONNECT_STEP_TIMEOUT, "app.stop()")
|
|
await self._await_disconnect_step(self._app.shutdown(), _DISCONNECT_STEP_TIMEOUT, "app.shutdown()")
|
|
except Exception as e:
|
|
logger.warning("[%s] Error during Telegram disconnect: %s", self.name, _redact_telegram_error_text(e))
|
|
self._app = None
|
|
self._bot = None
|
|
logger.info("[%s] Disconnected from Telegram", self.name)
|
|
|
|
def _should_thread_reply(self, reply_to: Optional[str], chunk_index: int) -> bool:
|
|
"""Whether this chunk (0 = first) should reply-thread to ``reply_to``, per reply_to_mode."""
|
|
if not reply_to:
|
|
return False
|
|
mode = self._reply_to_mode
|
|
if mode == "off":
|
|
return False
|
|
if mode == "all":
|
|
return True
|
|
return chunk_index == 0 # "first" (default)
|
|
|
|
@staticmethod
|
|
def _telegram_error_types() -> tuple:
|
|
"""``(NetworkError, BadRequest, TimedOut)`` from PTB, with import-failure fallbacks
|
|
(``OSError``, ``None``, ``None``) so send() still classifies without the SDK."""
|
|
try:
|
|
from telegram.error import NetworkError as _NetErr
|
|
except ImportError:
|
|
_NetErr = OSError # type: ignore[misc,assignment]
|
|
try:
|
|
from telegram.error import BadRequest as _BadReq
|
|
except ImportError:
|
|
_BadReq = None # type: ignore[assignment,misc]
|
|
try:
|
|
from telegram.error import TimedOut as _TimedOut
|
|
except (ImportError, AttributeError):
|
|
_TimedOut = None # type: ignore[assignment,misc]
|
|
return _NetErr, _BadReq, _TimedOut
|
|
|
|
async def _send_chunk_markdown_or_plain(self, chunk: str, send_kwargs: Dict[str, Any]):
|
|
"""MarkdownV2 first; on a parse/markdown rejection resend as stripped plain text."""
|
|
try:
|
|
return await self._bot.send_message(text=chunk, parse_mode=ParseMode.MARKDOWN_V2, **send_kwargs)
|
|
except Exception as md_error:
|
|
if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower():
|
|
logger.warning("[%s] MarkdownV2 parse failed, falling back to plain text: %s", self.name, md_error)
|
|
return await self._bot.send_message(text=_strip_mdv2(chunk), parse_mode=None, **send_kwargs)
|
|
raise
|
|
|
|
async def _send_chunk_with_retries(
|
|
self, chat_id: str, chunk: str, index: int, reply_to: Optional[str], metadata: Optional[Dict[str, Any]],
|
|
thread_id: Optional[str], used_thread_fallback: bool, error_types: tuple):
|
|
"""Deliver one chunk: routing, up to 3 attempts, thread-not-found / deleted-anchor / flood handling.
|
|
|
|
Returns ``(msg, used_thread_fallback)`` on success or a ``SendResult`` to return verbatim (fail-loud DM-topic
|
|
cases, flood cap); raises anything the caller's classifier should see."""
|
|
_NetErr, _BadReq, _TimedOut = error_types
|
|
retried_thread_not_found = False
|
|
private_dm_topic_send, dm_topic_reply_to_off, reply_to_id = self._chunk_reply_routing(chat_id, reply_to, metadata, thread_id, index)
|
|
if private_dm_topic_send and reply_to_id is None and not dm_topic_reply_to_off:
|
|
return SendResult(success=False, error=self._dm_topic_missing_anchor_error(), retryable=False)
|
|
thread_kwargs = self._thread_kwargs_for_send(
|
|
chat_id, thread_id, metadata, reply_to_message_id=reply_to_id, reply_to_mode=self._reply_to_mode)
|
|
if used_thread_fallback and thread_kwargs.get("message_thread_id") is not None:
|
|
thread_kwargs = dict(thread_kwargs)
|
|
thread_kwargs["message_thread_id"] = None
|
|
effective_thread_id = thread_kwargs.get("message_thread_id")
|
|
for _send_attempt in range(3):
|
|
try:
|
|
send_kwargs = {
|
|
"chat_id": normalize_telegram_chat_id(chat_id), "reply_to_message_id": reply_to_id, **thread_kwargs,
|
|
**self._link_preview_kwargs(), **self._notification_kwargs(metadata)}
|
|
return await self._send_chunk_markdown_or_plain(chunk, send_kwargs), used_thread_fallback
|
|
except _NetErr as send_err:
|
|
# BadRequest subclasses NetworkError in PTB but is permanent; handle specific cases.
|
|
if _BadReq and isinstance(send_err, _BadReq):
|
|
if self._is_thread_not_found_error(send_err) and effective_thread_id is not None:
|
|
if private_dm_topic_send or (metadata and metadata.get("telegram_dm_topic_created_for_send")):
|
|
return SendResult(success=False, error=str(send_err), retryable=False)
|
|
# One-off "thread not found" flakes recover on immediate retry: same thread_id once.
|
|
if not retried_thread_not_found:
|
|
retried_thread_not_found = True
|
|
logger.warning("[%s] Thread %s not found, retrying once with same thread_id", self.name, effective_thread_id)
|
|
continue
|
|
# Thread is genuinely gone: retry without it and prune the stale binding.
|
|
logger.warning("[%s] Thread %s not found, retrying without message_thread_id", self.name, effective_thread_id)
|
|
self._prune_stale_dm_topic_binding(chat_id, effective_thread_id, metadata=metadata)
|
|
used_thread_fallback = True
|
|
effective_thread_id = None
|
|
thread_kwargs = {"message_thread_id": None}
|
|
continue
|
|
if "message to be replied not found" in str(send_err).lower() and reply_to_id is not None:
|
|
safe_send_error = _redact_telegram_error_text(send_err)
|
|
if private_dm_topic_send:
|
|
return SendResult(success=False, error=safe_send_error, retryable=False)
|
|
# Reply target deleted; private-topic fallback sends drop anchor + topic id together.
|
|
logger.warning("[%s] Reply target deleted, retrying without reply_to: %s", self.name, safe_send_error)
|
|
reply_to_id = None
|
|
if self._dm_topic_fallback(metadata):
|
|
thread_kwargs = {}
|
|
else:
|
|
thread_kwargs = self._thread_kwargs_for_send(
|
|
chat_id, thread_id, metadata, reply_to_message_id=reply_to_id, reply_to_mode=self._reply_to_mode)
|
|
effective_thread_id = thread_kwargs.get("message_thread_id")
|
|
continue
|
|
raise # other BadRequest errors are permanent
|
|
# TimedOut also subclasses NetworkError: a generic timeout may have reached Telegram (don't
|
|
# retry); a wrapped ConnectTimeout or an httpx pool timeout is safe to retry.
|
|
is_pool_timeout = self._looks_like_pool_timeout(send_err)
|
|
if (
|
|
_TimedOut and isinstance(send_err, _TimedOut)
|
|
and not self._looks_like_connect_timeout(send_err) and not is_pool_timeout):
|
|
raise
|
|
if is_pool_timeout:
|
|
await self._drain_general_connections_after_pool_timeout()
|
|
if _send_attempt >= 2:
|
|
raise
|
|
wait = 2 ** _send_attempt
|
|
logger.warning("[%s] Network error on send (attempt %d/3), retrying in %ds: %s",
|
|
self.name, _send_attempt + 1, wait, _redact_telegram_error_text(send_err))
|
|
await asyncio.sleep(wait)
|
|
except Exception as send_err:
|
|
retry_after = getattr(send_err, "retry_after", None)
|
|
if retry_after is not None or "retry after" in str(send_err).lower():
|
|
wait = float(retry_after) if retry_after is not None else 1.0
|
|
safe_send_error = _redact_telegram_error_text(send_err)
|
|
# Never sleep a long server RetryAfter verbatim — it once pinned send() for 97 minutes.
|
|
# Mirror the edit path: a RetryAfter past a few seconds is not something to hold this
|
|
# coroutine open for. Sleeping the server value verbatim pinned send() for 97 minutes in
|
|
# production and froze inbound on every platform when it ran on the gateway boot path
|
|
# (#91969).
|
|
if wait > _FLOOD_INLINE_WAIT_CAP_SECS:
|
|
logger.warning(
|
|
"[%s] Telegram flood control on send (retry_after=%.1fs > %.0fs); failing closed instead of sleeping: %s",
|
|
self.name, wait, _FLOOD_INLINE_WAIT_CAP_SECS, safe_send_error)
|
|
return _flood_cap_result(wait)
|
|
if _send_attempt < 2:
|
|
logger.warning(
|
|
"[%s] Telegram flood control on send (attempt %d/3), retrying in %.1fs: %s", self.name,
|
|
_send_attempt + 1, wait, safe_send_error)
|
|
await asyncio.sleep(wait)
|
|
continue
|
|
# Retries exhausted and still flooded. Fail closed the same way a long penalty
|
|
# does: raising here handed the caller the platform's own wording instead of the
|
|
# canonical result, so the delivery ledger did not recognise the row as a flood
|
|
# refusal, armed no redelivery timer, and the reply waited for the next restart.
|
|
logger.warning(
|
|
"[%s] Telegram flood control on send persisted across %d attempts; failing "
|
|
"closed so the delivery ledger owns the wait: %s",
|
|
self.name, _send_attempt + 1, safe_send_error)
|
|
return _flood_cap_result(wait)
|
|
raise
|
|
|
|
async def _retrigger_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]]) -> None:
|
|
"""Re-arm typing after an intermediate send (Telegram clears it when a message lands). Skipped on
|
|
the FINAL reply (``metadata["notify"]``): the refresh loop is gone and no API cancels the bubble."""
|
|
if (metadata or {}).get("notify"):
|
|
return
|
|
with contextlib.suppress(Exception):
|
|
await self.send_typing(chat_id, metadata=metadata)
|
|
|
|
async def send(
|
|
self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
"""Send a message to a Telegram chat."""
|
|
if not self._bot:
|
|
live = self._replacement_telegram_adapter()
|
|
if live is not None:
|
|
return await live.send(chat_id, content, reply_to, metadata)
|
|
if self._is_permanent_fatal() or not await self._wait_for_reconnection():
|
|
return SendResult(success=False, error="Not connected", retryable=not self._is_permanent_fatal())
|
|
live = self._replacement_telegram_adapter()
|
|
if not self._bot and live is not None:
|
|
return await live.send(chat_id, content, reply_to, metadata)
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected", retryable=True)
|
|
# getattr() — tests build adapters via object.__new__() (no __init__).
|
|
if getattr(self, "_send_path_degraded", False):
|
|
return SendResult(success=False, error="send_path_degraded", retryable=True)
|
|
# Skip whitespace-only text to prevent Telegram 400 empty-text errors.
|
|
if not content or not content.strip():
|
|
return SendResult(success=True, message_id=None)
|
|
error_types = self._telegram_error_types()
|
|
try:
|
|
# Bot API 10.1 rich fast-path; falls through to legacy MarkdownV2 on permanent/capability
|
|
# errors or DM-topic skips; returns directly on success or transient failure (no legacy resend).
|
|
if self._should_attempt_rich(content, metadata=metadata):
|
|
rich_result = await self._try_send_rich(chat_id, content, reply_to, metadata)
|
|
if rich_result is not None:
|
|
if rich_result.success:
|
|
await self._retrigger_typing(chat_id, metadata)
|
|
return rich_result
|
|
chunks = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)
|
|
if len(chunks) > 1:
|
|
# truncate_message appends a raw " (1/2)" suffix; escape the MarkdownV2-special parentheses.
|
|
chunks = [
|
|
_separate_chunk_indicator_from_fence(re.sub(r" \((\d+)/(\d+)\)$", r" \\(\1/\2\\)", chunk))
|
|
for chunk in chunks
|
|
]
|
|
message_ids = []
|
|
thread_id = self._metadata_thread_id(metadata)
|
|
requested_thread_id = self._message_thread_id_for_send(thread_id)
|
|
used_thread_fallback = False
|
|
for i, chunk in enumerate(chunks):
|
|
outcome = await self._send_chunk_with_retries(
|
|
chat_id, chunk, i, reply_to, metadata, thread_id, used_thread_fallback, error_types)
|
|
if isinstance(outcome, SendResult):
|
|
return outcome
|
|
msg, used_thread_fallback = outcome
|
|
message_ids.append(str(msg.message_id))
|
|
await self._retrigger_typing(chat_id, metadata)
|
|
return SendResult(
|
|
success=True, message_id=message_ids[0] if message_ids else None,
|
|
raw_response={
|
|
"message_ids": message_ids, "requested_thread_id": requested_thread_id, "thread_fallback": used_thread_fallback})
|
|
except Exception as e:
|
|
safe_error = _redact_telegram_error_text(e)
|
|
logger.error("[%s] Failed to send Telegram message: %s", self.name, safe_error)
|
|
err_str = str(e).lower()
|
|
error_kind = classify_send_error(e)
|
|
# Content exceeded 4096 chars: fail so the stream consumer enters fallback mode.
|
|
if "message_too_long" in err_str or "too long" in err_str:
|
|
logger.debug("[%s] send() content too long, falling back to new-message continuation", self.name)
|
|
return SendResult(success=False, error="message_too_long", error_kind="too_long")
|
|
# TimedOut may have reached Telegram — non-retryable so _send_with_retry() doesn't re-send,
|
|
# except a wrapped ConnectTimeout or an httpx pool timeout (safe to re-send).
|
|
_to = error_types[2]
|
|
is_timeout = (_to and isinstance(e, _to)) or "timed out" in err_str
|
|
return SendResult(
|
|
success=False, error=safe_error,
|
|
retryable=(self._looks_like_connect_timeout(e) or self._looks_like_pool_timeout(e) or not is_timeout),
|
|
error_kind=error_kind)
|
|
|
|
async def send_or_update_status(
|
|
self, chat_id: str, status_key: str, content: str, *, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
"""Send a status message, or edit the previous one with the same ``(chat_id, status_key)``; if the
|
|
edit fails (deleted, too old, …) the cached id is dropped and a fresh message is sent.
|
|
|
|
Issue #30045: progress/status callbacks (context-pressure, lifecycle, compression, etc.) used to
|
|
append a fresh bubble on every call. With this method, the first call sends and the message id is
|
|
remembered; subsequent calls with the same (chat_id, status_key) edit that same message in place.
|
|
"""
|
|
key = (str(chat_id), str(status_key))
|
|
cached_id = self._status_message_ids.get(key)
|
|
if cached_id is not None:
|
|
result = await self.edit_message(chat_id, cached_id, content, finalize=True, metadata=metadata)
|
|
if result.success:
|
|
if result.message_id:
|
|
self._status_message_ids[key] = str(result.message_id)
|
|
return result
|
|
self._status_message_ids.pop(key, None)
|
|
result = await self.send(chat_id, content, metadata=metadata)
|
|
if result.success and result.message_id:
|
|
self._status_message_ids[key] = str(result.message_id)
|
|
return result
|
|
|
|
async def _edit_text(self, chat_id: str, message_id: str, text: str, parse_mode: Any = None) -> None:
|
|
"""``editMessageText`` with normalized ids; ``parse_mode=None`` sends plain text."""
|
|
kwargs: Dict[str, Any] = {"chat_id": normalize_telegram_chat_id(chat_id), "message_id": int(message_id), "text": text}
|
|
if parse_mode is not None:
|
|
kwargs["parse_mode"] = parse_mode
|
|
await self._bot.edit_message_text(**kwargs)
|
|
|
|
async def _edit_markdown_or_plain(self, chat_id: str, message_id: str, formatted: str, plain: str, warn_fmt: str) -> bool:
|
|
"""MarkdownV2 edit with plain-text fallback. Returns True on a "not modified" no-op (caller may
|
|
skip further work); the fallback edit's exceptions propagate."""
|
|
try:
|
|
await self._edit_text(chat_id, message_id, formatted, ParseMode.MARKDOWN_V2)
|
|
except Exception as fmt_err:
|
|
if "not modified" in str(fmt_err).lower():
|
|
return True
|
|
logger.warning(warn_fmt, self.name, _redact_telegram_error_text(fmt_err))
|
|
await self._edit_text(chat_id, message_id, plain)
|
|
return False
|
|
|
|
async def edit_message(
|
|
self, chat_id: str, message_id: str, content: str, *, finalize: bool = False, metadata: Optional[Dict[str, Any]] = None,
|
|
) -> SendResult:
|
|
"""Edit a previously sent Telegram message.
|
|
|
|
Telegram caps a message at 4096 UTF-16 codeunits. Streaming replies that outgrow it must NOT be truncated
|
|
silently nor fail (the consumer would re-send a duplicate): edit with the first chunk, send the rest as
|
|
continuations, and return the final chunk's id as the next edit target."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
# Rich finalize (Bot API 10.1): edit the preview IN PLACE via rich_message — no fresh send + delete.
|
|
# Before the 4,096 pre-flight because the rich cap is 32,768; falls back to legacy on rejection.
|
|
# Rich finalize (Bot API 10.1): when the completed content has constructs the legacy MarkdownV2 edit
|
|
# degrades (tables → bullet lists, task lists, <details>, block math) and rich is available, edit
|
|
# the preview IN PLACE via editMessageText's rich_message param. No fresh send + delete → no
|
|
# duplicate preview (the problem #46206 reverted the fresh-final path for). Attempted before the
|
|
# 4,096 overflow pre-flight because the rich text cap is 32,768 — a rich table that exceeds the
|
|
# MarkdownV2 limit must not be split into legacy chunks. Falls back to the legacy edit path
|
|
# (overflow split included) on capability/permanent rejection.
|
|
if finalize and self._rich_eligible(content):
|
|
rich_result = await self._try_edit_rich(chat_id, message_id, content, metadata=metadata)
|
|
if rich_result is not None:
|
|
return rich_result
|
|
# Pre-flight: over-limit content is split-and-delivered on finalize; mid-stream we truncate instead
|
|
# (splitting moves the edit target to a continuation → infinite duplication loop).
|
|
# Pre-flight: if content already exceeds the limit, split-and-deliver without round-tripping a
|
|
# doomed edit. During streaming (finalize=False) we truncate instead of splitting — splitting
|
|
# creates continuation messages whose IDs become the new edit target, and on the next token chunk
|
|
# the full accumulated text is re-edited into the continuation, triggering another split → infinite
|
|
# duplication loop (#48648).
|
|
_preview_key = (str(chat_id), str(message_id))
|
|
_saturated_preview = False
|
|
if finalize:
|
|
self._last_overflow_preview.pop(_preview_key, None) # the final edit always delivers full content
|
|
if utf16_len(content) > self.MAX_MESSAGE_LENGTH:
|
|
if finalize:
|
|
return await self._edit_overflow_split(chat_id, message_id, content, finalize=finalize, metadata=metadata)
|
|
content = self._truncate_stream_overflow_preview(content)
|
|
_saturated_preview = True
|
|
# Saturated-preview dedup: past the cap every progressive edit truncates to the same text;
|
|
# re-sending is a visual no-op that still burns flood budget (200s+ penalties).
|
|
if self._last_overflow_preview.get(_preview_key) == content:
|
|
return SendResult(success=True, message_id=message_id)
|
|
elif not finalize:
|
|
# Content shrank back under the cap — clear stale saturation state so dedup can't mask an edit.
|
|
self._last_overflow_preview.pop(_preview_key, None)
|
|
try:
|
|
if not finalize:
|
|
await self._edit_text(chat_id, message_id, content)
|
|
if _saturated_preview:
|
|
self._last_overflow_preview[_preview_key] = content
|
|
return SendResult(success=True, message_id=message_id)
|
|
await self._edit_markdown_or_plain(
|
|
chat_id, message_id, self.format_message(content), _strip_mdv2(content) if content else content,
|
|
"[%s] MarkdownV2 edit failed, falling back to plain text: %s")
|
|
return SendResult(success=True, message_id=message_id)
|
|
except Exception as e:
|
|
err_str = str(e).lower()
|
|
if "not modified" in err_str:
|
|
return SendResult(success=True, message_id=message_id)
|
|
# Reactive split: MarkdownV2 escapes can inflate the payload past the limit even when raw text fit.
|
|
if "message_too_long" in err_str or "too long" in err_str:
|
|
logger.debug(
|
|
"[%s] edit_message overflow (%d UTF-16 > %d), splitting", self.name, utf16_len(content), self.MAX_MESSAGE_LENGTH)
|
|
if finalize:
|
|
return await self._edit_overflow_split(chat_id, message_id, content, finalize=finalize, metadata=metadata)
|
|
# Mid-stream: truncate and retry instead of splitting (saturated-preview dedup as above).
|
|
# See #48648.
|
|
truncated = self._truncate_stream_overflow_preview(content)
|
|
if self._last_overflow_preview.get(_preview_key) == truncated:
|
|
return SendResult(success=True, message_id=message_id)
|
|
await self._edit_text(chat_id, message_id, truncated)
|
|
self._last_overflow_preview[_preview_key] = truncated
|
|
return SendResult(success=True, message_id=message_id)
|
|
# Flood control: short waits retry inline; long waits fail immediately so streaming falls back
|
|
# to a normal final send instead of a clipped partial.
|
|
retry_after = getattr(e, "retry_after", None)
|
|
if retry_after is not None or "retry after" in err_str:
|
|
wait = retry_after if retry_after else 1.0
|
|
logger.warning("[%s] Telegram flood control, waiting %.1fs", self.name, wait)
|
|
if wait > _FLOOD_INLINE_WAIT_CAP_SECS:
|
|
return _flood_cap_result(wait)
|
|
await asyncio.sleep(wait)
|
|
try:
|
|
await self._edit_text(chat_id, message_id, content)
|
|
return SendResult(success=True, message_id=message_id)
|
|
except Exception as retry_err:
|
|
safe_retry_error = _redact_telegram_error_text(retry_err)
|
|
logger.error("[%s] Edit retry failed after flood wait: %s", self.name, safe_retry_error)
|
|
retry_wait = getattr(retry_err, "retry_after", None)
|
|
if retry_wait is not None or "retry after" in str(retry_err).lower():
|
|
# Still flooded after the inline wait, and typically for much longer than the
|
|
# first refusal asked for. Fail closed canonically so the ledger arms its
|
|
# timer on this delay rather than storing the platform's raw wording, which
|
|
# it would read as an ordinary failure and never redeliver.
|
|
return _flood_cap_result(
|
|
float(retry_wait) if retry_wait is not None else wait)
|
|
return SendResult(success=False, error=safe_retry_error)
|
|
safe_error = _redact_telegram_error_text(e)
|
|
# Transient network errors must not permanently disable progress-message editing.
|
|
_transient_markers = (
|
|
"connecterror", "connect error", "connection error", "networkerror", "network error", "timed out", "readtimeout",
|
|
"writetimeout", "server disconnected", "temporarily unavailable", "temporary failure", "httpx")
|
|
if any(m in err_str for m in _transient_markers):
|
|
logger.warning("[%s] Transient network error editing message %s (will retry): %s", self.name, message_id, safe_error)
|
|
return SendResult(success=False, error=safe_error, retryable=True)
|
|
logger.error("[%s] Failed to edit Telegram message %s: %s", self.name, message_id, safe_error)
|
|
return SendResult(success=False, error=safe_error)
|
|
|
|
def _truncate_stream_overflow_preview(self, content: str) -> str:
|
|
"""One-message preview for oversized streaming edits (edits must keep targeting the original id;
|
|
final edits use ``_edit_overflow_split``).
|
|
|
|
Splitting a mid-stream preview creates continuation messages and moves the active message id, so the
|
|
next accumulated-token edit repeats the overflow cycle (#48648). Final edits still use
|
|
``_edit_overflow_split`` to deliver the complete response.
|
|
"""
|
|
return self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)[0]
|
|
|
|
async def _send_overflow_continuation(
|
|
self, chat_id: str, chunk: str, reply_to_id: Optional[int], thread_kwargs: Dict[str, Any],
|
|
thread_id: Optional[str], metadata: Optional[Dict[str, Any]], finalize: bool):
|
|
"""Send one continuation chunk (MarkdownV2 then plain on finalize; raw when streaming); drops the
|
|
reply anchor once on 'reply message not found'. Returns the sent message or None."""
|
|
base = {**self._link_preview_kwargs(), **self._notification_kwargs(metadata)}
|
|
for use_markdown in (True, False) if finalize else (False,):
|
|
try:
|
|
if use_markdown:
|
|
text = _separate_chunk_indicator_from_fence(self.format_message(chunk))
|
|
else:
|
|
# Degrade to stripped text on finalize (raw ** / ``` would render literally); previews stay raw.
|
|
text = _strip_mdv2(chunk) if finalize else chunk
|
|
return await self._bot.send_message(
|
|
chat_id=normalize_telegram_chat_id(chat_id), text=text, parse_mode=ParseMode.MARKDOWN_V2 if use_markdown else None,
|
|
reply_to_message_id=reply_to_id, **thread_kwargs, **base)
|
|
except Exception as send_err:
|
|
if "reply message not found" in str(send_err).lower():
|
|
# Private DM topic fallback needs anchor + topic id together; forum topics keep thread id.
|
|
retry_thread_kwargs = (
|
|
{} if self._dm_topic_fallback(metadata)
|
|
else self._thread_kwargs_for_send(chat_id, thread_id, metadata, reply_to_message_id=None))
|
|
try:
|
|
return await self._bot.send_message(
|
|
chat_id=normalize_telegram_chat_id(chat_id), text=_strip_mdv2(chunk) if finalize else chunk,
|
|
**retry_thread_kwargs, **base)
|
|
except Exception as _retry_err:
|
|
logger.warning(
|
|
"[%s] Overflow continuation no-reply retry failed: %s", self.name, _redact_telegram_error_text(_retry_err))
|
|
return None
|
|
if use_markdown:
|
|
continue # try plain text on next loop iteration
|
|
logger.warning("[%s] Overflow continuation send failed: %s", self.name, _redact_telegram_error_text(send_err))
|
|
return None
|
|
return None
|
|
|
|
async def _edit_overflow_split(
|
|
self, chat_id: str, message_id: str, content: str, *, finalize: bool, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
"""Split an oversized edit across the existing message + continuations: edit ``message_id`` with
|
|
chunk 1, send the rest as replies to the previous chunk, return ``message_id=<last-chunk-id>`` so
|
|
the consumer keeps editing the newest message. ``success=False`` only if the first-chunk edit fails."""
|
|
chunks = self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)
|
|
if len(chunks) <= 1:
|
|
chunks = [content] # defensive: a single chunk just edits normally
|
|
first_chunk = chunks[0]
|
|
try:
|
|
if finalize:
|
|
await self._edit_markdown_or_plain(
|
|
chat_id, message_id, _separate_chunk_indicator_from_fence(self.format_message(first_chunk)), _strip_mdv2(first_chunk),
|
|
"[%s] Overflow split: MarkdownV2 first-chunk edit failed, falling back to plain text: %s")
|
|
else:
|
|
await self._edit_text(chat_id, message_id, first_chunk)
|
|
except Exception as e:
|
|
if "not modified" not in str(e).lower(): # identical first chunk still sends continuations
|
|
logger.error("[%s] Overflow split: first-chunk edit failed: %s", self.name, _redact_telegram_error_text(e), exc_info=True)
|
|
return SendResult(success=False, error=_redact_telegram_error_text(e))
|
|
# Continuations call self._bot.send_message directly to skip self.send's pre-chunking.
|
|
continuation_ids: list[str] = []
|
|
delivered_chunks = [first_chunk]
|
|
prev_id = message_id
|
|
thread_id = self._metadata_thread_id(metadata)
|
|
for chunk in chunks[1:]:
|
|
reply_to_id = int(prev_id) if prev_id else None
|
|
thread_kwargs = self._thread_kwargs_for_send(chat_id, thread_id, metadata, reply_to_message_id=reply_to_id)
|
|
sent_msg = await self._send_overflow_continuation(chat_id, chunk, reply_to_id, thread_kwargs, thread_id, metadata, finalize)
|
|
if sent_msg is None:
|
|
# Partial delivery: do NOT report success — the consumer would treat it as final delivery.
|
|
logger.warning("[%s] Overflow split: stopped at %d/%d chunks delivered", self.name, 1 + len(continuation_ids), len(chunks))
|
|
delivered_prefix = "".join(re.sub(r" \(\d+/\d+\)$", "", delivered) for delivered in delivered_chunks)
|
|
return SendResult(
|
|
success=False, message_id=prev_id, error="overflow_continuation_failed", retryable=True,
|
|
raw_response={
|
|
"partial_overflow": True, "delivered_chunks": 1 + len(continuation_ids),
|
|
"total_chunks": len(chunks), "last_message_id": prev_id, "delivered_prefix": delivered_prefix,
|
|
"continuation_message_ids": tuple(continuation_ids)},
|
|
continuation_message_ids=tuple(continuation_ids))
|
|
new_id = str(getattr(sent_msg, "message_id", "")) or prev_id
|
|
continuation_ids.append(new_id)
|
|
delivered_chunks.append(chunk)
|
|
prev_id = new_id
|
|
last_id = continuation_ids[-1] if continuation_ids else message_id
|
|
logger.debug("[%s] Overflow split delivered %d chunks; last_id=%s", self.name, 1 + len(continuation_ids), last_id)
|
|
return SendResult(success=True, message_id=last_id, continuation_message_ids=tuple(continuation_ids))
|
|
|
|
async def delete_message(self, chat_id: str, message_id: str) -> bool:
|
|
"""Delete a bot-posted message (Bot API allows it within 48h); failures are non-fatal.
|
|
|
|
Used by the stream consumer's fresh-final cleanup path (ported from openclaw/openclaw#72038) to
|
|
remove long-lived preview messages after sending the completed reply as a fresh message. Telegram's
|
|
Bot API ``deleteMessage`` works for bot-posted messages in the last 48 hours. Failures are non-fatal
|
|
— the caller leaves the preview in place and logs at debug level.
|
|
"""
|
|
if not self._bot:
|
|
return False
|
|
try:
|
|
await self._bot.delete_message(chat_id=normalize_telegram_chat_id(chat_id), message_id=int(message_id))
|
|
return True
|
|
except Exception as e:
|
|
logger.debug("[%s] Failed to delete Telegram message %s: %s", self.name, message_id, _redact_telegram_error_text(e))
|
|
return False
|
|
|
|
def supports_draft_streaming(self, chat_type: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> bool:
|
|
"""sendMessageDraft works for private chats only (Bot API 9.5) and needs PTB >= 22.6; groups and
|
|
older installs use the edit-based path. ``rich_drafts`` controls draft *format*, not availability."""
|
|
if not self._bot or not hasattr(self._bot, "send_message_draft"):
|
|
return False
|
|
return (chat_type or "").lower() in {"dm", "private"}
|
|
|
|
async def send_draft(self, chat_id: str, draft_id: int, content: str, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
"""Stream a partial message via ``sendRichMessageDraft`` (when rich is enabled and supported) else
|
|
``sendMessageDraft``; reusing ``draft_id`` animates the preview. The caller sends the final text."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="not_connected")
|
|
# Rich draft fast-path; any failure degrades to the plain draft below. Drafts have no message_id.
|
|
if self._should_attempt_rich_draft(content) and await self._try_send_rich_draft(chat_id, draft_id, content, metadata):
|
|
return SendResult(success=True, message_id=None)
|
|
if not hasattr(self._bot, "send_message_draft"):
|
|
return SendResult(success=False, error="api_unavailable")
|
|
# Drafts share the regular-send UTF-16 length contract.
|
|
text = content if len(
|
|
content) <= self.MAX_MESSAGE_LENGTH else self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)[0]
|
|
# Same MarkdownV2 conversion as ``send`` (MarkdownV2 then plain) so the draft doesn't snap at the end. Exception: a Rich
|
|
# final with rich drafts disabled previews raw — the legacy formatter would turn pipe tables into bullets.
|
|
plain_rich_preview = bool(
|
|
getattr(self, "_rich_messages_enabled", False) and not getattr(self, "_rich_drafts_enabled", False)
|
|
and self._needs_rich_rendering(text))
|
|
draft_thread_kwargs = self._thread_kwargs_for_draft(chat_id, metadata)
|
|
for use_markdown in ((False,) if plain_rich_preview else (True, False)):
|
|
kwargs: Dict[str, Any] = {
|
|
"chat_id": normalize_telegram_chat_id(chat_id), "draft_id": int(draft_id),
|
|
"text": self.format_message(text) if use_markdown else text}
|
|
if use_markdown:
|
|
kwargs["parse_mode"] = ParseMode.MARKDOWN_V2
|
|
kwargs.update(draft_thread_kwargs)
|
|
try:
|
|
if await self._bot.send_message_draft(**kwargs):
|
|
return SendResult(success=True, message_id=None)
|
|
return SendResult(success=False, error="draft_rejected")
|
|
except Exception as e:
|
|
# MarkdownV2 parse failure → retry once as plain text; anything else returns to the caller,
|
|
# which falls back to edit-based streaming for this response.
|
|
if use_markdown and self._is_bad_request_error(e):
|
|
logger.debug(
|
|
"[%s] sendMessageDraft MarkdownV2 rejected, retrying as plain text (chat=%s draft_id=%s): %s",
|
|
self.name, chat_id, draft_id, _redact_telegram_error_text(e))
|
|
continue
|
|
logger.debug("[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s", self.name, chat_id, draft_id, e)
|
|
return SendResult(success=False, error=_redact_telegram_error_text(e))
|
|
return SendResult(success=False, error="draft_rejected")
|
|
|
|
async def _send_message_with_thread_fallback(self, **kwargs):
|
|
"""Send a control-style message (approval prompts, pickers), retrying once without
|
|
message_thread_id on 'Message thread not found' (stale thread_id); ``send`` has its own.
|
|
|
|
Used for control-style sends (approval prompts, model picker, update prompts) that can carry a stale
|
|
thread_id from a DM reply chain. The streaming send loop has its own equivalent (PR #3390) at the
|
|
body of ``send``; this helper applies the same retry pattern to the non-streaming control paths.
|
|
"""
|
|
if not self._bot:
|
|
raise RuntimeError("Not connected")
|
|
message_thread_id = kwargs.get("message_thread_id")
|
|
try:
|
|
return await self._bot.send_message(**kwargs)
|
|
except Exception as send_err:
|
|
if (message_thread_id is not None and self._is_bad_request_error(send_err) and self._is_thread_not_found_error(send_err)):
|
|
logger.warning(
|
|
"[%s] Thread %s not found for control message, retrying without message_thread_id", self.name, message_thread_id)
|
|
# Same prune as the streaming send path; control sends carry no gateway metadata.
|
|
self._prune_stale_dm_topic_binding(kwargs.get("chat_id"), message_thread_id)
|
|
retry_kwargs = dict(kwargs)
|
|
retry_kwargs.pop("message_thread_id", None)
|
|
return await self._bot.send_message(**retry_kwargs)
|
|
raise
|
|
|
|
async def _send_control_message(
|
|
self, chat_id: str, text: str, *, parse_mode: Any, thread_id: Optional[str], metadata: Optional[Dict[str, Any]],
|
|
reply_markup: Any = None, reply_to_mode: Optional[str] = None):
|
|
"""Send a control-style message (prompt/picker) with topic routing + thread fallback."""
|
|
reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=reply_to_mode)
|
|
kwargs: Dict[str, Any] = {
|
|
"chat_id": normalize_telegram_chat_id(chat_id), "text": text, "parse_mode": parse_mode, **self._link_preview_kwargs()}
|
|
if reply_markup is not None:
|
|
kwargs["reply_markup"] = reply_markup
|
|
kwargs["reply_to_message_id"] = reply_to_id
|
|
kwargs.update(self._thread_kwargs_for_send(
|
|
chat_id, thread_id, metadata, reply_to_message_id=reply_to_id, reply_to_mode=reply_to_mode))
|
|
return await self._send_message_with_thread_fallback(**kwargs)
|
|
|
|
async def _send_prompt(self, what: str, chat_id: str, metadata: Optional[Dict[str, Any]], build, *,
|
|
parse_mode: Any = None, thread_id: Any = None, reply_to_mode: Any = None) -> SendResult:
|
|
"""Shared control-prompt shell: not-connected guard, ``build()`` → ``(text, keyboard, on_sent)`` (or a
|
|
SendResult to return as-is), routed send, state hook, redacted failure log."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
try:
|
|
built = build()
|
|
if isinstance(built, SendResult):
|
|
return built
|
|
text, keyboard, on_sent = built
|
|
msg = await self._send_control_message(
|
|
chat_id, text, parse_mode=parse_mode if parse_mode is not None else ParseMode.MARKDOWN_V2,
|
|
reply_markup=keyboard, thread_id=thread_id, metadata=metadata, reply_to_mode=reply_to_mode)
|
|
if on_sent is not None:
|
|
on_sent(msg)
|
|
return SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e:
|
|
logger.warning("[%s] %s failed: %s", self.name, what, _redact_telegram_error_text(e))
|
|
return SendResult(success=False, error=_redact_telegram_error_text(e))
|
|
|
|
@staticmethod
|
|
def _rows_of_two(buttons: list) -> list:
|
|
"""2-per-row layout keeps labels readable on mobile (a 4-button row truncates)."""
|
|
return [buttons[i:i + 2] for i in range(0, len(buttons), 2)]
|
|
|
|
async def send_update_prompt(
|
|
self, chat_id: str, prompt: str, default: str = "", session_key: str = "", metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
"""Send an inline-keyboard Yes/No prompt for the gateway ``/update`` watcher."""
|
|
def build():
|
|
default_hint = f" (default: {default})" if default else ""
|
|
text = self.format_message(f"☤ *Update needs your input:*\n\n{prompt}{default_hint}")
|
|
keyboard = InlineKeyboardMarkup([[
|
|
InlineKeyboardButton("✓ Yes", callback_data="update_prompt:y"),
|
|
InlineKeyboardButton("✗ No", callback_data="update_prompt:n")]])
|
|
return text, keyboard, None
|
|
return await self._send_prompt(
|
|
"send_update_prompt", chat_id, metadata, build, thread_id=self._metadata_thread_id(metadata), reply_to_mode=self._reply_to_mode)
|
|
|
|
# Template attrs for the shared _format_exec_approval core (HTML mode).
|
|
_EA_HEADER = "⚠️ <b>Command Approval Required</b>\n\n"
|
|
_EA_CODE_OPEN = "<pre>"
|
|
_EA_CODE_CLOSE = "</pre>\n\n"
|
|
_EA_SMART_DENY_LINE = "\n\n<b>Smart DENY:</b> owner override applies to this one operation only."
|
|
_EA_CMD_BUDGET = 3800
|
|
|
|
def _ea_escape(self, text: str) -> str:
|
|
return _html.escape(text)
|
|
|
|
_EA_ACTION_LABELS = {"once": "✅ Allow Once", "session": "✅ Session", "always": "✅ Always", "deny": "❌ Deny"}
|
|
|
|
async def _send_exec_approval_prompt(self, prompt: ExecApprovalPrompt) -> SendResult:
|
|
"""Inline-keyboard approval prompt; buttons call ``resolve_gateway_approval()`` like the
|
|
text ``/approve`` flow."""
|
|
def build():
|
|
# Short monotonic ids in callback_data map back to session_key.
|
|
import itertools
|
|
if not hasattr(self, "_approval_counter"):
|
|
self._approval_counter = itertools.count(1)
|
|
approval_id = next(self._approval_counter)
|
|
buttons = [InlineKeyboardButton(label, callback_data=f"ea:{choice}:{approval_id}")
|
|
for label, choice, _ in prompt.actions]
|
|
return prompt.text, InlineKeyboardMarkup(self._rows_of_two(buttons)), (
|
|
lambda msg: self._approval_state.__setitem__(approval_id, prompt.session_key))
|
|
return await self._send_prompt(
|
|
"send_exec_approval", prompt.chat_id, prompt.metadata, build, parse_mode=ParseMode.HTML,
|
|
thread_id=self._metadata_thread_id(prompt.metadata), reply_to_mode=self._reply_to_mode)
|
|
|
|
async def send_slash_confirm(
|
|
self, chat_id: str, title: str, message: str, session_key: str, confirm_id: str,
|
|
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
"""Render a three-button slash-command confirmation prompt."""
|
|
def build():
|
|
keyboard = InlineKeyboardMarkup([
|
|
[
|
|
InlineKeyboardButton("✅ Approve Once", callback_data=f"sc:once:{confirm_id}"),
|
|
InlineKeyboardButton("🔒 Always Approve", callback_data=f"sc:always:{confirm_id}")],
|
|
[InlineKeyboardButton("❌ Cancel", callback_data=f"sc:cancel:{confirm_id}")],
|
|
])
|
|
preview = self.format_message(self._truncate_preview(message, 3800))
|
|
return preview, keyboard, lambda msg: self._slash_confirm_state.__setitem__(confirm_id, session_key)
|
|
return await self._send_prompt(
|
|
"send_slash_confirm", chat_id, metadata, build, thread_id=self._metadata_thread_id(metadata), reply_to_mode=self._reply_to_mode)
|
|
|
|
async def send_clarify(
|
|
self, chat_id: str, question: str, choices: Optional[list], clarify_id: str, session_key: str,
|
|
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
"""Render a clarify prompt: numbered buttons per choice plus "✏️ Other (type answer)" (flips to
|
|
text-capture mode); without choices, plain question and the gateway text-intercept captures."""
|
|
def build():
|
|
text = f"❓ {_html.escape(question)}"
|
|
keyboard = None
|
|
if choices:
|
|
# Full option text in the body (mobile truncates button labels); buttons keep numeric labels.
|
|
text += "\n\n" + "\n".join(f"{i + 1}. {_html.escape(str(c))}" for i, c in enumerate(choices))
|
|
# Telegram caps callback_data at 64 bytes; keep "cl:<id>:<idx>" short.
|
|
rows = [[InlineKeyboardButton(str(idx + 1), callback_data=f"cl:{clarify_id}:{idx}")] for idx in range(len(choices))]
|
|
rows.append([InlineKeyboardButton("✏️ Other (type answer)", callback_data=f"cl:{clarify_id}:other")])
|
|
keyboard = InlineKeyboardMarkup(rows)
|
|
return text, keyboard, lambda msg: self._clarify_state.__setitem__(clarify_id, session_key)
|
|
return await self._send_prompt(
|
|
"send_clarify", chat_id, metadata, build, parse_mode=ParseMode.HTML, thread_id=self._metadata_thread_id(metadata))
|
|
|
|
@staticmethod
|
|
def _provider_get_label():
|
|
try:
|
|
from hermes_cli.providers import get_label
|
|
except ImportError:
|
|
def get_label(slug):
|
|
return slug
|
|
return get_label
|
|
|
|
async def send_model_picker(
|
|
self, chat_id: str, providers: list, current_model: str, current_provider: str, session_key: str,
|
|
on_model_selected, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
"""Send an inline-keyboard model picker: provider → model drill-down, edited in place."""
|
|
def build():
|
|
keyboard, provider_page_info = self._build_provider_keyboard(providers, 0)
|
|
text = self.format_message(
|
|
self._provider_list_text(current_model, self._provider_get_label()(current_provider), provider_page_info)
|
|
)
|
|
|
|
def _remember(msg):
|
|
self._model_picker_state[str(chat_id)] = {
|
|
"msg_id": msg.message_id, "providers": providers, "session_key": session_key, "on_model_selected": on_model_selected,
|
|
"current_model": current_model, "current_provider": current_provider, "provider_page": 0}
|
|
return text, keyboard, _remember
|
|
return await self._send_prompt(
|
|
"send_model_picker", chat_id, metadata, build, thread_id=metadata.get("thread_id") if metadata else None,
|
|
reply_to_mode=self._reply_to_mode)
|
|
|
|
_PROVIDER_PAGE_SIZE = 10
|
|
|
|
async def send_choice_picker(
|
|
self, chat_id: str, title: str, choices: list, session_key: str, on_choice_selected,
|
|
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
"""Flat inline-keyboard picker (one tap → one value) for /reasoning, /fast, etc. Each choice dict:
|
|
``{"value": str, "label": str, "is_current": bool}``."""
|
|
def build():
|
|
buttons = []
|
|
for i, choice in enumerate(choices):
|
|
label = str(choice.get("label") or choice.get("value") or "")
|
|
if choice.get("is_current"):
|
|
label = f"✓ {label}"
|
|
buttons.append(InlineKeyboardButton(label, callback_data=f"cp:{i}"))
|
|
if not buttons:
|
|
return SendResult(success=False, error="No choices")
|
|
keyboard = InlineKeyboardMarkup(self._rows_of_two(buttons))
|
|
|
|
def _remember(msg):
|
|
self._choice_picker_state[str(chat_id)] = {
|
|
"msg_id": msg.message_id, "choices": choices, "session_key": session_key, "on_choice_selected": on_choice_selected}
|
|
return self.format_message(title), keyboard, _remember
|
|
return await self._send_prompt(
|
|
"send_choice_picker", chat_id, metadata, build, thread_id=metadata.get("thread_id") if metadata else None,
|
|
reply_to_mode=self._reply_to_mode)
|
|
|
|
async def _edit_result_text(self, query, result_text: str) -> None:
|
|
"""Replace a picker message with ``result_text`` (MarkdownV2, then plain, then give up), keyboard removed."""
|
|
try:
|
|
await query.edit_message_text(text=self.format_message(result_text), parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None)
|
|
except Exception:
|
|
with contextlib.suppress(Exception):
|
|
await query.edit_message_text(text=result_text, parse_mode=None, reply_markup=None)
|
|
|
|
async def _handle_choice_picker_callback(self, query, data: str, chat_id: str) -> None:
|
|
"""Handle choice picker button taps (cp:<index>)."""
|
|
state = self._choice_picker_state.get(chat_id)
|
|
if not state:
|
|
await query.answer(text="Picker expired — run the command again.")
|
|
return
|
|
# Same auth gate as approval buttons: strangers in a shared group must not flip session state.
|
|
if not await self._callback_authorized(query, self._callback_ctx(query), "⛔ You are not authorized to change this setting."):
|
|
return
|
|
try:
|
|
choice = state["choices"][int(data[3:])]
|
|
except (ValueError, IndexError):
|
|
await query.answer(text="Invalid selection.")
|
|
return
|
|
callback = state.get("on_choice_selected")
|
|
if not callback:
|
|
await query.answer(text="Picker expired.")
|
|
return
|
|
try:
|
|
result_text = await callback(chat_id, str(choice.get("value") or ""))
|
|
except Exception as exc:
|
|
logger.error("Choice picker selection failed: %s", exc)
|
|
result_text = f"Error applying selection: {exc}"
|
|
await self._edit_result_text(query, result_text)
|
|
await query.answer()
|
|
self._choice_picker_state.pop(chat_id, None)
|
|
|
|
_MODEL_PAGE_SIZE = 8
|
|
|
|
@staticmethod
|
|
def _provider_button(p: dict) -> "InlineKeyboardButton":
|
|
count = p.get("total_models", len(p.get("models", [])))
|
|
label = f"{p['name']} ({count})"
|
|
if p.get("is_current"):
|
|
label = f"✓ {label}"
|
|
return InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
|
|
|
@staticmethod
|
|
def _picker_nav_row(page: int, total_pages: int, prefix: str) -> list:
|
|
"""``◀ Prev | n/N | Next ▶`` row (``prefix`` = ``mpv``/``mg`` page callback)."""
|
|
nav: list = []
|
|
if page > 0:
|
|
nav.append(InlineKeyboardButton("◀ Prev", callback_data=f"{prefix}:{page - 1}"))
|
|
nav.append(InlineKeyboardButton(f"{page + 1}/{total_pages}", callback_data="mx:noop"))
|
|
if page < total_pages - 1:
|
|
nav.append(InlineKeyboardButton("Next ▶", callback_data=f"{prefix}:{page + 1}"))
|
|
return nav
|
|
|
|
@staticmethod
|
|
def _picker_back_cancel_row() -> list:
|
|
return [InlineKeyboardButton("◀ Back", callback_data="mb"), InlineKeyboardButton("✗ Cancel", callback_data="mx")]
|
|
|
|
def _paged_keyboard(self, buttons: list, page_meta: dict, nav_prefix: str, tail_row: list) -> tuple:
|
|
rows = self._rows_of_two(buttons)
|
|
if page_meta["total_pages"] > 1:
|
|
rows.append(self._picker_nav_row(page_meta["page"], page_meta["total_pages"], nav_prefix))
|
|
rows.append(tail_row)
|
|
return InlineKeyboardMarkup(rows), page_meta["page_info"]
|
|
|
|
def _build_provider_keyboard(self, providers: list, page: int = 0) -> tuple:
|
|
"""Paginated top-level provider keyboard folding provider families (Kimi/Moonshot, MiniMax, xAI…)
|
|
into one ``mpg:<gid>`` button via the shared ``group_providers`` fold; singles are ``mp:<slug>``."""
|
|
try:
|
|
from hermes_cli.models_catalog_static import group_providers
|
|
except Exception:
|
|
group_providers = None
|
|
by_slug = {p.get("slug"): p for p in providers}
|
|
buttons: list = []
|
|
if group_providers is not None:
|
|
for row in group_providers([p.get("slug") for p in providers]):
|
|
if row["kind"] == "group":
|
|
members = [by_slug[m] for m in row["members"] if m in by_slug]
|
|
count = sum(m.get("total_models", len(m.get("models", []))) for m in members)
|
|
label = f"{row['label']} ▸ ({count})"
|
|
if any(m.get("is_current") for m in members):
|
|
label = f"✓ {label}"
|
|
buttons.append(InlineKeyboardButton(label, callback_data=f"mpg:{row['group_id']}"))
|
|
else:
|
|
p = by_slug.get(row["slug"])
|
|
if p is not None:
|
|
buttons.append(self._provider_button(p))
|
|
else:
|
|
buttons = [self._provider_button(p) for p in providers]
|
|
page_buttons, page_meta = self._format_choice_page(buttons, page, self._PROVIDER_PAGE_SIZE)
|
|
return self._paged_keyboard(page_buttons, page_meta, "mpv", [InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
|
|
|
def _build_model_keyboard(self, models: list, page: int) -> tuple:
|
|
"""Build paginated model buttons. Returns (keyboard, page_info_text)."""
|
|
page_models, page_meta = self._format_choice_page(models, page, self._MODEL_PAGE_SIZE)
|
|
start = page_meta["start"]
|
|
buttons: list = []
|
|
for i, model_id in enumerate(page_models):
|
|
short = model_id.split("/")[-1] if "/" in model_id else model_id
|
|
if len(short) > 38:
|
|
short = short[:35] + "..."
|
|
buttons.append(InlineKeyboardButton(short, callback_data=f"mm:{start + i}"))
|
|
return self._paged_keyboard(buttons, page_meta, "mg", self._picker_back_cancel_row())
|
|
|
|
async def _picker_edit(self, query, text_md: str, keyboard) -> None:
|
|
"""Re-render the picker message in place (MarkdownV2) and ack the tap."""
|
|
await query.edit_message_text(text=self.format_message(text_md), parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard)
|
|
await query.answer()
|
|
|
|
async def _picker_show_models(self, query, state: dict, page: int) -> None:
|
|
"""Render the model page for the provider currently selected in ``state``."""
|
|
models = state.get("model_list", [])
|
|
state["model_page"] = page
|
|
keyboard, page_info = self._build_model_keyboard(models, page)
|
|
pname = state.get("selected_provider_name", "")
|
|
provider_slug = state.get("selected_provider", "")
|
|
provider = next((p for p in state["providers"] if p["slug"] == provider_slug), None)
|
|
total = provider.get("total_models", len(models)) if provider else len(models)
|
|
shown = len(models)
|
|
extra = f"\n_{total - shown} more available — type `/model <name>` directly_" if total > shown else ""
|
|
await self._picker_edit(query, f"⚙ *Model Configuration*\n\nProvider: *{pname}*{page_info}\nSelect a model:{extra}", keyboard)
|
|
|
|
@staticmethod
|
|
def _provider_list_text(current_model: str, provider_label: str, page_info: str) -> str:
|
|
return (f"⚙ *Model Configuration*\n\nCurrent model: `{current_model or 'unknown'}`\n"
|
|
f"Provider: {provider_label}\n\nSelect a provider:{page_info}")
|
|
|
|
async def _picker_show_providers(self, query, state: dict, page: int, get_label) -> None:
|
|
"""Render the (folded, paginated) provider list."""
|
|
keyboard, provider_page_info = self._build_provider_keyboard(state["providers"], page)
|
|
try:
|
|
provider_label = get_label(state["current_provider"])
|
|
except Exception:
|
|
provider_label = state["current_provider"]
|
|
await self._picker_edit(query, self._provider_list_text(state["current_model"], provider_label, provider_page_info), keyboard)
|
|
|
|
async def _picker_selection(self, query, state: dict, raw_idx: str) -> Optional[tuple]:
|
|
"""Resolve ``mm:``/``mc:`` index → ``(idx, model_id, provider_slug, callback)``; answers + None on error."""
|
|
try:
|
|
idx = int(raw_idx)
|
|
except ValueError:
|
|
await query.answer(text="Invalid selection.")
|
|
return None
|
|
model_list = state.get("model_list", [])
|
|
if idx < 0 or idx >= len(model_list):
|
|
await query.answer(text="Invalid model index.")
|
|
return None
|
|
callback = state.get("on_model_selected")
|
|
if not callback:
|
|
await query.answer(text="Picker expired.")
|
|
return None
|
|
return idx, model_list[idx], state.get("selected_provider", ""), callback
|
|
|
|
async def _picker_switch(self, query, chat_id: str, model_id: str, provider_slug: str, callback) -> None:
|
|
"""Perform the model switch, render the result, and drop the picker state."""
|
|
switch_failed = False
|
|
try:
|
|
result_text = await callback(chat_id, model_id, provider_slug)
|
|
except Exception as exc:
|
|
logger.error("Model picker switch failed: %s", exc)
|
|
result_text = f"Error switching model: {exc}"
|
|
switch_failed = True
|
|
await self._edit_result_text(query, result_text)
|
|
await query.answer(text="Switch failed." if switch_failed else "Model switched!")
|
|
self._model_picker_state.pop(chat_id, None)
|
|
|
|
@staticmethod
|
|
async def _parse_page(query, raw: str) -> Optional[int]:
|
|
try:
|
|
return int(raw)
|
|
except ValueError:
|
|
await query.answer(text="Invalid page.")
|
|
return None
|
|
|
|
async def _handle_model_picker_callback(self, query, data: str, chat_id: str) -> None:
|
|
"""Handle model picker callbacks (mp:/mpg:/mpv:/mm:/mc:/mb/mx/mg:)."""
|
|
state = self._model_picker_state.get(chat_id)
|
|
if not state:
|
|
await query.answer(text="Picker expired — use /model again.")
|
|
return
|
|
get_label = self._provider_get_label()
|
|
if data.startswith("mp:"): # provider selected: show model buttons (page 0)
|
|
provider_slug = data[3:]
|
|
provider = next((p for p in state["providers"] if p["slug"] == provider_slug), None)
|
|
if not provider:
|
|
await query.answer(text="Provider not found.")
|
|
return
|
|
state["selected_provider"] = provider_slug
|
|
state["selected_provider_name"] = provider.get("name", provider_slug)
|
|
state["model_list"] = provider.get("models", [])
|
|
await self._picker_show_models(query, state, 0)
|
|
elif data.startswith("mg:"): # model page navigation
|
|
page = await self._parse_page(query, data[3:])
|
|
if page is not None:
|
|
await self._picker_show_models(query, state, page)
|
|
elif data.startswith("mpv:"): # provider page navigation
|
|
page = await self._parse_page(query, data[4:])
|
|
if page is not None:
|
|
state["provider_page"] = page
|
|
await self._picker_show_providers(query, state, page, get_label)
|
|
elif data.startswith("mc:"): # expensive model confirmed: perform the switch
|
|
sel = await self._picker_selection(query, state, data[3:])
|
|
if sel is not None:
|
|
_idx, model_id, provider_slug, callback = sel
|
|
await self._picker_switch(query, chat_id, model_id, provider_slug, callback)
|
|
elif data.startswith("mm:"): # model selected: warn if expensive, else perform the switch
|
|
sel = await self._picker_selection(query, state, data[3:])
|
|
if sel is None:
|
|
return
|
|
idx, model_id, provider_slug, callback = sel
|
|
try:
|
|
from hermes_cli.model_selection_guards import combined_selection_warning
|
|
# Pricing lookup may hit models.dev on a cache miss — keep it off the event loop.
|
|
warning = await asyncio.to_thread(combined_selection_warning, model_id, provider=provider_slug)
|
|
except Exception:
|
|
warning = None
|
|
if warning is not None:
|
|
keyboard = InlineKeyboardMarkup([
|
|
[InlineKeyboardButton("Switch anyway", callback_data=f"mc:{idx}")], self._picker_back_cancel_row()])
|
|
await query.edit_message_text(
|
|
text=self.format_message(f"⚠ *{warning.title}*\n\n{warning.message}"),
|
|
parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard)
|
|
await query.answer(text="Confirm model selection")
|
|
return
|
|
await self._picker_switch(query, chat_id, model_id, provider_slug, callback)
|
|
elif data.startswith("mpg:"): # provider group selected: show member providers
|
|
group_id = data[4:]
|
|
try:
|
|
from hermes_cli.models_catalog_static import PROVIDER_GROUPS
|
|
_label, _desc, member_slugs = PROVIDER_GROUPS.get(group_id, ("", "", []))
|
|
except Exception:
|
|
_label, member_slugs = "", []
|
|
by_slug = {p["slug"]: p for p in state["providers"]}
|
|
members = [by_slug[m] for m in member_slugs if m in by_slug]
|
|
if not members:
|
|
await query.answer(text="Group not found.")
|
|
return
|
|
rows = self._rows_of_two([self._provider_button(p) for p in members])
|
|
rows.append(self._picker_back_cancel_row())
|
|
await self._picker_edit(
|
|
query, f"⚙ *Model Configuration*\n\nProvider family: *{_label or group_id}*\n\nSelect a provider:",
|
|
InlineKeyboardMarkup(rows))
|
|
elif data == "mb": # back to provider list (folds groups)
|
|
await self._picker_show_providers(query, state, int(state.get("provider_page", 0) or 0), get_label)
|
|
elif data == "mx":
|
|
self._model_picker_state.pop(chat_id, None)
|
|
await query.edit_message_text(text="Model selection cancelled.", reply_markup=None)
|
|
await query.answer()
|
|
else:
|
|
await query.answer() # e.g. page-counter button "mx:noop"
|
|
|
|
async def _notify_clarify_expired(self, query, user_display: str) -> None:
|
|
"""Tell the user a clarify tap arrived too late (entry evicted or gateway restarted) — otherwise
|
|
the tap leaves a misleading ✓ the agent never sees."""
|
|
with contextlib.suppress(Exception):
|
|
await query.answer(text="⚠️ This prompt expired — please /retry.")
|
|
await self._edit_html_quiet(
|
|
query, f"❓ {_html.escape(query.message.text or '')}\n\n<i>⚠️ This question expired or the session reset — please /retry.</i>")
|
|
|
|
@staticmethod
|
|
async def _edit_html_quiet(query, text: str) -> None:
|
|
"""HTML edit with the keyboard removed; failures ignored (non-fatal)."""
|
|
with contextlib.suppress(Exception):
|
|
await query.edit_message_text(text=text, parse_mode=ParseMode.HTML, reply_markup=None)
|
|
|
|
async def _edit_md_quiet(self, query, text_md: str) -> None:
|
|
"""MarkdownV2 edit with the keyboard removed; failures ignored (non-fatal)."""
|
|
with contextlib.suppress(Exception):
|
|
await query.edit_message_text(text=self.format_message(text_md), parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None)
|
|
|
|
async def _handle_inline_query(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
|
|
"""Answer ``@botname <query>`` with a searchable command/skill picker (the ``/`` menu is capped at
|
|
60 slots). Results are computed per keystroke, 50 per page; tapping sends ``/cmd`` text as the
|
|
user, so dispatch flows through the normal command path. Inline queries arrive from ANY chat, so
|
|
unauthorized users get an empty list (the skill catalog is not leaked)."""
|
|
inline_query = getattr(update, "inline_query", None)
|
|
if inline_query is None:
|
|
return
|
|
from_user = getattr(inline_query, "from_user", None)
|
|
user_id = str(getattr(from_user, "id", "") or "").strip()
|
|
try:
|
|
# No chat context on inline queries — authorize on user identity alone, DM-shaped.
|
|
authorized = bool(user_id) and self._is_callback_user_authorized(
|
|
user_id, chat_id=user_id, chat_type="private", user_name=getattr(from_user, "username", None))
|
|
except Exception:
|
|
logger.debug("[%s] inline picker auth check failed", self.name, exc_info=True)
|
|
authorized = False
|
|
if not authorized:
|
|
try:
|
|
from plugins.platforms.telegram.inline_picker import CACHE_TIME_SECONDS as _deny_cache
|
|
await inline_query.answer([], cache_time=_deny_cache, is_personal=True)
|
|
except Exception:
|
|
logger.debug("[%s] inline picker empty answer failed", self.name, exc_info=True)
|
|
return
|
|
try:
|
|
from telegram import InlineQueryResultArticle, InputTextMessageContent
|
|
from plugins.platforms.telegram.inline_picker import CACHE_TIME_SECONDS as _CACHE, build_inline_results
|
|
results, next_offset = build_inline_results(
|
|
getattr(inline_query, "query", "") or "", offset=getattr(inline_query, "offset", "") or "")
|
|
articles = [
|
|
InlineQueryResultArticle(
|
|
id=r["id"], title=r["title"], description=r["description"],
|
|
input_message_content=InputTextMessageContent(r["message_text"]))
|
|
for r in results
|
|
]
|
|
# is_personal: catalogs differ per user (auth, disabled skills) — never share cached pages.
|
|
await inline_query.answer(articles, cache_time=_CACHE, is_personal=True, next_offset=next_offset)
|
|
except Exception:
|
|
logger.debug("[%s] inline picker answer failed", self.name, exc_info=True)
|
|
|
|
@staticmethod
|
|
def _callback_ctx(query) -> Dict[str, Any]:
|
|
"""Chat/thread/user context of a button tap, for the callback auth gate."""
|
|
query_message = getattr(query, "message", None)
|
|
query_chat = getattr(query_message, "chat", None)
|
|
return {
|
|
"chat_id": getattr(query_message, "chat_id", None), "chat_type": getattr(query_chat, "type", None),
|
|
"thread_id": getattr(query_message, "message_thread_id", None), "user_name": getattr(query.from_user, "first_name", None)}
|
|
|
|
async def _callback_authorized(self, query, cb: Dict[str, Any], denial_text: str) -> bool:
|
|
"""Gate a button tap on the callback allowlist; answers ``denial_text`` when refused."""
|
|
if self._is_callback_user_authorized(
|
|
str(getattr(query.from_user, "id", "")), chat_id=cb["chat_id"],
|
|
chat_type=str(cb["chat_type"]) if cb["chat_type"] is not None else None,
|
|
thread_id=str(cb["thread_id"]) if cb["thread_id"] is not None else None, user_name=cb["user_name"]):
|
|
return True
|
|
await query.answer(text=denial_text)
|
|
return False
|
|
|
|
async def _handle_callback_query(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
|
|
"""Dispatch inline keyboard button clicks on the callback_data prefix."""
|
|
query = update.callback_query
|
|
if not query or not query.data:
|
|
return
|
|
data = query.data
|
|
cb = self._callback_ctx(query)
|
|
# Model picker / generic choice picker (/reasoning, /fast) need a chat id.
|
|
for prefixes, handler in (
|
|
(("mp:", "mpg:", "mpv:", "mm:", "mc:", "mb", "mx", "mg:"), self._handle_model_picker_callback),
|
|
(("cp:",), self._handle_choice_picker_callback)):
|
|
if data.startswith(prefixes):
|
|
chat_id = str(query.message.chat_id) if query.message else None
|
|
if chat_id:
|
|
await handler(query, data, chat_id)
|
|
return
|
|
for prefix, handler in (
|
|
("gt:", self._handle_gmail_triage_callback), ("ea:", self._handle_exec_approval_callback),
|
|
("sc:", self._handle_slash_confirm_callback), ("cl:", self._handle_clarify_callback),
|
|
("update_prompt:", self._handle_update_prompt_callback)):
|
|
if data.startswith(prefix):
|
|
await handler(query, data, cb)
|
|
return
|
|
|
|
async def _claim_callback_state(self, query, cb: Dict[str, Any], state: dict, key, denial: str, resolved: str, *, pop: bool = True):
|
|
"""Auth-gate a button tap, then claim its pending entry; None (after answering) when refused or expired."""
|
|
if not await self._callback_authorized(query, cb, denial):
|
|
return None
|
|
session_key = state.pop(key, None) if pop else state.get(key)
|
|
if not session_key:
|
|
await query.answer(text=resolved)
|
|
return session_key
|
|
|
|
async def _handle_exec_approval_callback(self, query, data: str, cb: Dict[str, Any]) -> None:
|
|
"""``ea:<choice>:<approval_id>`` — resolve a pending exec approval."""
|
|
parts = data.split(":", 2)
|
|
if len(parts) != 3:
|
|
return
|
|
choice = parts[1] # once, session, always, deny
|
|
try:
|
|
approval_id = int(parts[2])
|
|
except (ValueError, IndexError):
|
|
await query.answer(text="Invalid approval data.")
|
|
return
|
|
session_key = await self._claim_callback_state(
|
|
query, cb, self._approval_state, approval_id, "⛔ You are not authorized to approve commands.",
|
|
"This approval has already been resolved.")
|
|
if not session_key:
|
|
return
|
|
user_display = getattr(query.from_user, "first_name", "User")
|
|
# Resolve FIRST (unblocks the agent thread), render after: a tap landing after the wait timed out
|
|
# (count == 0) must NOT claim "Approved" — the command was already denied.
|
|
try:
|
|
# Rendering happens after so the message reflects what actually occurred: a tap that lands after
|
|
# the approval wait timed out (count == 0) must NOT claim "Approved" — the command was already
|
|
# denied and will not run (#63501 regression follow-up: 60s waits made stale taps common).
|
|
from tools.approval import resolve_gateway_approval
|
|
count = resolve_gateway_approval(session_key, choice)
|
|
logger.info(
|
|
"Telegram button resolved %d approval(s) for session %s (choice=%s, user=%s)", count, session_key, choice, user_display)
|
|
except Exception as exc:
|
|
logger.error("Failed to resolve gateway approval from Telegram button: %s", exc)
|
|
count = 0
|
|
if count:
|
|
label_map = {
|
|
"once": "✅ Approved once", "session": "✅ Approved for session", "always": "✅ Approved permanently", "deny": "❌ Denied",
|
|
}
|
|
label = label_map.get(choice, "Resolved")
|
|
edit_text = f"{label} by {user_display}"
|
|
else:
|
|
label = "⌛ Approval expired"
|
|
edit_text = f"{label} — no command was waiting. It already timed out (and was denied) or was resolved elsewhere."
|
|
await query.answer(text=label)
|
|
await self._edit_md_quiet(query, edit_text)
|
|
# Typing was paused when the approval was sent; the text /approve and /deny paths resume it too.
|
|
if count and cb["chat_id"] is not None:
|
|
self.resume_typing_for_chat(str(cb["chat_id"]))
|
|
|
|
async def _handle_slash_confirm_callback(self, query, data: str, cb: Dict[str, Any]) -> None:
|
|
"""``sc:<choice>:<confirm_id>`` — resolve a slash-command confirmation."""
|
|
parts = data.split(":", 2)
|
|
if len(parts) != 3:
|
|
return
|
|
choice = parts[1] # once, always, cancel
|
|
confirm_id = parts[2]
|
|
session_key = await self._claim_callback_state(
|
|
query, cb, self._slash_confirm_state, confirm_id, "⛔ You are not authorized to answer this prompt.",
|
|
"This prompt has already been resolved.")
|
|
if not session_key:
|
|
return
|
|
label_map = {"once": "✅ Approved once", "always": "🔒 Always approve", "cancel": "❌ Cancelled"}
|
|
user_display = getattr(query.from_user, "first_name", "User")
|
|
label = label_map.get(choice, "Resolved")
|
|
await query.answer(text=label)
|
|
await self._edit_md_quiet(query, f"{label} by {user_display}")
|
|
# The runner stored a handler keyed by session_key; run it and send any returned text as a follow-up.
|
|
try:
|
|
from tools import slash_confirm as _slash_confirm_mod
|
|
result_text = await _slash_confirm_mod.resolve(session_key, confirm_id, choice)
|
|
if result_text and query.message:
|
|
# Inherit the prompt's topic: forums use message_thread_id; private DM-topic lanes need
|
|
# both the topic id and the prompt reply anchor.
|
|
thread_id = getattr(query.message, "message_thread_id", None)
|
|
chat_type = getattr(getattr(query.message, "chat", None), "type", None)
|
|
prompt_message_id = getattr(query.message, "message_id", None)
|
|
send_kwargs: Dict[str, Any] = {
|
|
"chat_id": int(query.message.chat_id), "text": self.format_message(result_text),
|
|
"parse_mode": ParseMode.MARKDOWN_V2, **self._link_preview_kwargs()}
|
|
is_private_chat = str(getattr(chat_type, "value", chat_type)).lower() in {
|
|
"private", str(ChatType.PRIVATE).lower(), str(getattr(ChatType.PRIVATE, "value", ChatType.PRIVATE)).lower()}
|
|
if thread_id is not None:
|
|
meta: Dict[str, Any] = {"thread_id": str(thread_id)}
|
|
reply_to_id = None
|
|
if is_private_chat and prompt_message_id is not None:
|
|
reply_to_id = send_kwargs["reply_to_message_id"] = int(prompt_message_id)
|
|
meta["telegram_dm_topic_reply_fallback"] = True
|
|
send_kwargs.update(self._thread_kwargs_for_send(
|
|
str(
|
|
query.message.chat_id
|
|
), str(thread_id), meta, reply_to_message_id=reply_to_id, reply_to_mode=self._reply_to_mode))
|
|
await self._send_message_with_thread_fallback(**send_kwargs)
|
|
except Exception as exc:
|
|
logger.error("[%s] slash-confirm callback failed: %s", self.name, exc, exc_info=True)
|
|
|
|
async def _handle_clarify_callback(self, query, data: str, cb: Dict[str, Any]) -> None:
|
|
"""``cl:<clarify_id>:<idx|other>`` — resolve a clarify prompt or flip to text capture."""
|
|
parts = data.split(":", 2)
|
|
if len(parts) != 3:
|
|
return
|
|
clarify_id = parts[1]
|
|
choice_token = parts[2]
|
|
session_key = await self._claim_callback_state(
|
|
query, cb, self._clarify_state, clarify_id, "⛔ You are not authorized to answer this prompt.",
|
|
"This prompt has already been resolved.", pop=False)
|
|
if not session_key:
|
|
return
|
|
user_display = getattr(query.from_user, "first_name", "User")
|
|
if choice_token == "other":
|
|
# Flip to text-capture: the gateway's text-intercept resolves the clarify with the next message.
|
|
# Do NOT pop _clarify_state yet — still needed if the entry gets cleared by something else.
|
|
flipped = False
|
|
try:
|
|
from tools.clarify_gateway import mark_awaiting_text
|
|
flipped = mark_awaiting_text(clarify_id)
|
|
except Exception as exc:
|
|
logger.warning("[%s] mark_awaiting_text failed: %s", self.name, exc)
|
|
if not flipped:
|
|
# Entry evicted / gateway restarted — a typed answer would go nowhere.
|
|
self._clarify_state.pop(clarify_id, None)
|
|
await self._notify_clarify_expired(query, user_display)
|
|
return
|
|
await query.answer(text="✏️ Type your answer in the chat.")
|
|
await self._edit_html_quiet(
|
|
query, f"❓ {query.message.text or ''}\n\n<i>Awaiting typed response from {_html.escape(user_display)}…</i>")
|
|
return
|
|
# Numeric choice → resolve immediately with the chosen text
|
|
try:
|
|
idx = int(choice_token)
|
|
except (ValueError, TypeError):
|
|
await query.answer(text="Invalid choice.")
|
|
return
|
|
resolved_text: Optional[str] = None
|
|
try:
|
|
from tools.clarify_gateway import _entries as _clarify_entries # type: ignore
|
|
entry = _clarify_entries.get(clarify_id)
|
|
if entry and entry.choices and 0 <= idx < len(entry.choices):
|
|
resolved_text = entry.choices[idx]
|
|
except Exception:
|
|
resolved_text = None
|
|
if resolved_text is None:
|
|
# Race (timeout / session reset): echo the index so the agent sees an intentional response.
|
|
resolved_text = f"choice {idx + 1}"
|
|
self._clarify_state.pop(clarify_id, None)
|
|
try:
|
|
from tools.clarify_gateway import resolve_gateway_clarify
|
|
resolved = resolve_gateway_clarify(clarify_id, resolved_text)
|
|
except Exception as exc:
|
|
logger.error("[%s] resolve_gateway_clarify failed: %s", self.name, exc)
|
|
resolved = False
|
|
if resolved:
|
|
await query.answer(text=f"✓ {resolved_text[:60]}")
|
|
await self._edit_html_quiet(
|
|
query, f"❓ {_html.escape(query.message.text or '')}\n\n<b>{_html.escape(user_display)}:</b> {_html.escape(resolved_text)}")
|
|
logger.info("Telegram clarify button resolved (id=%s, choice=%r, user=%s)", clarify_id, resolved_text, user_display)
|
|
else:
|
|
# Entry evicted / gateway restarted between ask and tap.
|
|
await self._notify_clarify_expired(query, user_display)
|
|
logger.warning("Telegram clarify button: resolve_gateway_clarify returned False (id=%s)", clarify_id)
|
|
|
|
async def _handle_update_prompt_callback(self, query, data: str, cb: Dict[str, Any]) -> None:
|
|
"""``update_prompt:<y|n>`` — forward the answer to the update process."""
|
|
answer = data.split(":", 1)[1] # "y" or "n"
|
|
if not await self._callback_authorized(query, cb, "⛔ You are not authorized to answer update prompts."):
|
|
return
|
|
await query.answer(text=f"Sent '{answer}' to the update process.")
|
|
await self._edit_md_quiet(query, f"☤ Update prompt answered: *{'Yes' if answer == 'y' else 'No'}*")
|
|
try:
|
|
from hermes_constants import get_hermes_home
|
|
response_path = get_hermes_home() / ".update_response"
|
|
tmp = response_path.with_suffix(".tmp")
|
|
tmp.write_text(answer, encoding="utf-8")
|
|
tmp.replace(response_path)
|
|
logger.info("Telegram update prompt answered '%s' by user %s", answer, getattr(query.from_user, "id", "unknown"))
|
|
except Exception as exc:
|
|
logger.error("Failed to write update response from callback: %s", exc)
|
|
|
|
# `gt:<verb>` -> (script in ~/.hermes/scripts/gmail-triage/, extra-args, success-label, is_state). The callback
|
|
# `arg` is always the first positional arg. is_state=True keeps the keyboard tappable (sticky sender rule);
|
|
# False strips it on success (per-email one-shot).
|
|
_GT_VERB_DISPATCH = {
|
|
"send": ("send-draft.sh", [], "✓ sent draft", False),
|
|
"archive": ("archive.sh", [], "✓ archived", False),
|
|
"draft": ("draft-blank.sh", [], "✓ drafted reply", False),
|
|
"spam": ("spam.sh", [], "✓ marked spam", False),
|
|
"mute": ("mute-add.sh", ["email"], "✓ muted", True),
|
|
"mute-domain": ("mute-add.sh", ["domain"], "✓ muted domain", True),
|
|
"trust": ("trusted-ops-add.sh", ["email"], "✓ trusted", True),
|
|
"trust-domain": ("trusted-ops-add.sh", ["domain"], "✓ trusted domain", True),
|
|
"vip": ("vip-add.sh", ["email"], "✓ marked VIP", True),
|
|
"vip-domain": ("vip-add.sh", ["domain"], "✓ marked VIP domain", True)}
|
|
|
|
async def _handle_gmail_triage_callback(self, query, data: str, cb: Dict[str, Any]) -> None:
|
|
"""Dispatch a gmail-triage inline-button callback (gt:verb:arg)."""
|
|
parts = data.split(":", 2)
|
|
if len(parts) != 3:
|
|
await query.answer(text="Invalid gmail-triage data.")
|
|
return
|
|
verb, arg = parts[1], parts[2]
|
|
if not await self._callback_authorized(query, cb, "⛔ You are not authorized to act on this email."):
|
|
return
|
|
entry = self._GT_VERB_DISPATCH.get(verb)
|
|
if not entry:
|
|
await query.answer(text=f"Unknown verb: {verb}")
|
|
return
|
|
script_name, extra_args, success_label, is_state_verb = entry
|
|
from hermes_constants import get_hermes_home
|
|
script_path = get_hermes_home() / "scripts" / "gmail-triage" / script_name
|
|
if not script_path.exists():
|
|
await query.answer(text=f"❌ {script_name} missing")
|
|
logger.error("[%s] gmail-triage script missing: %s", self.name, script_path)
|
|
return
|
|
success = False
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
str(script_path), arg, *extra_args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
|
_stdout_bytes, stderr_bytes = await asyncio.wait_for(proc.communicate(), timeout=60)
|
|
if proc.returncode == 0:
|
|
label = success_label
|
|
success = True
|
|
logger.info("[%s] gmail-triage callback ok: verb=%s arg=%s", self.name, verb, arg)
|
|
else:
|
|
stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
|
|
last_line = stderr_text.splitlines()[-1] if stderr_text else f"exit {proc.returncode}"
|
|
label = f"❌ {verb} failed: {last_line[:80]}"
|
|
logger.error(
|
|
"[%s] gmail-triage callback failed: verb=%s arg=%s rc=%s stderr=%s", self.name, verb, arg, proc.returncode, stderr_text)
|
|
except asyncio.TimeoutError:
|
|
label = f"❌ {verb} timed out"
|
|
logger.error("[%s] gmail-triage callback timed out: verb=%s arg=%s", self.name, verb, arg)
|
|
except Exception as exc:
|
|
label = f"❌ {verb} error: {exc}"
|
|
logger.error("[%s] gmail-triage callback exception: verb=%s arg=%s err=%s", self.name, verb, arg, exc, exc_info=True)
|
|
await query.answer(text=label)
|
|
if not success:
|
|
return
|
|
original_text = (query.message.text or "") if query.message else ""
|
|
appended = f"{original_text}\n— {label} by {getattr(query.from_user, 'first_name', 'User')}"
|
|
# Sticky state verbs keep the keyboard so further actions can stack; one-shots strip it (can't fire twice).
|
|
with contextlib.suppress(Exception):
|
|
await query.edit_message_text(text=appended, **({} if is_state_verb else {"reply_markup": None}))
|
|
|
|
def _missing_media_path_error(self, label: str, path: str) -> str:
|
|
"""File-not-found error for MEDIA delivery; /workspace-style paths often exist only in the sandbox."""
|
|
error = f"{label} file not found: {path}"
|
|
if path.startswith(("/workspace/", "/output/", "/outputs/")):
|
|
error += (
|
|
" (path may only exist inside the Docker sandbox. "
|
|
"Bind-mount a host directory and emit the host-visible path in MEDIA: for gateway file delivery.)")
|
|
return error
|
|
|
|
def _telegram_media_too_large_note(self, label: str, file_size: Any, max_bytes: int) -> str:
|
|
limit_mb = max(1, max_bytes // (1024 * 1024))
|
|
try:
|
|
size_text = f"{int(file_size or 0) / (1024 * 1024):.1f} MB"
|
|
except (TypeError, ValueError):
|
|
size_text = "unknown size"
|
|
return f"[Telegram {label} skipped: file size {size_text} exceeds the {limit_mb} MB limit. Ask the user to send a smaller file.]"
|
|
|
|
@staticmethod
|
|
def _int_or_zero(value: Any) -> int:
|
|
try:
|
|
return int(value or 0)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
def _telegram_media_size_allowed(self, source: Any, label: str) -> tuple[bool, Optional[str]]:
|
|
"""Validate Telegram media size before downloading into memory."""
|
|
max_bytes = int(getattr(self, "_max_doc_bytes", 20 * 1024 * 1024) or 20 * 1024 * 1024)
|
|
size = self._int_or_zero(getattr(source, "file_size", None))
|
|
if size <= 0 or size <= max_bytes:
|
|
return True, None
|
|
return False, self._telegram_media_too_large_note(label, size, max_bytes)
|
|
|
|
def _media_send_kwargs(
|
|
self, chat_id: str, reply_to: Optional[str], metadata: Optional[Dict[str, Any]]) -> tuple[Optional[int], Dict[str, Any]]:
|
|
"""Return ``(reply_to_id, base_kwargs)`` shared by every native media send."""
|
|
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
|
|
thread_kwargs = self._thread_kwargs_for_send(
|
|
chat_id, self._metadata_thread_id(metadata), metadata, reply_to_message_id=reply_to_id, reply_to_mode=self._reply_to_mode)
|
|
return reply_to_id, {
|
|
"chat_id": normalize_telegram_chat_id(chat_id), "reply_to_message_id": reply_to_id,
|
|
"read_timeout": _MEDIA_SEND_READ_TIMEOUT, **thread_kwargs, **self._notification_kwargs(metadata)}
|
|
|
|
async def _send_media(
|
|
self, send_fn: Any, chat_id: str, reply_to: Optional[str], metadata: Optional[Dict[str, Any]], media_label: str,
|
|
reset_media: Optional[Any] = None, **media_kwargs: Any) -> Any:
|
|
"""Send one native media payload with thread routing + DM-topic anchor retry."""
|
|
reply_to_id, kwargs = self._media_send_kwargs(chat_id, reply_to, metadata)
|
|
return await self._send_with_dm_topic_reply_anchor_retry(
|
|
send_fn, {**kwargs, **media_kwargs}, metadata, reply_to_id, media_label, reset_media=reset_media)
|
|
|
|
@staticmethod
|
|
def _caption_1024(caption: Optional[str]) -> Optional[str]:
|
|
return caption[:1024] if caption else None
|
|
|
|
async def _send_voice_bubble(self, audio_file, chat_id, reply_to, metadata, caption, duration_secs):
|
|
"""sendVoice with caption variants: MarkdownV2 when it fits 1024 chars, plain fallback when the
|
|
Bot API rejects the entities; anything else is a real error."""
|
|
# Render caption markdown (#32029): auto-TTS captions carry the agent's markdown reply, which showed
|
|
# literal *asterisks* and [links](...) without a parse_mode. Format to MarkdownV2 when it fits the
|
|
# 1024-char caption cap; fall back to the raw text (previous behaviour) when formatting would
|
|
# overflow or the Bot API rejects the entities.
|
|
_caption_variants: List[tuple] = []
|
|
if caption:
|
|
try:
|
|
_formatted_caption = self.format_message(caption)
|
|
if utf16_len(_formatted_caption) <= 1024:
|
|
_caption_variants.append((_formatted_caption, ParseMode.MARKDOWN_V2))
|
|
except Exception:
|
|
logger.debug("[%s] voice caption MarkdownV2 formatting failed; sending plain caption", self.name, exc_info=True)
|
|
_caption_variants.append((caption[:1024], None))
|
|
else:
|
|
_caption_variants.append((None, None))
|
|
_last_parse_error: Optional[Exception] = None
|
|
for _cap_text, _cap_parse_mode in _caption_variants:
|
|
try:
|
|
return await self._send_media(
|
|
self._bot.send_voice, chat_id, reply_to, metadata, "voice", reset_media=lambda: audio_file.seek(0),
|
|
voice=audio_file, caption=_cap_text, parse_mode=_cap_parse_mode, duration=duration_secs)
|
|
except Exception as _cap_error:
|
|
err = str(_cap_error).lower()
|
|
if _cap_parse_mode is not None and ("parse" in err or "entit" in err):
|
|
logger.warning(
|
|
"[%s] voice caption MarkdownV2 rejected, retrying plain: %s", self.name, _redact_telegram_error_text(_cap_error))
|
|
_last_parse_error = _cap_error
|
|
audio_file.seek(0)
|
|
continue
|
|
raise
|
|
raise _last_parse_error or RuntimeError("Telegram send_voice failed for all caption variants")
|
|
|
|
async def send_voice(
|
|
self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
|
|
"""Send audio as a native Telegram voice message or audio file."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
_transcoded_voice_path: Optional[str] = None
|
|
try:
|
|
if not os.path.exists(audio_path):
|
|
return SendResult(success=False, error=self._missing_media_path_error("Audio", audio_path))
|
|
# sendVoice only accepts Ogg/Opus: an explicit voice-bubble request (is_voice) transcodes via
|
|
# ffmpeg; otherwise route by extension (.mp3/.m4a → sendAudio, others → document).
|
|
if kwargs.get("is_voice") and os.path.splitext(audio_path)[1].lower() not in (".ogg", ".opus"):
|
|
from gateway.platforms.base import transcode_to_ogg_opus
|
|
_transcoded_voice_path = await asyncio.to_thread(transcode_to_ogg_opus, audio_path)
|
|
if _transcoded_voice_path:
|
|
audio_path = _transcoded_voice_path
|
|
else:
|
|
logger.warning(
|
|
"[%s] voice transcode unavailable for %s — sending original format (install ffmpeg for voice bubbles)",
|
|
self.name, os.path.basename(audio_path))
|
|
# Telegram drops duration for long clips (~5 min+, shows 0:00).
|
|
_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()
|
|
if ext in {".ogg", ".opus"}: # round playable voice bubble
|
|
msg = await self._send_voice_bubble(audio_file, chat_id, reply_to, metadata, caption, _duration_secs)
|
|
elif ext in {".mp3", ".m4a"}: # Bot API sendAudio only accepts MP3 / M4A
|
|
msg = await self._send_media(
|
|
self._bot.send_audio, chat_id, reply_to, metadata, "audio", reset_media=lambda: audio_file.seek(0),
|
|
audio=audio_file, caption=self._caption_1024(caption), duration=_duration_secs)
|
|
else: # formats Telegram can't play natively (.wav, .flac, ...)
|
|
return await self.send_document(
|
|
chat_id=chat_id, file_path=audio_path, caption=caption, reply_to=reply_to, metadata=metadata)
|
|
return SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e:
|
|
logger.error(
|
|
"[%s] Failed to send Telegram voice/audio, falling back to base adapter: %s", self.name,
|
|
_redact_telegram_error_text(e), exc_info=True)
|
|
return await super().send_voice(chat_id, audio_path, caption, reply_to, metadata=metadata)
|
|
finally:
|
|
if _transcoded_voice_path:
|
|
with contextlib.suppress(OSError):
|
|
os.unlink(_transcoded_voice_path)
|
|
|
|
async def send_multiple_images(
|
|
self, chat_id: str, images: List[tuple], metadata: Optional[Dict[str, Any]] = None, human_delay: float = 0.0) -> SendResult:
|
|
"""Send images as Telegram albums (``send_media_group``, 10 per chunk). Animated GIFs can't join a
|
|
media group (need ``send_animation``) so they go via the base per-image path, as does a failed chunk."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
if not images:
|
|
return SendResult(success=False, error="no images to send")
|
|
try:
|
|
from telegram import InputMediaPhoto
|
|
except Exception as exc: # pragma: no cover - missing SDK
|
|
logger.warning("[%s] InputMediaPhoto unavailable, falling back to per-image send: %s", self.name, exc)
|
|
return await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
|
is_anim = lambda url: not url.startswith("file://") and self._is_animation_url(url) # noqa: E731
|
|
animations = [img for img in images if is_anim(img[0])]
|
|
photos = [img for img in images if not is_anim(img[0])]
|
|
delivered = False
|
|
if animations:
|
|
anim_result = await super().send_multiple_images(chat_id, animations, metadata, human_delay=human_delay)
|
|
delivered = anim_result.success
|
|
if not photos:
|
|
return SendResult(success=delivered, error=None if delivered else "all images failed to send")
|
|
from urllib.parse import unquote as _unquote
|
|
CHUNK = 10 # Telegram's album limit
|
|
chunks = [photos[i:i + CHUNK] for i in range(0, len(photos), CHUNK)]
|
|
for chunk_idx, chunk in enumerate(chunks):
|
|
if human_delay > 0 and chunk_idx > 0:
|
|
await asyncio.sleep(human_delay)
|
|
media: List[Any] = []
|
|
opened_files: List[Any] = []
|
|
try:
|
|
for image_url, alt_text in chunk:
|
|
source: Any = image_url
|
|
if image_url.startswith("file://"):
|
|
local_path = _unquote(image_url[7:])
|
|
if not os.path.exists(local_path):
|
|
logger.warning("[%s] Skipping missing image in media group: %s", self.name, local_path)
|
|
continue
|
|
source = open(local_path, "rb")
|
|
opened_files.append(source)
|
|
media.append(InputMediaPhoto(media=source, caption=self._caption_1024(alt_text)))
|
|
if not media:
|
|
continue
|
|
logger.info("[%s] Sending media group of %d photo(s) (chunk %d/%d)", self.name, len(media), chunk_idx + 1, len(chunks))
|
|
reply_to_id, send_kwargs = self._media_send_kwargs(chat_id, None, metadata)
|
|
|
|
def _reset_opened_files() -> None:
|
|
for fh in opened_files:
|
|
with contextlib.suppress(Exception):
|
|
fh.seek(0)
|
|
|
|
await self._send_with_dm_topic_reply_anchor_retry(
|
|
self._bot.send_media_group, {**send_kwargs, "media": media}, metadata, reply_to_id,
|
|
"media group", reset_media=_reset_opened_files)
|
|
delivered = True
|
|
except Exception as e:
|
|
logger.warning(
|
|
"[%s] send_media_group failed (chunk %d/%d), falling back to per-image: %s", self.name,
|
|
chunk_idx + 1, len(chunks), _redact_telegram_error_text(e), exc_info=True)
|
|
fallback = await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
|
|
delivered = delivered or fallback.success
|
|
finally:
|
|
for fh in opened_files:
|
|
with contextlib.suppress(Exception):
|
|
fh.close()
|
|
return SendResult(success=delivered, error=None if delivered else "all images failed to send")
|
|
|
|
async def send_image_file(
|
|
self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
|
|
"""Send a local image file natively as a Telegram photo."""
|
|
async def _photo_failed(e: Exception) -> SendResult:
|
|
error_str = str(e)
|
|
# Dimension errors are expected for valid images Telegram refuses as photos → INFO.
|
|
if "Photo_invalid_dimensions" in error_str or "PHOTO_INVALID_DIMENSIONS" in error_str:
|
|
logger.info("[%s] Image dimensions exceed Telegram photo limits, sending as document: %s", self.name, image_path)
|
|
else:
|
|
logger.warning(
|
|
"[%s] Failed to send Telegram local image as photo, trying document fallback: %s", self.name,
|
|
_redact_telegram_error_text(e), exc_info=True)
|
|
# Document has no dimension limit (50MB only); if even that fails, base adapter text.
|
|
try:
|
|
return await self.send_document(
|
|
chat_id=chat_id, file_path=image_path, caption=caption, file_name=os.path.basename(image_path),
|
|
reply_to=reply_to, metadata=metadata)
|
|
except Exception as doc_err:
|
|
logger.error(
|
|
"[%s] Failed to send Telegram local image as document, falling back to base adapter: %s",
|
|
self.name, doc_err, exc_info=True)
|
|
return await super(TelegramAdapter, self).send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata)
|
|
return await self._send_local_file(
|
|
"Image", image_path, chat_id, reply_to, metadata, "photo",
|
|
lambda f: {"photo": f, "caption": self._caption_1024(caption)}, _photo_failed)
|
|
|
|
async def _send_local_file(
|
|
self, label: str, path: str, chat_id, reply_to, metadata, media_key: str, build_kwargs, on_error,
|
|
) -> SendResult:
|
|
"""Shared shell for native local-file sends: existence check, open, send with routing, then
|
|
``await on_error(exc)`` on any failure. ``build_kwargs(f)`` supplies the media kwargs."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
try:
|
|
if not os.path.exists(path):
|
|
return SendResult(success=False, error=self._missing_media_path_error(label, path))
|
|
with open(path, "rb") as f:
|
|
msg = await self._send_media(
|
|
getattr(self._bot, f"send_{media_key}"), chat_id, reply_to, metadata, media_key,
|
|
reset_media=lambda: f.seek(0), **build_kwargs(f))
|
|
return SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e:
|
|
return await on_error(e)
|
|
|
|
async def _warn_then(self, media_key: str, e: Exception, fallback) -> SendResult:
|
|
logger.warning("[%s] Failed to send %s: %s", self.name, media_key, _redact_telegram_error_text(e))
|
|
return await fallback
|
|
|
|
async def send_document(
|
|
self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None,
|
|
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
|
|
"""Send a document/file natively as a Telegram file attachment."""
|
|
return await self._send_local_file(
|
|
"File", file_path, chat_id, reply_to, metadata, "document",
|
|
lambda f: {"document": f, "filename": file_name or os.path.basename(file_path), "caption": self._caption_1024(caption)},
|
|
lambda e: self._warn_then(
|
|
"document", e, super(
|
|
TelegramAdapter, self,
|
|
).send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata)))
|
|
|
|
async def send_video(
|
|
self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
|
|
"""Send a video natively as a Telegram video message."""
|
|
return await self._send_local_file(
|
|
"Video", video_path, chat_id, reply_to, metadata, "video",
|
|
lambda f: {"video": f, "caption": self._caption_1024(caption)},
|
|
lambda e: self._warn_then(
|
|
"video", e, super(TelegramAdapter, self).send_video(chat_id, video_path, caption, reply_to, metadata=metadata),
|
|
))
|
|
|
|
async def send_image(
|
|
self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
"""Send a URL image as a Telegram photo: URL send (<5MB) → download+upload (≤10MB) → base text."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
from tools.url_safety import is_safe_url
|
|
if not is_safe_url(image_url):
|
|
logger.warning("[%s] Blocked unsafe image URL (SSRF protection)", self.name)
|
|
return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)
|
|
photo_caption = self._caption_1024(caption)
|
|
try:
|
|
msg = await self._send_media(
|
|
self._bot.send_photo, chat_id, reply_to, metadata, "URL photo", photo=image_url, caption=photo_caption)
|
|
return SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e:
|
|
logger.warning(
|
|
"[%s] URL-based send_photo failed, trying file upload: %s", self.name, _redact_telegram_error_text(e), exc_info=True)
|
|
try:
|
|
from gateway.platforms.base import _ssrf_redirect_guard
|
|
from tools.url_safety import create_ssrf_safe_async_client
|
|
async with create_ssrf_safe_async_client(timeout=30.0, event_hooks={"response": [_ssrf_redirect_guard]}) as client:
|
|
resp = await client.get(image_url)
|
|
resp.raise_for_status()
|
|
image_data = resp.content
|
|
msg = await self._send_media(
|
|
self._bot.send_photo, chat_id, reply_to, metadata, "uploaded photo", photo=image_data, caption=photo_caption)
|
|
return SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e2:
|
|
logger.error("[%s] File upload send_photo also failed: %s", self.name, e2, exc_info=True)
|
|
return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)
|
|
|
|
async def send_animation(
|
|
self, chat_id: str, animation_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
"""Send an animated GIF natively as a Telegram animation (auto-plays inline)."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
try:
|
|
msg = await self._send_media(
|
|
self._bot.send_animation, chat_id, reply_to, metadata, "animation", animation=animation_url,
|
|
caption=self._caption_1024(caption))
|
|
return SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e:
|
|
logger.error(
|
|
"[%s] Failed to send Telegram animation, falling back to photo: %s", self.name,
|
|
_redact_telegram_error_text(e), exc_info=True)
|
|
return await self.send_image(chat_id, animation_url, caption, reply_to, metadata=metadata)
|
|
|
|
@staticmethod
|
|
def _is_transient_typing_error(exc: Exception) -> bool:
|
|
"""Return True for Telegram typing errors worth cooling down."""
|
|
if getattr(exc, "retry_after", None) is not None:
|
|
return True
|
|
status_code = getattr(exc, "status_code", None) or getattr(exc, "code", None)
|
|
if isinstance(status_code, int) and (status_code == 429 or status_code >= 500):
|
|
return True
|
|
text = str(exc).lower()
|
|
if any(marker in text for marker in ("too many requests", "rate limit", "timed out", "timeout", "temporar")):
|
|
return True
|
|
return isinstance(exc, (OSError, TimeoutError, ConnectionError, asyncio.TimeoutError))
|
|
|
|
def _record_typing_cooldown(self, chat_id: str, exc: Exception) -> None:
|
|
"""Suppress Telegram typing refreshes for this chat after transient failures."""
|
|
if not hasattr(self, "_telegram_typing_cooldown_until"):
|
|
self._telegram_typing_cooldown_until = {}
|
|
retry_after = getattr(exc, "retry_after", None)
|
|
try:
|
|
delay = float(retry_after) if retry_after is not None else self._telegram_typing_cooldown_seconds
|
|
except (TypeError, ValueError):
|
|
delay = self._telegram_typing_cooldown_seconds
|
|
self._telegram_typing_cooldown_until[str(chat_id)] = asyncio.get_running_loop().time() + max(1.0, min(delay, 300.0))
|
|
|
|
def _typing_in_cooldown(self, chat_id: str) -> bool:
|
|
if not hasattr(self, "_telegram_typing_cooldown_until"):
|
|
self._telegram_typing_cooldown_until = {}
|
|
self._telegram_typing_cooldown_seconds = 30.0
|
|
until = self._telegram_typing_cooldown_until.get(str(chat_id))
|
|
if until is None:
|
|
return False
|
|
if asyncio.get_running_loop().time() < until:
|
|
return True
|
|
self._telegram_typing_cooldown_until.pop(str(chat_id), None)
|
|
return False
|
|
|
|
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
|
"""Send typing indicator."""
|
|
if not self._bot or self._typing_in_cooldown(chat_id):
|
|
return
|
|
_is_dm_topic: bool = False
|
|
message_thread_id: Optional[int] = None
|
|
|
|
async def _action(**kw) -> None:
|
|
await self._bot.send_chat_action(chat_id=normalize_telegram_chat_id(chat_id), action="typing", **kw)
|
|
self._telegram_typing_cooldown_until.pop(str(chat_id), None)
|
|
try:
|
|
_is_dm_topic = self._dm_topic_fallback(metadata)
|
|
message_thread_id = self._message_thread_id_for_typing(self._metadata_thread_id(metadata))
|
|
await _action(message_thread_id=message_thread_id)
|
|
except Exception as e:
|
|
# DM topic lanes: Telegram may reject message_thread_id — retry without it so the indicator at
|
|
# least appears in the main DM view.
|
|
if _is_dm_topic and message_thread_id is not None:
|
|
try:
|
|
await _action()
|
|
return
|
|
except Exception as fallback_exc:
|
|
if self._is_transient_typing_error(fallback_exc):
|
|
self._record_typing_cooldown(chat_id, fallback_exc)
|
|
elif self._is_transient_typing_error(e):
|
|
self._record_typing_cooldown(chat_id, e)
|
|
logger.debug("[%s] Failed to send Telegram typing indicator: %s", self.name, _redact_telegram_error_text(e), exc_info=True)
|
|
|
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
"""Get information about a Telegram chat."""
|
|
if not self._bot:
|
|
return {"name": "Unknown", "type": "dm"}
|
|
try:
|
|
chat = await self._bot.get_chat(normalize_telegram_chat_id(chat_id))
|
|
chat_type = "dm"
|
|
if chat.type == ChatType.GROUP:
|
|
chat_type = "group"
|
|
elif chat.type == ChatType.SUPERGROUP:
|
|
chat_type = "forum" if chat.is_forum else "group"
|
|
elif chat.type == ChatType.CHANNEL:
|
|
chat_type = "channel"
|
|
return {
|
|
"name": chat.title or chat.full_name or str(chat_id), "type": chat_type, "username": chat.username,
|
|
"is_forum": getattr(chat, "is_forum", False)}
|
|
except Exception as e:
|
|
logger.error("[%s] Failed to get Telegram chat info for %s: %s", self.name, chat_id, _redact_telegram_error_text(
|
|
e), exc_info=True)
|
|
return {"name": str(chat_id), "type": "dm", "error": str(e)}
|
|
|
|
def format_message(self, content: str) -> str:
|
|
"""Convert standard markdown to Telegram MarkdownV2: code is stashed behind placeholders first (never
|
|
modified), markdown constructs become MarkdownV2 syntax, everything else is escaped."""
|
|
if not content:
|
|
return content
|
|
placeholders: dict = {}
|
|
counter = [0]
|
|
|
|
def _ph(value: str) -> str:
|
|
"""Stash *value* behind a placeholder token that survives escaping."""
|
|
key = f"\x00PH{counter[0]}\x00"
|
|
counter[0] += 1
|
|
placeholders[key] = value
|
|
return key
|
|
|
|
def _ph_wrap(open_: str, close: str):
|
|
return lambda m: _ph(f"{open_}{_escape_mdv2(m.group(1))}{close}")
|
|
|
|
# 0) GFM pipe tables → Telegram-friendly row groups, before the MarkdownV2 conversions.
|
|
text = _wrap_markdown_tables(content)
|
|
# 1) Protect fenced code blocks; per MarkdownV2 spec \ and ` inside pre/code must be escaped.
|
|
def _protect_fenced(m):
|
|
raw = m.group(0)
|
|
open_end = raw.index('\n') + 1 if '\n' in raw[3:] else 3 # opening ``` (+ optional language)
|
|
body = raw[open_end:][:-3].replace('\\', '\\\\').replace('`', '\\`')
|
|
return _ph(raw[:open_end] + body + '```')
|
|
|
|
text = re.sub(r'(```(?:[^\n]*\n)?[\s\S]*?```)', _protect_fenced, text)
|
|
# 2) Protect inline code; escape \ inside it per MarkdownV2 spec.
|
|
text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0).replace('\\', '\\\\')), text)
|
|
# 3) Links: escape display text; inside the URL only ')' and '\' need escaping.
|
|
def _convert_link(m):
|
|
url = m.group(2).replace('\\', '\\\\').replace(')', '\\)')
|
|
return _ph(f'[{_escape_mdv2(m.group(1))}]({url})')
|
|
|
|
text = re.sub(r'\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)', _convert_link, text)
|
|
# 4) Headers (## Title) → bold *Title*, stripping redundant ** inside the header
|
|
def _convert_header(m):
|
|
inner = re.sub(r'\*\*(.+?)\*\*', r'\1', m.group(1).strip())
|
|
return _ph(f'*{_escape_mdv2(inner)}*')
|
|
|
|
text = re.sub(r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE)
|
|
# 5) Bold **text** → *text*; 6) Italic *text* → _text_ ([^*\n]+ keeps matches on one line, or *
|
|
# bullet lists corrupt); 7) Strikethrough ~~text~~ → ~text~; 8) Spoiler ||text|| kept as-is.
|
|
text = re.sub(r'\*\*(.+?)\*\*', _ph_wrap('*', '*'), text)
|
|
text = re.sub(r'\*([^*\n]+)\*', _ph_wrap('_', '_'), text)
|
|
text = re.sub(r'~~(.+?)~~', _ph_wrap('~', '~'), text)
|
|
text = re.sub(r'\|\|(.+?)\|\|', _ph_wrap('||', '||'), text)
|
|
# 9) Blockquotes: protect leading > from escaping; expandable quotes (**> starts, trailing || ends).
|
|
def _convert_blockquote(m):
|
|
prefix, content = m.group(1), m.group(2) # prefix: >, >>, >>>, **>, **>> …
|
|
if prefix.startswith('**') and content.endswith('||'):
|
|
return _ph(f'{prefix} {_escape_mdv2(content[:-2])}||')
|
|
return _ph(f'{prefix} {_escape_mdv2(content)}')
|
|
|
|
text = re.sub(r'^((?:\*\*)?>{1,3}) (.+)$', _convert_blockquote, text, flags=re.MULTILINE)
|
|
# 10) Escape remaining special characters in plain text
|
|
text = _escape_mdv2(text)
|
|
# 11) Restore placeholders in reverse insertion order so nested placeholders resolve.
|
|
for key in reversed(list(placeholders.keys())):
|
|
text = text.replace(key, placeholders[key])
|
|
# 12) Safety net: escape bare ( ) { } that slipped through, but never inside ``` or ` spans.
|
|
_safe_parts = []
|
|
for _idx, _seg in enumerate(re.split(r'(```[\s\S]*?```|`[^`]+`)', text)):
|
|
if _idx % 2 == 1:
|
|
_safe_parts.append(_seg) # inside code — untouched
|
|
else:
|
|
_safe_parts.append(re.sub(r'[(){}]', lambda m, _seg=_seg: TelegramAdapter._escape_bare_bracket(m, _seg), _seg))
|
|
return ''.join(_safe_parts)
|
|
|
|
@staticmethod
|
|
def _escape_bare_bracket(m, seg: str) -> str:
|
|
"""Escape a bare ( ) { } unless it is already escaped or delimits a ``[text](url)`` link."""
|
|
s = m.start()
|
|
ch = m.group(0)
|
|
if s > 0 and seg[s - 1] == '\\': # already escaped
|
|
return ch
|
|
if ch == '(' and s > 0 and seg[s - 1] == ']': # opens a link [text](url)
|
|
return ch
|
|
if ch == ')': # closes a link URL? walk back matching depth
|
|
before = seg[:s]
|
|
if '](http' in before or '](' in before:
|
|
depth = 0
|
|
for j in range(s - 1, max(s - 2000, -1), -1):
|
|
if seg[j] == '(':
|
|
depth -= 1
|
|
if depth < 0:
|
|
if j > 0 and seg[j - 1] == ']':
|
|
return ch
|
|
break
|
|
elif seg[j] == ')':
|
|
depth += 1
|
|
return '\\' + ch
|
|
|
|
# ── Group mention gating ──────────────────────────────────────────────
|
|
|
|
def _extra_bool(self, key: str, env_name: str, default: str, *fallback_keys: str) -> bool:
|
|
"""Boolean gate from ``config.extra[key]`` (then ``fallback_keys``), else env var."""
|
|
configured = self.config.extra.get(key)
|
|
for alt in fallback_keys:
|
|
if configured is None:
|
|
configured = self.config.extra.get(alt)
|
|
if configured is not None:
|
|
if isinstance(configured, str):
|
|
return configured.lower() in {"true", "1", "yes", "on"}
|
|
return bool(configured)
|
|
return _scoped_gate_env(env_name, default).lower() in {"true", "1", "yes", "on"}
|
|
|
|
def _extra_str_set(self, key: str, env_name: str) -> set[str]:
|
|
"""Comma/list allowlist from ``config.extra[key]``, else the profile-scoped env var."""
|
|
raw = self.config.extra.get(key)
|
|
if raw is None:
|
|
raw = _scoped_gate_env(env_name)
|
|
if isinstance(raw, list):
|
|
return {str(part).strip() for part in raw if str(part).strip()}
|
|
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
|
|
|
def _telegram_require_mention(self) -> bool:
|
|
"""Return whether group chats should require an explicit bot trigger."""
|
|
return self._extra_bool("require_mention", "TELEGRAM_REQUIRE_MENTION", "false")
|
|
|
|
def _telegram_observe_unmentioned_group_messages(self) -> bool:
|
|
"""Store skipped unmentioned group messages as context (observe chatter, dispatch only when addressed)."""
|
|
return self._extra_bool(
|
|
"observe_unmentioned_group_messages", "TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES", "false",
|
|
"ingest_unmentioned_group_messages")
|
|
|
|
def _telegram_guest_mode(self) -> bool:
|
|
"""Return whether non-allowlisted groups may trigger via direct @mention."""
|
|
return self._extra_bool("guest_mode", "TELEGRAM_GUEST_MODE", "false")
|
|
|
|
def _telegram_exclusive_bot_mentions(self) -> bool:
|
|
"""Return whether explicit @...bot mentions exclusively route group messages."""
|
|
return self._extra_bool("exclusive_bot_mentions", "TELEGRAM_EXCLUSIVE_BOT_MENTIONS", "true")
|
|
|
|
def _telegram_bots_require_mention(self) -> bool:
|
|
"""Whether another bot's message must explicitly @mention us (a quote-reply alone won't);
|
|
breaks two-bot reply loops in groups while human replies stay unaffected."""
|
|
return self._extra_bool(
|
|
"bots_require_mention", "TELEGRAM_BOTS_REQUIRE_MENTION", "false"
|
|
)
|
|
|
|
def _telegram_free_response_chats(self) -> set[str]:
|
|
return self._extra_str_set("free_response_chats", "TELEGRAM_FREE_RESPONSE_CHATS")
|
|
|
|
def _telegram_free_response_topics(self) -> set[str]:
|
|
"""Topic-level free-response entries as ``<chat_id>:<thread_id>`` (General topic = ``1``)."""
|
|
return self._extra_str_set("free_response_topics", "TELEGRAM_FREE_RESPONSE_TOPICS")
|
|
|
|
def _telegram_is_free_response_topic(self, message: Message) -> bool:
|
|
"""True when the message's chat/topic pair is in ``free_response_topics``."""
|
|
topics = self._telegram_free_response_topics()
|
|
if not topics:
|
|
return False
|
|
chat_id = self._chat_id_str(message)
|
|
if not chat_id:
|
|
return False
|
|
return f"{chat_id}:{self._topic_id_or_general(self._effective_message_thread_id(message))}" in topics
|
|
|
|
def _telegram_allowed_chats(self) -> set[str]:
|
|
"""Group chat IDs the bot responds in (non-empty: others need ``guest_mode`` + @mention; DMs never
|
|
filtered; empty = no restriction)."""
|
|
return self._extra_str_set("allowed_chats", "TELEGRAM_ALLOWED_CHATS")
|
|
|
|
def _telegram_group_allowed_chats(self) -> set[str]:
|
|
"""Return Telegram chats authorized at group scope."""
|
|
return self._extra_str_set("group_allowed_chats", "TELEGRAM_GROUP_ALLOWED_CHATS")
|
|
|
|
def _telegram_observe_allowed_chats(self) -> set[str]:
|
|
"""Chats where observed group context may use a shared source: ``group_allowed_chats`` ∩
|
|
``allowed_chats`` (when set)."""
|
|
group_allowed = self._telegram_group_allowed_chats()
|
|
if not group_allowed:
|
|
return set()
|
|
response_allowed = self._telegram_allowed_chats()
|
|
return group_allowed & response_allowed if response_allowed else group_allowed
|
|
|
|
def _telegram_allowed_topics(self) -> set[str]:
|
|
"""Forum topic IDs this bot handles (non-empty: other topics ignored; DMs never filtered; missing
|
|
``message_thread_id`` == General topic ``1``)."""
|
|
return self._extra_str_set("allowed_topics", "TELEGRAM_ALLOWED_TOPICS")
|
|
|
|
def _telegram_ignored_threads(self) -> set[int]:
|
|
raw = self.config.extra.get("ignored_threads")
|
|
if raw is None:
|
|
raw = _scoped_gate_env("TELEGRAM_IGNORED_THREADS")
|
|
ignored: set[int] = set()
|
|
for value in (raw if isinstance(raw, list) else str(raw).split(",")):
|
|
text = str(value).strip()
|
|
if not text:
|
|
continue
|
|
try:
|
|
ignored.add(int(text))
|
|
except (TypeError, ValueError):
|
|
logger.warning("[%s] Ignoring invalid Telegram thread id: %r", self.name, value)
|
|
return ignored
|
|
|
|
def _compile_mention_patterns(self) -> List[re.Pattern]:
|
|
"""Compile optional regex wake-word patterns for group triggers."""
|
|
patterns = self.config.extra.get("mention_patterns")
|
|
if patterns is None:
|
|
raw = _scoped_gate_env("TELEGRAM_MENTION_PATTERNS", "").strip()
|
|
if raw:
|
|
try:
|
|
loaded = json.loads(raw)
|
|
except Exception:
|
|
loaded = [part.strip() for part in raw.splitlines() if part.strip()]
|
|
if not loaded:
|
|
loaded = [part.strip() for part in raw.split(",") if part.strip()]
|
|
patterns = loaded
|
|
if patterns is None:
|
|
return [] # before touching ``self.name``: tests build bare adapters via object.__new__
|
|
return compile_mention_patterns(patterns, log_prefix=self.name, platform_label="telegram", display_label="Telegram", logger_=logger)
|
|
|
|
@staticmethod
|
|
def _chat_type_str(chat) -> str:
|
|
"""PTB enum or plain-string ``chat.type`` → bare lowercase name (``supergroup``)."""
|
|
return str(getattr(chat, "type", "")).split(".")[-1].lower() if chat else ""
|
|
|
|
@staticmethod
|
|
def _chat_id_str(message) -> str:
|
|
return str(getattr(getattr(message, "chat", None), "id", ""))
|
|
|
|
@classmethod
|
|
def _topic_id_or_general(cls, thread_id) -> str:
|
|
return str(thread_id) if thread_id is not None else cls._GENERAL_TOPIC_THREAD_ID
|
|
|
|
def _is_group_chat(self, message: Message) -> bool:
|
|
chat = getattr(message, "chat", None)
|
|
return bool(chat) and self._chat_type_str(chat) in {"group", "supergroup"}
|
|
|
|
@classmethod
|
|
def _effective_message_thread_id(cls, message: Message) -> Optional[str]:
|
|
"""Routable thread id: forum General-topic messages arrive with ``message_thread_id=None`` but
|
|
Telegram addresses that topic as ``1``; plain group/DM replies carry a reply-UI anchor that is NOT
|
|
a routing id. Gating, skill binding and outbound routing must all agree on this value."""
|
|
chat = getattr(message, "chat", None)
|
|
chat_type = cls._chat_type_str(chat)
|
|
raw = getattr(message, "message_thread_id", None)
|
|
is_topic_message = bool(getattr(message, "is_topic_message", False))
|
|
is_group = chat_type in ("group", "supergroup")
|
|
is_forum_group = is_group and getattr(chat, "is_forum", False) is True
|
|
if raw is not None:
|
|
if is_forum_group or (is_group and is_topic_message) or (chat_type == "private" and is_topic_message):
|
|
return str(raw)
|
|
return None
|
|
return cls._GENERAL_TOPIC_THREAD_ID if is_forum_group else None
|
|
|
|
# Decides only whether a FOREIGN @handle is bot-shaped; our own handle is matched by identity, never
|
|
# shape (collectible/Fragment bot usernames need not end in "bot").
|
|
_FOREIGN_BOT_HANDLE_RE = re.compile(r"[a-z0-9_]{2,29}bot", re.IGNORECASE)
|
|
_BOT_IDENTITY_TTL_SECONDS = 300.0 # how long an observed identity is trusted before re-check
|
|
|
|
def _current_bot_username(self) -> str:
|
|
"""This bot's live @username (lowercased, no ``@``): the last observed handle beats PTB's
|
|
``get_me()`` cache, which keeps a stale handle after a BotFather rename."""
|
|
observed = getattr(self, "_bot_username_observed", None)
|
|
if observed:
|
|
return observed
|
|
return (getattr(self._bot, "username", None) or "").lstrip("@").lower()
|
|
|
|
def _note_bot_username(self, username: Optional[str]) -> None:
|
|
"""Record the bot's current @username, logging real renames."""
|
|
handle = (username or "").lstrip("@").lower()
|
|
if not handle:
|
|
return
|
|
previous = getattr(self, "_bot_username_observed", None)
|
|
if previous == handle:
|
|
return
|
|
self._bot_username_observed = handle
|
|
self._bot_identity_checked_at = time.monotonic()
|
|
if previous:
|
|
logger.info(
|
|
"[%s] Telegram bot username changed: @%s -> @%s (mention routing now follows the new handle)", self.name, previous, handle)
|
|
|
|
def _observe_bot_identity_from_message(self, message: Message) -> None:
|
|
"""Learn our own handle from a message Telegram says we authored (own messages and
|
|
``reply_to_message``); only trusted when the user id matches this bot."""
|
|
bot_id = getattr(self._bot, "id", None)
|
|
if bot_id is None:
|
|
return
|
|
for candidate in (getattr(message, "from_user", None), getattr(getattr(message, "reply_to_message", None), "from_user", None)):
|
|
if candidate is not None and getattr(candidate, "id", None) == bot_id:
|
|
self._note_bot_username(getattr(candidate, "username", None))
|
|
|
|
def _bot_identity_is_fresh(self) -> bool:
|
|
"""True when identity was re-read within the TTL. ``None`` (never checked) is always stale — do
|
|
not fold it into ``0.0``: monotonic clocks have an arbitrary epoch."""
|
|
checked_at = getattr(self, "_bot_identity_checked_at", None)
|
|
return checked_at is not None and (time.monotonic() - checked_at) < self._BOT_IDENTITY_TTL_SECONDS
|
|
|
|
async def _refresh_bot_identity(self, *, force: bool = False) -> None:
|
|
"""Re-read bot identity when the cache may be stale (``get_me()`` rewrites PTB's ``Bot._bot_user``
|
|
in place). Best-effort: a failed probe keeps the last known handle."""
|
|
bot = self._bot
|
|
if bot is None or not callable(getattr(bot, "get_me", None)):
|
|
return
|
|
if not force and self._bot_identity_is_fresh():
|
|
return
|
|
try:
|
|
me = await asyncio.wait_for(bot.get_me(), self._BOT_IDENTITY_PROBE_TIMEOUT)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
logger.debug(
|
|
"[%s] Telegram identity refresh failed (keeping @%s): %s", self.name, self._current_bot_username() or "unknown", exc)
|
|
return
|
|
self._bot_identity_checked_at = time.monotonic()
|
|
self._note_bot_username(getattr(me, "username", None))
|
|
|
|
_BOT_IDENTITY_PROBE_TIMEOUT = 15.0
|
|
|
|
def _is_reply_to_bot(self, message: Message) -> bool:
|
|
if not self._bot or not getattr(message, "reply_to_message", None):
|
|
return False
|
|
reply_user = getattr(message.reply_to_message, "from_user", None)
|
|
return bool(reply_user and getattr(reply_user, "id", None) == getattr(self._bot, "id", None))
|
|
|
|
@staticmethod
|
|
def _entity_sources(message: Message):
|
|
"""``(text, entities)`` pairs for the message text and caption."""
|
|
yield getattr(message, "text", None) or "", getattr(message, "entities", None) or []
|
|
yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or []
|
|
|
|
@staticmethod
|
|
def _entity_type(entity) -> str:
|
|
return str(getattr(entity, "type", "")).split(".")[-1].lower()
|
|
|
|
@classmethod
|
|
def _entity_span(cls, source_text: str, entity) -> Optional[str]:
|
|
"""The entity's text, or None when its offsets are unusable."""
|
|
# Telegram's official group-disambiguation form for slash commands (``/cmd@botname``) is emitted as
|
|
# a single ``bot_command`` entity covering the whole span — there is no accompanying ``mention``
|
|
# entity. Treat it as a direct address to this bot when the ``@botname`` suffix matches. This is the
|
|
# form Telegram's own command menu autocomplete produces in groups, so dropping it at the mention
|
|
# gate would break /new, /reset, /help, ... for every group that has ``require_mention`` enabled
|
|
# (#15415).
|
|
offset = int(getattr(entity, "offset", -1))
|
|
length = int(getattr(entity, "length", 0))
|
|
if offset < 0 or length <= 0:
|
|
return None
|
|
return cls._telegram_entity_text(source_text, offset, length)
|
|
|
|
@classmethod
|
|
def _extract_bot_mention_usernames(cls, message: Message, self_username: str = "") -> set[str]:
|
|
"""Explicit bot usernames mentioned in text/captions: foreign handles count only when bot-shaped
|
|
(``...bot``), ``self_username`` opts our OWN handle in regardless of shape. Entity mentions are
|
|
authoritative; the raw-text fallback is deliberately narrow."""
|
|
mentioned_bot_usernames: set[str] = set()
|
|
own = (self_username or "").lstrip("@").lower()
|
|
|
|
def _is_bot_handle(handle: str) -> bool:
|
|
if not handle:
|
|
return False
|
|
if own and handle == own:
|
|
return True
|
|
return bool(cls._FOREIGN_BOT_HANDLE_RE.fullmatch(handle))
|
|
|
|
for source_text, entities in cls._entity_sources(message):
|
|
for entity in entities:
|
|
entity_type = cls._entity_type(entity)
|
|
if entity_type not in {"mention", "bot_command"}:
|
|
continue
|
|
entity_text = cls._entity_span(source_text, entity)
|
|
if entity_text is None:
|
|
continue
|
|
entity_text = entity_text.strip()
|
|
if entity_type == "mention":
|
|
handle = entity_text.lstrip("@").lower()
|
|
if _is_bot_handle(handle):
|
|
mentioned_bot_usernames.add(handle)
|
|
continue
|
|
# /cmd@botname is one bot_command entity; its suffix is an explicit bot address.
|
|
at_index = entity_text.find("@")
|
|
if at_index < 0:
|
|
continue
|
|
command_target = entity_text[at_index + 1:].strip().lower()
|
|
if _is_bot_handle(command_target):
|
|
mentioned_bot_usernames.add(command_target)
|
|
# Entity-less fallback only: if Telegram supplied entities, trust them (no URL/code rescue).
|
|
for raw_text, entities in cls._entity_sources(message):
|
|
if not raw_text or entities:
|
|
continue
|
|
for match in re.finditer(r"(?i)(?<![A-Za-z0-9_`/])@([A-Za-z0-9_]{2,31})\b", raw_text):
|
|
handle = match.group(1).lower()
|
|
if _is_bot_handle(handle):
|
|
mentioned_bot_usernames.add(handle)
|
|
return mentioned_bot_usernames
|
|
|
|
@staticmethod
|
|
def _telegram_entity_text(source_text: str, offset: int, length: int) -> str:
|
|
"""Return a Telegram entity span using UTF-16 code-unit offsets."""
|
|
if offset < 0 or length <= 0:
|
|
return ""
|
|
try:
|
|
return source_text.encode("utf-16-le")[offset * 2:(offset + length) * 2].decode("utf-16-le")
|
|
except UnicodeDecodeError:
|
|
return ""
|
|
|
|
def _message_mentions_bot(self, message: Message) -> bool:
|
|
if not self._bot:
|
|
return False
|
|
bot_username = self._current_bot_username()
|
|
bot_id = getattr(self._bot, "id", None)
|
|
expected = f"@{bot_username}" if bot_username else None
|
|
# Server-side MessageEntity values are authoritative: raw substrings like "foo@hermes_bot.example"
|
|
# or handles inside URLs/code are not mentions.
|
|
for source_text, entities in self._entity_sources(message):
|
|
for entity in entities:
|
|
entity_type = self._entity_type(entity)
|
|
if entity_type == "mention" and expected:
|
|
span = self._entity_span(source_text, entity)
|
|
if span is not None and span.strip().lower() == expected:
|
|
return True
|
|
elif entity_type == "text_mention":
|
|
user = getattr(entity, "user", None)
|
|
if user and getattr(user, "id", None) == bot_id:
|
|
return True
|
|
elif entity_type == "bot_command" and expected:
|
|
# ``/cmd@botname`` (what the group command menu produces) must count as a direct address.
|
|
command_text = self._entity_span(source_text, entity)
|
|
if command_text is None:
|
|
continue
|
|
at_index = command_text.find("@")
|
|
if at_index >= 0 and command_text[at_index:].strip().lower() == expected:
|
|
return True
|
|
if bot_username:
|
|
return bot_username in self._extract_bot_mention_usernames(message, bot_username)
|
|
return False
|
|
|
|
def _schedule_bot_identity_recheck(self) -> None:
|
|
"""Fire a TTL-guarded identity refresh in the background when routing is about to discard a
|
|
message naming other bots but not us (the symptom of a stale handle after a rename)."""
|
|
existing = getattr(self, "_bot_identity_refresh_task", None)
|
|
if (existing is not None and not existing.done()) or self._bot_identity_is_fresh():
|
|
return
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
return
|
|
task = loop.create_task(self._refresh_bot_identity())
|
|
self._bot_identity_refresh_task = task
|
|
tracked = getattr(self, "_background_tasks", None)
|
|
if isinstance(tracked, set):
|
|
tracked.add(task)
|
|
task.add_done_callback(tracked.discard)
|
|
|
|
def _explicit_bot_mentions_exclude_self(self, message: Message) -> bool:
|
|
"""True when explicit bot handles target other bots, not this one (``@bot3 hi @bot4`` must not
|
|
wake ``@bot1`` via reply/wake-word fallbacks)."""
|
|
if not self._bot:
|
|
return False
|
|
bot_username = self._current_bot_username()
|
|
if not bot_username:
|
|
return False
|
|
mentioned_bot_usernames = self._extract_bot_mention_usernames(message, bot_username)
|
|
excludes_self = bool(mentioned_bot_usernames) and bot_username not in mentioned_bot_usernames
|
|
if excludes_self:
|
|
# Either truly for another bot, or our handle is stale after a rename — re-check out of band.
|
|
self._schedule_bot_identity_recheck()
|
|
return excludes_self
|
|
|
|
def _message_matches_mention_patterns(self, message: Message) -> bool:
|
|
if not self._mention_patterns:
|
|
return False
|
|
return any(
|
|
pattern.search(candidate)
|
|
for candidate in (getattr(message, "text", None), getattr(message, "caption", None)) if candidate
|
|
for pattern in self._mention_patterns)
|
|
|
|
def _is_guest_mention(self, message: Message) -> bool:
|
|
"""Guest-mode bypass: explicit bot mention (caller already verified group chat)."""
|
|
return self._telegram_guest_mode() and self._message_mentions_bot(message)
|
|
|
|
def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]:
|
|
bot_username = self._current_bot_username()
|
|
if not text or not bot_username:
|
|
return text
|
|
cleaned = re.sub(rf"(?i)@{re.escape(bot_username)}\b[,:\-]*\s*", "", text).strip()
|
|
return cleaned or text
|
|
|
|
def _topic_gates_pass(self, thread_id, *, warn_non_numeric: bool) -> Optional[bool]:
|
|
"""``allowed_topics`` / ``ignored_threads`` gates; False = blocked, None = undecided."""
|
|
allowed_topics = self._telegram_allowed_topics()
|
|
if allowed_topics and self._topic_id_or_general(thread_id) not in allowed_topics:
|
|
return False
|
|
if thread_id is not None:
|
|
try:
|
|
if int(thread_id) in self._telegram_ignored_threads():
|
|
return False
|
|
except (TypeError, ValueError):
|
|
if not warn_non_numeric:
|
|
return False
|
|
logger.warning("[%s] Ignoring non-numeric Telegram message_thread_id: %r", self.name, thread_id)
|
|
return None
|
|
|
|
def _should_observe_unmentioned_group_message(self, message: Message) -> bool:
|
|
"""Return True when a group message should be stored but not dispatched."""
|
|
if self._is_own_message(message) or not self._telegram_observe_unmentioned_group_messages() or not self._is_group_chat(message):
|
|
return False
|
|
if self._topic_gates_pass(getattr(message, "message_thread_id", None), warn_non_numeric=False) is False:
|
|
return False
|
|
chat_id_str = self._chat_id_str(message)
|
|
if self._telegram_exclusive_bot_mentions() and self._explicit_bot_mentions_exclude_self(message):
|
|
return False
|
|
# Observed context is shared at chat/topic scope, so require an explicit chat allowlist.
|
|
allowed = self._telegram_observe_allowed_chats()
|
|
if not allowed or chat_id_str not in allowed:
|
|
return False
|
|
# Only observe messages the require_mention gate would skip.
|
|
if chat_id_str in self._telegram_free_response_chats() or self._telegram_is_free_response_topic(message):
|
|
return False
|
|
if not self._telegram_require_mention() or self._is_reply_to_bot(message) or self._message_mentions_bot(message):
|
|
return False
|
|
return not self._message_matches_mention_patterns(message)
|
|
|
|
def _telegram_group_observe_shared_source(self, source):
|
|
"""Return a chat/topic-scoped source for observed Telegram group context."""
|
|
return dataclasses.replace(source, user_id=None, user_name=None, user_id_alt=None)
|
|
|
|
def _telegram_group_observe_attributed_text(self, event: MessageEvent) -> str:
|
|
user_id = event.source.user_id or "unknown"
|
|
return f"[{event.source.user_name or user_id}|{user_id}]\n{event.text or ''}"
|
|
|
|
def _telegram_group_observe_channel_prompt(self) -> str:
|
|
username = self._current_bot_username() or "unknown"
|
|
bot_id = getattr(getattr(self, "_bot", None), "id", None) or "unknown"
|
|
return (
|
|
"You are handling a Telegram group chat message.\n"
|
|
f"- Your identity: user_id={bot_id}, @-mention name in this group=@{username}\n"
|
|
"- observed Telegram group context may be provided in a separate context-only block "
|
|
"before the current message; it is not necessarily addressed to you.\n"
|
|
"- Treat only the current new message as a request explicitly directed at you, "
|
|
"and use observed context only when the current message asks for it.")
|
|
|
|
def _apply_telegram_group_observe_attribution(self, event: MessageEvent) -> MessageEvent:
|
|
"""Align triggered group turns with observed-history attribution."""
|
|
if not self._telegram_observe_unmentioned_group_messages():
|
|
return event
|
|
raw_message = getattr(event, "raw_message", None)
|
|
if not raw_message or not self._is_group_chat(raw_message):
|
|
return event
|
|
allowed = self._telegram_observe_allowed_chats()
|
|
if not allowed or self._chat_id_str(raw_message) not in allowed:
|
|
return event
|
|
observe_prompt = self._telegram_group_observe_channel_prompt()
|
|
channel_prompt = f"{event.channel_prompt}\n\n{observe_prompt}" if event.channel_prompt else observe_prompt
|
|
if event.message_type == MessageType.COMMAND:
|
|
# Commands keep the original source (user_id) so _check_slash_access can identify the sender.
|
|
return dataclasses.replace(event, channel_prompt=channel_prompt)
|
|
return dataclasses.replace(
|
|
event, text=self._telegram_group_observe_attributed_text(event),
|
|
source=self._telegram_group_observe_shared_source(event.source), channel_prompt=channel_prompt)
|
|
|
|
def _media_message_type(self, msg: Message) -> MessageType:
|
|
"""Classify a Telegram media message into a MessageType (first present attachment wins)."""
|
|
for attr, mtype in (
|
|
("sticker", MessageType.STICKER), ("photo", MessageType.PHOTO), ("video", MessageType.VIDEO),
|
|
("audio", MessageType.AUDIO), ("voice", MessageType.VOICE)):
|
|
if getattr(msg, attr):
|
|
return mtype
|
|
return MessageType.DOCUMENT
|
|
|
|
_CACHED_KIND_TO_MESSAGE_TYPE = {"image": MessageType.PHOTO, "video": MessageType.VIDEO, "audio": MessageType.AUDIO}
|
|
|
|
async def _download_observed_media(self, msg: Any, what: str):
|
|
"""Download ``msg``'s attachment into the media cache (bounded by ``_max_doc_bytes``). Returns ``(status, cached)``:
|
|
``"none"``, ``"oversized"`` (cached = raw file_size), ``"failed"``, ``"unreadable"`` or ``"ok"``."""
|
|
from gateway.platforms.base import cache_media_bytes_async
|
|
source, filename, mime, kind = self._observed_media_source(msg)
|
|
if source is None:
|
|
return "none", None
|
|
file_size = getattr(source, "file_size", None)
|
|
if not (0 < self._int_or_zero(file_size) <= getattr(self, "_max_doc_bytes", 20 * 1024 * 1024)):
|
|
return "oversized", file_size
|
|
try:
|
|
file_obj = await source.get_file()
|
|
data = bytes(await file_obj.download_as_bytearray())
|
|
if not filename:
|
|
filename = os.path.basename(getattr(file_obj, "file_path", "") or "")
|
|
cached = await cache_media_bytes_async(data, filename=filename, mime_type=mime, default_kind=kind)
|
|
except Exception as exc:
|
|
logger.warning("[Telegram] Failed to cache %s: %s", what, _redact_telegram_error_text(exc), exc_info=True)
|
|
return "failed", None
|
|
if cached is None:
|
|
return "unreadable", None
|
|
return "ok", cached
|
|
|
|
async def _cache_observed_media(self, msg: Message, event: MessageEvent) -> None:
|
|
"""Cache an unmentioned group attachment and annotate the observed text; oversized or unsupported
|
|
attachments are noted in the transcript without downloading."""
|
|
status, cached = await self._download_observed_media(msg, "observed group media")
|
|
if status == "oversized":
|
|
limit_mb = getattr(self, "_max_doc_bytes", 20 * 1024 * 1024) // (1024 * 1024)
|
|
event.text = self._append_observed_note(
|
|
event.text, f"[Observed Telegram attachment too large or unverifiable. Maximum: {limit_mb} MB.]")
|
|
logger.info("[Telegram] Observed group attachment skipped (size=%s)", cached)
|
|
return
|
|
if status == "unreadable": # only images that fail validation reach here
|
|
event.text = self._append_observed_note(event.text, "[Observed Telegram attachment could not be read, not cached.]")
|
|
return
|
|
if status == "ok":
|
|
event.media_urls = []
|
|
event.media_types = []
|
|
self._attach_cached(event, cached, cached.context_note(), "[Telegram] Cached observed group %s at %s")
|
|
|
|
def _attach_cached(self, event: MessageEvent, cached, note: str, log_fmt: str) -> None:
|
|
"""Append a cached attachment to the event (message type follows the kind only for the first one)."""
|
|
event.media_urls.append(cached.path)
|
|
event.media_types.append(cached.media_type)
|
|
if len(event.media_urls) == 1 and cached.kind in self._CACHED_KIND_TO_MESSAGE_TYPE:
|
|
event.message_type = self._CACHED_KIND_TO_MESSAGE_TYPE[cached.kind]
|
|
event.text = self._append_observed_note(event.text, note)
|
|
logger.info(log_fmt, cached.kind, cached.path)
|
|
|
|
async def _cache_replied_media(self, msg: Any, event: MessageEvent) -> None:
|
|
"""Cache media from the message this turn replies to, if any."""
|
|
reply_msg = getattr(msg, "reply_to_message", None)
|
|
if reply_msg is None:
|
|
return
|
|
status, cached = await self._download_observed_media(reply_msg, "replied-to media")
|
|
if status == "ok":
|
|
self._attach_cached(
|
|
event, cached, f"[Replied-to {cached.kind} '{cached.display_name}' saved at: {cached.path}]",
|
|
"[Telegram] Cached replied-to %s at %s")
|
|
|
|
def _observed_media_source(self, msg: Message):
|
|
"""Return (telegram_file_source, filename, mime, default_kind) or Nones."""
|
|
if msg.photo:
|
|
return msg.photo[-1], "", "", "image"
|
|
if msg.video:
|
|
return msg.video, "", "video/mp4", "video"
|
|
if msg.voice:
|
|
return msg.voice, "voice.ogg", "audio/ogg", "audio"
|
|
if msg.audio:
|
|
return msg.audio, getattr(msg.audio, "file_name", "") or "", "", "audio"
|
|
if msg.document:
|
|
doc = msg.document
|
|
return doc, doc.file_name or "", (doc.mime_type or "").lower(), None
|
|
return None, "", "", None
|
|
|
|
@staticmethod
|
|
def _append_observed_note(existing: Optional[str], note: str) -> str:
|
|
if not note:
|
|
return existing or ""
|
|
return f"{existing}\n\n{note}" if existing else note
|
|
|
|
async def _surface_media_cache_failure(
|
|
self, msg: Message, event: MessageEvent, kind: str, exc: Exception, display_name: Optional[str] = None) -> None:
|
|
"""Surface a failed media download to BOTH the user (reply asking to retry) and the agent (observed
|
|
note) — otherwise the turn dispatches silently with empty media_urls.
|
|
|
|
This (1) replies to the user in Telegram so they know to retry, and (2) appends an agent-visible
|
|
notice to event.text via the existing observed-note channel so the agent knows an attachment was
|
|
attempted and failed — never a silent empty turn. No new event fields (the structured-event refactor
|
|
is out of scope per #23045).
|
|
"""
|
|
named = f" ({display_name})" if display_name else ""
|
|
try:
|
|
await msg.reply_text(
|
|
f"\u26a0\ufe0f Couldn't download your {kind}{named} ({exc.__class__.__name__}). Please try sending it again.")
|
|
except Exception as reply_err:
|
|
logger.warning("[Telegram] Failed to notify user about %s cache failure: %s", kind, reply_err, exc_info=True)
|
|
event.text = self._append_observed_note(
|
|
event.text,
|
|
f"[The user attempted to send a {kind}{named} but it could not be downloaded ({exc.__class__.__name__}); they have been asked to retry.]",
|
|
)
|
|
|
|
def _observe_unmentioned_group_message(
|
|
self, message: Message, msg_type: MessageType, update_id: Optional[int] = None, event: Optional[MessageEvent] = None) -> None:
|
|
"""Append skipped group chatter to the target session without dispatching."""
|
|
store = getattr(self, "_session_store", None)
|
|
if not store:
|
|
return
|
|
adapter_name = getattr(self, "name", "telegram")
|
|
try:
|
|
event = event or self._build_message_event(message, msg_type, update_id=update_id)
|
|
session_entry = store.get_or_create_session(self._telegram_group_observe_shared_source(event.source))
|
|
entry = {
|
|
"role": "user", "content": self._telegram_group_observe_attributed_text(event),
|
|
"timestamp": datetime.now(tz=timezone.utc).isoformat(), "observed": True}
|
|
if event.message_id:
|
|
entry["message_id"] = str(event.message_id)
|
|
store.append_to_transcript(session_entry.session_id, entry)
|
|
logger.info(
|
|
"[%s] Telegram group message observed (no bot trigger): chat=%s from=%s", adapter_name,
|
|
getattr(getattr(message, "chat", None), "id", "unknown"), event.source.user_id or "unknown")
|
|
except Exception as exc:
|
|
logger.warning("[%s] Failed to observe Telegram group message: %s", adapter_name, exc)
|
|
|
|
def _is_own_message(self, message: Message) -> bool:
|
|
"""True when sent by this bot itself (echoed getUpdates must not count as incoming unread)."""
|
|
if not self._bot:
|
|
return False
|
|
from_user = getattr(message, "from_user", None)
|
|
if from_user is None:
|
|
return False
|
|
bot_id = getattr(self._bot, "id", None)
|
|
user_id = getattr(from_user, "id", None)
|
|
return bot_id is not None and user_id is not None and bot_id == user_id
|
|
|
|
def _sender_is_other_bot(self, message: Message) -> bool:
|
|
"""True when the sender is a bot other than this one (this bot's own echoes are already
|
|
filtered by ``_is_own_message``)."""
|
|
sender = getattr(message, "from_user", None)
|
|
if sender is None or not getattr(sender, "is_bot", False):
|
|
return False
|
|
bot_id = getattr(self._bot, "id", None)
|
|
sender_id = getattr(sender, "id", None)
|
|
return bot_id is None or sender_id is None or sender_id != bot_id
|
|
|
|
def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool:
|
|
"""Apply Telegram group trigger rules: DMs unrestricted; group messages pass ``allowed_chats`` (hard gate; only
|
|
the ``guest_mode`` @mention bypass crosses it) and then any of free_response chat/topic, ``require_mention``
|
|
off, reply to the bot, @mention (incl. ``/cmd@botname``), or a wake-word match."""
|
|
# Learn the live handle BEFORE any mention gate routes on it, then drop our own echoed messages.
|
|
# Filter out the bot's own messages (returned by getUpdates in some environments like
|
|
# groups/supergroups where the bot can see its own messages). Without this, outbound messages are
|
|
# counted as incoming unread in the Hermes inbox (#52363). Otherwise a BotFather rename leaves the
|
|
# stale handle in place and the exclusive-mention gate reads a message addressed to us as one
|
|
# addressed to some other bot.
|
|
self._observe_bot_identity_from_message(message)
|
|
if self._is_own_message(message):
|
|
return False
|
|
if not self._is_group_chat(message):
|
|
return True
|
|
thread_id = self._effective_message_thread_id(message)
|
|
if self._topic_gates_pass(thread_id, warn_non_numeric=True) is False:
|
|
return False
|
|
chat_id_str = self._chat_id_str(message)
|
|
if self._telegram_exclusive_bot_mentions() and self._explicit_bot_mentions_exclude_self(message):
|
|
return False
|
|
# Resolve once; _message_mentions_bot is not re-called below in guest mode.
|
|
guest_mention = self._is_guest_mention(message)
|
|
# allowed_chats whitelist: outside chats pass only via the guest-mode explicit mention.
|
|
allowed = self._telegram_allowed_chats()
|
|
if allowed and chat_id_str not in allowed:
|
|
return guest_mention
|
|
if guest_mention or chat_id_str in self._telegram_free_response_chats() or self._telegram_is_free_response_topic(message):
|
|
return True
|
|
# Bot-to-bot loop breaker: another bot must explicitly @mention us; its quote-reply or
|
|
# plain chatter does not count (two bots answering each other's replies never stop otherwise).
|
|
if (
|
|
self._telegram_bots_require_mention()
|
|
and self._sender_is_other_bot(message)
|
|
and not self._message_mentions_bot(message)
|
|
):
|
|
return False
|
|
if not self._telegram_require_mention() or self._is_reply_to_bot(message):
|
|
return True
|
|
if not self._telegram_guest_mode() and self._message_mentions_bot(message):
|
|
return True
|
|
return self._message_matches_mention_patterns(message)
|
|
|
|
async def _ensure_forum_commands(self, message) -> None:
|
|
"""Lazy-register bot commands for forum supergroups (topics don't inherit AllGroupChats scope;
|
|
Telegram resolves via BotCommandScopeChat)."""
|
|
async with self._forum_lock:
|
|
try:
|
|
chat = getattr(message, "chat", None)
|
|
if not chat or not getattr(chat, "is_forum", False):
|
|
return
|
|
chat_id = int(chat.id)
|
|
if chat_id in self._forum_command_registered:
|
|
return
|
|
from telegram import BotCommand, BotCommandScopeChat
|
|
from hermes_cli.commands_platforms import telegram_menu_commands, telegram_menu_max_commands
|
|
menu_commands, _ = telegram_menu_commands(max_commands=telegram_menu_max_commands())
|
|
bot_commands = [BotCommand(name, desc) for name, desc in menu_commands]
|
|
await self._bot.set_my_commands(bot_commands, scope=BotCommandScopeChat(chat_id=chat_id))
|
|
self._forum_command_registered.add(chat_id)
|
|
logger.info("[%s] Lazy-registered %d commands for forum chat %s", self.name, len(bot_commands), chat_id)
|
|
except Exception as e:
|
|
logger.warning("[%s] Forum command lazy-registration failed: %s", self.name, _redact_telegram_error_text(e))
|
|
|
|
def _effective_update_message(self, update: Update) -> Optional[Message]:
|
|
"""Message-like payload for normal messages and channel posts (``update.channel_post``)."""
|
|
return getattr(update, "effective_message", None) or getattr(update, "message", None)
|
|
|
|
def _log_blocked_user(self, msg, *, level=logging.WARNING, what: str = "unauthorized user") -> None:
|
|
logger.log(
|
|
level, "[Telegram] Blocked %s %s in chat %s", what, getattr(getattr(msg, "from_user", None), "id", None),
|
|
getattr(getattr(msg, "chat", None), "id", None))
|
|
|
|
def _gate_or_observe(self, msg, update, msg_type: MessageType) -> bool:
|
|
"""Group trigger gate; observes unmentioned chatter when configured. True = proceed."""
|
|
if self._should_process_message(msg):
|
|
return True
|
|
if self._should_observe_unmentioned_group_message(msg):
|
|
self._observe_unmentioned_group_message(msg, msg_type, update_id=update.update_id)
|
|
return False
|
|
|
|
async def _build_triggered_event(self, msg, update, msg_type: MessageType) -> MessageEvent:
|
|
"""Event for an addressed text/command: trigger text cleaned (sole addressee only), replied-to
|
|
media cached, attribution applied."""
|
|
from plugins.platforms.telegram.telegram_context import group_trigger_text
|
|
event = self._build_message_event(msg, msg_type, update_id=update.update_id)
|
|
event.text = group_trigger_text(self, msg, event.text)
|
|
await self._cache_replied_media(msg, event)
|
|
return self._apply_telegram_group_observe_attribution(event)
|
|
|
|
async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle incoming text; buffers client-split chunks into one MessageEvent."""
|
|
msg = self._effective_update_message(update)
|
|
if not msg or not msg.text:
|
|
return
|
|
# Auth check first: blocked users must not reach batching, the observed transcript, or the agent.
|
|
if not self._is_user_authorized_from_message(msg):
|
|
self._log_blocked_user(msg)
|
|
return
|
|
if not self._gate_or_observe(msg, update, MessageType.TEXT):
|
|
return
|
|
await self._ensure_forum_commands(update.message)
|
|
self._enqueue_text_event(await self._build_triggered_event(msg, update, MessageType.TEXT))
|
|
|
|
async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle incoming command messages."""
|
|
msg = self._effective_update_message(update)
|
|
if not msg or not msg.text:
|
|
return
|
|
if not self._should_process_message(msg, is_command=True):
|
|
return
|
|
if not self._is_user_authorized_from_message(msg):
|
|
self._log_blocked_user(msg)
|
|
return
|
|
await self._ensure_forum_commands(msg)
|
|
event = await self._build_triggered_event(msg, update, MessageType.COMMAND)
|
|
# A >4096-char command paste arrives as a near-limit COMMAND chunk plus TEXT continuations; dispatching
|
|
# immediately would orphan them. Near-limit commands go through text batching.
|
|
if len(event.text or "") >= self._SPLIT_THRESHOLD:
|
|
self._enqueue_text_event(event)
|
|
return
|
|
await self.handle_message(event)
|
|
|
|
async def _handle_location_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle incoming location/venue pin messages."""
|
|
msg = self._effective_update_message(update)
|
|
if not msg:
|
|
return
|
|
if not self._is_user_authorized_from_message(msg):
|
|
self._log_blocked_user(msg)
|
|
return
|
|
if not self._gate_or_observe(msg, update, MessageType.LOCATION):
|
|
return
|
|
venue = getattr(msg, "venue", None)
|
|
location = getattr(venue, "location", None) if venue else getattr(msg, "location", None)
|
|
if not location:
|
|
return
|
|
lat = getattr(location, "latitude", None)
|
|
lon = getattr(location, "longitude", None)
|
|
if lat is None or lon is None:
|
|
return
|
|
parts = ["[The user shared a location pin.]"]
|
|
if venue:
|
|
title = getattr(venue, "title", None)
|
|
address = getattr(venue, "address", None)
|
|
if title:
|
|
parts.append(f"Venue: {title}")
|
|
if address:
|
|
parts.append(f"Address: {address}")
|
|
parts += [
|
|
f"latitude: {lat}", f"longitude: {lon}", f"Map: https://www.google.com/maps/search/?api=1&query={lat},{lon}",
|
|
"Ask what they'd like to find nearby (restaurants, cafes, etc.) and any preferences."]
|
|
event = self._build_message_event(msg, MessageType.LOCATION, update_id=update.update_id)
|
|
event.text = "\n".join(parts)
|
|
await self.handle_message(self._apply_telegram_group_observe_attribution(event))
|
|
|
|
# -- Text message aggregation (handles Telegram client-side splits) --
|
|
|
|
def _text_batch_key(self, event: MessageEvent) -> str:
|
|
"""Session-scoped batching key; topic recovery first so DM-topic batches coalesce on the recovered lane."""
|
|
self._apply_topic_recovery(event)
|
|
return super()._text_batch_key(event)
|
|
|
|
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
|
"""Buffer a text chunk, or hold it while delayed delivery must be dropped."""
|
|
if self._should_drop_delayed_delivery():
|
|
self._hold_inbound_event(event, where="text-enqueue")
|
|
return
|
|
super()._enqueue_text_event(event)
|
|
|
|
async def _flush_buffered(self, pending: dict, tasks: dict, key: str, delay: float, where: str, log_fn=None) -> None:
|
|
"""Shared delayed-flush body: sleep, pop, hold if teardown started, else dispatch. A cancel after
|
|
the pop but before durable dispatch re-holds the event (never lose it)."""
|
|
current_task = asyncio.current_task()
|
|
event = None
|
|
try:
|
|
await asyncio.sleep(delay)
|
|
# Superseded flush (a newer chunk re-armed the timer while our sleep was already done):
|
|
# CancelledError only lands at the next await, so check synchronously before the pop.
|
|
owner = tasks.get(key)
|
|
if owner is not None and owner is not current_task:
|
|
return
|
|
event = pending.pop(key, None)
|
|
if not event:
|
|
return
|
|
if self._should_drop_delayed_delivery():
|
|
self._hold_inbound_event(event, where=f"{where}-flush")
|
|
event = None
|
|
return
|
|
if log_fn is not None:
|
|
log_fn(event)
|
|
await self.handle_message(event)
|
|
event = None
|
|
except asyncio.CancelledError:
|
|
if event is not None:
|
|
self._hold_inbound_event(event, where=f"{where}-flush-cancelled")
|
|
raise
|
|
finally:
|
|
if tasks.get(key) is current_task:
|
|
tasks.pop(key, None)
|
|
|
|
def _text_batch_delay_for(self, pending: Optional[MessageEvent]) -> float:
|
|
"""Adaptive delay: near-split-point last chunk → long delay (continuation almost certain);
|
|
short/medium totals → capped fast delays; else configured cap (all min()'d with the operator cap)."""
|
|
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
|
total_len = len(getattr(pending, "text", "") or "") if pending else 0
|
|
if last_len >= self._SPLIT_THRESHOLD:
|
|
return self._text_batch_split_delay_seconds
|
|
if total_len <= self._TEXT_BATCH_FAST_LEN:
|
|
return min(self._text_batch_delay_seconds, self._TEXT_BATCH_FAST_DELAY_S)
|
|
if total_len <= self._TEXT_BATCH_SHORT_LEN:
|
|
return min(self._text_batch_delay_seconds, self._TEXT_BATCH_SHORT_DELAY_S)
|
|
return self._text_batch_delay_seconds
|
|
|
|
async def _flush_text_batch(self, key: str) -> None:
|
|
"""Telegram keeps its own flush body: a cancel after the pop must HOLD the event and re-raise
|
|
(PTB already acked the update; the hold queue redispatches after reconnect) rather than shield
|
|
the dispatch — teardown must be able to stop a flush from reaching a torn-down session."""
|
|
await self._flush_buffered(
|
|
self._pending_text_batches, self._pending_text_batch_tasks, key,
|
|
self._text_batch_delay_for(self._pending_text_batches.get(key)), "text",
|
|
lambda ev: logger.info("[Telegram] Flushing text batch %s (%d chars)", key, len(ev.text or "")))
|
|
|
|
# -- Photo batching --
|
|
|
|
def _photo_batch_key(self, event: MessageEvent, msg: Message) -> str:
|
|
"""Return a batching key for Telegram photos/albums."""
|
|
from gateway.session import build_session_key
|
|
session_key = build_session_key(
|
|
event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
|
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
|
profile=self._session_key_profile(event.source))
|
|
media_group_id = getattr(msg, "media_group_id", None)
|
|
return f"{session_key}:album:{media_group_id}" if media_group_id else f"{session_key}:photo-burst"
|
|
|
|
async def _flush_photo_batch(self, batch_key: str) -> None:
|
|
"""Send a buffered photo burst/album as a single MessageEvent."""
|
|
await self._flush_buffered(
|
|
self._pending_photo_batches, self._pending_photo_batch_tasks, batch_key, self._media_batch_delay_seconds, "photo",
|
|
lambda ev: logger.info("[Telegram] Flushing photo batch %s with %d image(s)", batch_key, len(ev.media_urls)))
|
|
|
|
def _merge_into_pending(self, pending: dict, key: str, event: MessageEvent) -> None:
|
|
"""Merge ``event`` into ``pending[key]`` (media + caption) or seed it."""
|
|
existing = pending.get(key)
|
|
if existing is None:
|
|
pending[key] = event
|
|
return
|
|
existing.media_urls.extend(event.media_urls)
|
|
existing.media_types.extend(event.media_types)
|
|
if event.text:
|
|
existing.text = self._merge_caption(existing.text, event.text)
|
|
|
|
def _enqueue_photo_event(self, batch_key: str, event: MessageEvent) -> None:
|
|
"""Merge photo events into a pending batch and schedule flush."""
|
|
if self._should_drop_delayed_delivery():
|
|
self._hold_inbound_event(event, where="photo-enqueue")
|
|
return
|
|
self._merge_into_pending(self._pending_photo_batches, batch_key, event)
|
|
prior_task = self._pending_photo_batch_tasks.get(batch_key)
|
|
if prior_task and not prior_task.done():
|
|
prior_task.cancel()
|
|
self._pending_photo_batch_tasks[batch_key] = asyncio.create_task(self._flush_photo_batch(batch_key))
|
|
|
|
async def _route_photo_event(self, msg, event: MessageEvent) -> None:
|
|
"""Album items debounce on media_group_id; singles go through the photo burst batcher."""
|
|
media_group_id = getattr(msg, "media_group_id", None)
|
|
if media_group_id:
|
|
await self._queue_media_group_event(str(media_group_id), event)
|
|
else:
|
|
self._enqueue_photo_event(self._photo_batch_key(event, msg), event)
|
|
|
|
@staticmethod
|
|
def _ext_from_path(file_path: Optional[str], candidates, default: str) -> str:
|
|
"""First extension in ``candidates`` that ``file_path`` ends with (case-insensitive), else default."""
|
|
if file_path:
|
|
lowered = file_path.lower()
|
|
for candidate in candidates:
|
|
if lowered.endswith(candidate):
|
|
return candidate
|
|
return default
|
|
|
|
async def _cache_inbound_av(self, msg, event: MessageEvent, source: Any, label: str, kind: str, ext: str, mime: str) -> bool:
|
|
"""Download a voice/audio/video attachment into the local cache. Returns True when the event was
|
|
already dispatched (oversized attachment), so the caller must return."""
|
|
try:
|
|
allowed, note = self._telegram_media_size_allowed(source, label)
|
|
if not allowed:
|
|
event.text = self._append_observed_note(event.text, note or "")
|
|
logger.info("[Telegram] Skipped oversized user %s (size=%s)", kind, getattr(source, "file_size", None))
|
|
await self.handle_message(event)
|
|
return True
|
|
file_obj = await source.get_file()
|
|
data = await file_obj.download_as_bytearray()
|
|
if kind == "video":
|
|
ext = self._ext_from_path(getattr(file_obj, "file_path", None), SUPPORTED_VIDEO_TYPES, ext)
|
|
cached_path = await cache_video_from_bytes_async(bytes(data), ext=ext)
|
|
mime = SUPPORTED_VIDEO_TYPES.get(ext, "video/mp4")
|
|
else:
|
|
cached_path = await cache_audio_from_bytes_async(bytes(data), ext=ext)
|
|
event.media_urls = [cached_path]
|
|
event.media_types = [mime]
|
|
logger.info("[Telegram] Cached user %s at %s", kind, cached_path)
|
|
except Exception as e:
|
|
logger.warning("[Telegram] Failed to cache %s: %s", kind, _redact_telegram_error_text(e), exc_info=True)
|
|
await self._surface_media_cache_failure(msg, event, label, e)
|
|
return False
|
|
|
|
async def _dispatch_with_text(self, event: MessageEvent, text: str) -> bool:
|
|
"""Replace the event text with a user-facing note and dispatch it; returns True (handled)."""
|
|
event.text = text
|
|
await self.handle_message(event)
|
|
return True
|
|
|
|
@staticmethod
|
|
def _set_cached_media(event: MessageEvent, path: str, mime: str, mtype: MessageType, log_fmt: str) -> None:
|
|
event.media_urls = [path]
|
|
event.media_types = [mime]
|
|
event.message_type = mtype
|
|
logger.info(log_fmt, path)
|
|
|
|
async def _cache_inbound_document(self, msg, event: MessageEvent) -> bool:
|
|
"""Cache a document attachment (image → photo path, video, else generic media + text injection).
|
|
Returns True when the event was already dispatched/routed so the caller must return."""
|
|
doc = msg.document
|
|
try:
|
|
original_filename = doc.file_name or ""
|
|
ext = os.path.splitext(original_filename)[1].lower() if original_filename else ""
|
|
doc_mime = (doc.mime_type or "").lower() # some clients send "IMAGE/PNG"
|
|
if not ext and doc_mime:
|
|
ext = _TELEGRAM_IMAGE_MIME_TO_EXT.get(doc_mime, "")
|
|
if not ext:
|
|
ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}.get(doc_mime, "")
|
|
display = original_filename or doc_mime or ext or 'unknown'
|
|
# Size check before the image branch so image documents can't bypass the limit.
|
|
if not doc.file_size or doc.file_size > self._max_doc_bytes:
|
|
logger.info("[Telegram] Document too large: %s bytes", doc.file_size)
|
|
return await self._dispatch_with_text(
|
|
event, f"The document is too large or its size could not be verified. Maximum: {self._max_doc_bytes // (1024 * 1024)} MB.")
|
|
# Screenshots/photos sent as documents take the image cache + batching path.
|
|
if ext in _TELEGRAM_IMAGE_EXTENSIONS or doc_mime.startswith("image/"):
|
|
file_obj = await doc.get_file()
|
|
image_bytes = await file_obj.download_as_bytearray()
|
|
image_ext = ext if ext in _TELEGRAM_IMAGE_EXTENSIONS else _TELEGRAM_IMAGE_MIME_TO_EXT.get(doc_mime, ".jpg")
|
|
try:
|
|
cached_path = await cache_image_from_bytes_async(bytes(image_bytes), ext=image_ext)
|
|
except ValueError as e:
|
|
logger.warning("[Telegram] Failed to cache image document: %s", _redact_telegram_error_text(e), exc_info=True)
|
|
return await self._dispatch_with_text(event, f"Image document '{display}' could not be read as an image.")
|
|
self._set_cached_media(
|
|
event, cached_path, doc_mime if doc_mime.startswith(
|
|
"image/"
|
|
) else _TELEGRAM_IMAGE_EXT_TO_MIME.get(image_ext, "image/jpeg"),
|
|
MessageType.PHOTO, "[Telegram] Cached user image-document at %s")
|
|
await self._route_photo_event(msg, event)
|
|
return True
|
|
if not ext and doc.mime_type:
|
|
ext = {v: k for k, v in SUPPORTED_VIDEO_TYPES.items()}.get(doc.mime_type, "")
|
|
if not ext and doc.mime_type:
|
|
# .jpg and .jpeg both map to image/jpeg; keep the first ext seen.
|
|
image_mime_to_ext: dict[str, str] = {}
|
|
for _ext, _mime in SUPPORTED_IMAGE_DOCUMENT_TYPES.items():
|
|
image_mime_to_ext.setdefault(_mime, _ext)
|
|
ext = image_mime_to_ext.get(doc.mime_type, "")
|
|
if ext in SUPPORTED_VIDEO_TYPES:
|
|
file_obj = await doc.get_file()
|
|
video_bytes = await file_obj.download_as_bytearray()
|
|
self._set_cached_media(
|
|
event, await cache_video_from_bytes_async(bytes(video_bytes), ext=ext), SUPPORTED_VIDEO_TYPES[ext], MessageType.VIDEO,
|
|
"[Telegram] Cached user video document at %s")
|
|
await self.handle_message(event)
|
|
return True
|
|
# Any file type is accepted (authorization is the gate, not the extension); unknown types get
|
|
# application/octet-stream. Image documents already returned above.
|
|
file_obj = await doc.get_file()
|
|
raw_bytes = bytes(await file_obj.download_as_bytearray())
|
|
from gateway.platforms.base import cache_media_bytes_async
|
|
cached = await cache_media_bytes_async(raw_bytes, filename=original_filename or f"document{ext or '.bin'}", mime_type=doc_mime)
|
|
if cached is None:
|
|
return await self._dispatch_with_text(event, f"Document '{display}' could not be cached.")
|
|
event.media_urls = [cached.path]
|
|
event.media_types = [cached.media_type]
|
|
if cached.kind == "audio":
|
|
event.message_type = MessageType.AUDIO
|
|
logger.info("[Telegram] Cached user %s at %s (%s)", cached.kind, cached.path, cached.media_type)
|
|
# Inject text-readable content (≤100 KB). Gate on extension/MIME, NOT a blind UTF-8 decode:
|
|
# PDF/zip/docx have decodable ASCII headers. Binary files are surfaced as a cached path only.
|
|
MAX_TEXT_INJECT_BYTES = 100 * 1024
|
|
_is_text = ext in _TEXT_INJECT_EXTENSIONS or (doc_mime or "").startswith("text/")
|
|
if _is_text and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES:
|
|
try:
|
|
text_content = raw_bytes.decode("utf-8")
|
|
display_name = re.sub(r'[^\w.\- ]', '_', original_filename or f"document{ext or '.txt'}")
|
|
injection = f"[Content of {display_name}]:\n{text_content}"
|
|
event.text = f"{injection}\n\n{event.text}" if event.text else injection
|
|
except UnicodeDecodeError:
|
|
pass # binary — agent has the cached path
|
|
except Exception as e:
|
|
logger.warning("[Telegram] Failed to cache document: %s", _redact_telegram_error_text(e), exc_info=True)
|
|
await self._surface_media_cache_failure(msg, event, "attachment", e, display_name=getattr(doc, "file_name", None) or None)
|
|
return False
|
|
|
|
async def _handle_media_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle incoming media messages, downloading images to local cache."""
|
|
msg = update.message
|
|
if not msg:
|
|
return
|
|
if not self._is_user_authorized_from_message(msg):
|
|
self._log_blocked_user(msg, level=logging.INFO, what="media from unauthorized user")
|
|
return
|
|
if not self._should_process_message(msg):
|
|
if self._should_observe_unmentioned_group_message(msg):
|
|
_event = self._build_message_event(msg, self._media_message_type(msg), update_id=update.update_id)
|
|
if msg.caption:
|
|
_event.text = self._clean_bot_trigger_text(msg.caption)
|
|
await self._cache_observed_media(msg, _event)
|
|
self._observe_unmentioned_group_message(msg, _event.message_type, update_id=update.update_id, event=_event)
|
|
return
|
|
event = self._build_message_event(msg, self._media_message_type(msg), update_id=update.update_id)
|
|
if msg.caption:
|
|
from plugins.platforms.telegram.telegram_context import group_trigger_text
|
|
event.text = group_trigger_text(self, msg, msg.caption)
|
|
# Stickers: _handle_sticker overwrites event.text with its vision description, so observe attribution must run after it.
|
|
if msg.sticker:
|
|
await self._handle_sticker(msg, event)
|
|
await self.handle_message(self._apply_telegram_group_observe_attribution(event))
|
|
return
|
|
event = self._apply_telegram_group_observe_attribution(event)
|
|
# Cache photo locally: Telegram's file URLs expire (~1 hour) before vision may run.
|
|
if msg.photo:
|
|
try:
|
|
file_obj = await msg.photo[-1].get_file() # PhotoSize list sorted by size; largest last
|
|
image_bytes = await file_obj.download_as_bytearray()
|
|
ext = self._ext_from_path(file_obj.file_path, [".png", ".webp", ".gif", ".jpeg", ".jpg"], ".jpg")
|
|
self._set_cached_media(
|
|
event, await cache_image_from_bytes_async(bytes(image_bytes), ext=ext), f"image/{ext.lstrip('.')}", event.message_type,
|
|
"[Telegram] Cached user photo at %s")
|
|
await self._route_photo_event(msg, event)
|
|
return
|
|
except Exception as e:
|
|
logger.warning("[Telegram] Failed to cache photo: %s", _redact_telegram_error_text(e), exc_info=True)
|
|
await self._surface_media_cache_failure(msg, event, "photo", e)
|
|
# Voice/audio cached for STT transcription; video for vision.
|
|
if msg.voice:
|
|
if await self._cache_inbound_av(msg, event, msg.voice, "voice message", "voice", ".ogg", "audio/ogg"):
|
|
return
|
|
elif msg.audio:
|
|
if await self._cache_inbound_av(msg, event, msg.audio, "audio file", "audio", ".mp3", "audio/mp3"):
|
|
return
|
|
elif msg.video:
|
|
if await self._cache_inbound_av(msg, event, msg.video, "video file", "video", ".mp4", "video/mp4"):
|
|
return
|
|
elif msg.document and await self._cache_inbound_document(msg, event):
|
|
return
|
|
media_group_id = getattr(msg, "media_group_id", None)
|
|
if media_group_id:
|
|
await self._queue_media_group_event(str(media_group_id), event)
|
|
return
|
|
await self.handle_message(event)
|
|
|
|
async def _queue_media_group_event(self, media_group_id: str, event: MessageEvent) -> None:
|
|
"""Debounce album items (shared media_group_id) into one MessageEvent so the second image isn't
|
|
treated as a new message interrupting the first."""
|
|
if self._should_drop_delayed_delivery():
|
|
self._hold_inbound_event(event, where="media-group-enqueue")
|
|
return
|
|
self._merge_into_pending(self._media_group_events, media_group_id, event)
|
|
prior_task = self._media_group_tasks.get(media_group_id)
|
|
if prior_task:
|
|
prior_task.cancel()
|
|
self._media_group_tasks[media_group_id] = asyncio.create_task(self._flush_media_group_event(media_group_id))
|
|
|
|
async def _flush_media_group_event(self, media_group_id: str) -> None:
|
|
await self._flush_buffered(
|
|
self._media_group_events, self._media_group_tasks, media_group_id, self.MEDIA_GROUP_WAIT_SECONDS, "media-group")
|
|
|
|
async def _handle_sticker(self, msg: Message, event: "MessageEvent") -> None:
|
|
"""Describe a sticker via vision, cached by file_unique_id; animated/video stickers get an emoji placeholder."""
|
|
from gateway.sticker_cache import (
|
|
get_cached_description, cache_sticker_description, build_sticker_injection,
|
|
build_animated_sticker_injection, STICKER_VISION_PROMPT)
|
|
sticker = msg.sticker
|
|
emoji = sticker.emoji or ""
|
|
set_name = sticker.set_name or ""
|
|
if sticker.is_animated or sticker.is_video:
|
|
event.text = build_animated_sticker_injection(emoji)
|
|
return
|
|
cached = get_cached_description(sticker.file_unique_id)
|
|
if cached:
|
|
event.text = build_sticker_injection(cached["description"], cached.get("emoji", emoji), cached.get("set_name", set_name))
|
|
logger.info("[Telegram] Sticker cache hit: %s", sticker.file_unique_id)
|
|
return
|
|
fallback = f"a sticker with emoji {emoji}" if emoji else "a sticker"
|
|
try:
|
|
file_obj = await sticker.get_file()
|
|
image_bytes = await file_obj.download_as_bytearray()
|
|
cached_path = await cache_image_from_bytes_async(bytes(image_bytes), ext=".webp")
|
|
logger.info("[Telegram] Analyzing sticker at %s", cached_path)
|
|
from tools.vision_tools import vision_analyze_tool
|
|
result = json.loads(await vision_analyze_tool(image_url=cached_path, user_prompt=STICKER_VISION_PROMPT))
|
|
if result.get("success"):
|
|
description = result.get("analysis", "a sticker")
|
|
cache_sticker_description(sticker.file_unique_id, description, emoji, set_name)
|
|
event.text = build_sticker_injection(description, emoji, set_name)
|
|
else:
|
|
event.text = build_sticker_injection(fallback, emoji, set_name)
|
|
except Exception as e:
|
|
logger.warning("[Telegram] Sticker analysis error: %s", _redact_telegram_error_text(e), exc_info=True)
|
|
event.text = build_sticker_injection(fallback, emoji, set_name)
|
|
|
|
def _reload_dm_topics_from_config(self) -> None:
|
|
"""Re-read dm_topics from config.yaml so externally created topics work without restart."""
|
|
try:
|
|
from hermes_cli.config import load_config_readonly # canonical loader: managed overlay + ${VAR}
|
|
dm_topics = load_config_readonly().get("platforms", {}).get("telegram", {}).get("extra", {}).get("dm_topics", [])
|
|
if not dm_topics:
|
|
self._dm_topics_config = []
|
|
self._dm_topic_chat_ids = set()
|
|
return
|
|
self._dm_topics_config = dm_topics
|
|
self._dm_topic_chat_ids = {str(chat_entry["chat_id"]) for chat_entry in dm_topics if "chat_id" in chat_entry}
|
|
for chat_entry in dm_topics:
|
|
cid = chat_entry.get("chat_id")
|
|
if not cid:
|
|
continue
|
|
for t in chat_entry.get("topics", []):
|
|
tid = t.get("thread_id")
|
|
name = t.get("name")
|
|
if tid and name and f"{cid}:{name}" not in self._dm_topics:
|
|
self._dm_topics[f"{cid}:{name}"] = int(tid)
|
|
logger.info("[%s] Hot-loaded DM topic from config: %s -> thread_id=%s", self.name, f"{cid}:{name}", tid)
|
|
except Exception as e:
|
|
logger.debug("[%s] Failed to reload dm_topics from config: %s", self.name, e)
|
|
|
|
def _get_dm_topic_info(self, chat_id: str, thread_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
"""Return the DM topic config dict (name, skill, ...) for this thread_id, or None."""
|
|
if not thread_id:
|
|
return None
|
|
thread_id_int = int(thread_id)
|
|
|
|
def _lookup() -> Optional[Dict[str, Any]]:
|
|
for key, cached_tid in self._dm_topics.items():
|
|
if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"):
|
|
topic_name = key.split(":", 1)[1]
|
|
for chat_entry in self._dm_topics_config:
|
|
if str(chat_entry.get("chat_id")) == chat_id:
|
|
for t in chat_entry.get("topics", []):
|
|
if t.get("name") == topic_name:
|
|
return t
|
|
return {"name": topic_name}
|
|
return None
|
|
|
|
found = _lookup()
|
|
if found is not None:
|
|
return found
|
|
self._reload_dm_topics_from_config() # cache miss — topics may have been added externally
|
|
return _lookup()
|
|
|
|
def _cache_dm_topic_from_message(self, chat_id: str, thread_id: str, topic_name: str) -> None:
|
|
"""Cache a thread_id -> topic_name mapping discovered from an incoming message."""
|
|
cache_key = f"{chat_id}:{topic_name}"
|
|
if cache_key not in self._dm_topics:
|
|
self._dm_topics[cache_key] = int(thread_id)
|
|
logger.info("[%s] Cached DM topic from message: %s -> thread_id=%s", self.name, cache_key, thread_id)
|
|
|
|
@classmethod
|
|
def _flatten_rich_inline_text(cls, value: Any) -> str:
|
|
"""Best-effort plaintext flattener for Bot API rich-message inline nodes."""
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, str):
|
|
return value
|
|
if isinstance(value, list):
|
|
return "".join(cls._flatten_rich_inline_text(item) for item in value)
|
|
if isinstance(value, dict):
|
|
for key in ("text", "children"):
|
|
if value.get(key) is not None:
|
|
return cls._flatten_rich_inline_text(value[key])
|
|
return ""
|
|
|
|
@classmethod
|
|
def _flatten_rich_blocks(cls, blocks: Any) -> str:
|
|
"""Best-effort plaintext flattener for Bot API rich-message blocks."""
|
|
if not isinstance(blocks, list):
|
|
return ""
|
|
lines: List[str] = []
|
|
for block in blocks:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
if block.get("type") == "list":
|
|
for item in block.get("items", []):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
item_lines = cls._flatten_rich_blocks(item.get("blocks")).splitlines()
|
|
if not item_lines:
|
|
continue
|
|
label = item.get("label")
|
|
lines.append(f"{label} {item_lines[0]}".strip() if label else item_lines[0])
|
|
lines.extend(item_lines[1:])
|
|
continue
|
|
text = cls._flatten_rich_inline_text(block.get("text"))
|
|
if text:
|
|
lines.extend(text.splitlines())
|
|
return "\n".join(line.rstrip() for line in lines if line)
|
|
|
|
@classmethod
|
|
def _extract_rich_reply_text(cls, reply_to_message: Any) -> Optional[str]:
|
|
"""Return plaintext echoed by Telegram's rich_message reply payload."""
|
|
try:
|
|
getter = getattr(getattr(reply_to_message, "api_kwargs", None), "get", None)
|
|
if not callable(getter):
|
|
return None
|
|
rich_getter = getattr(getter("rich_message"), "get", None)
|
|
if not callable(rich_getter):
|
|
return None
|
|
return cls._flatten_rich_blocks(rich_getter("blocks")).strip() or None
|
|
except Exception:
|
|
return None
|
|
|
|
def _resolve_topic_binding(self, message: Message, chat_type: str, thread_id_str: Optional[str]) -> tuple:
|
|
"""Return ``(chat_topic, topic_skill)`` for a DM topic or bound forum topic (else Nones)."""
|
|
chat = message.chat
|
|
chat_topic = None
|
|
topic_skill = None
|
|
if chat_type == "dm" and thread_id_str:
|
|
topic_info = self._get_dm_topic_info(str(chat.id), thread_id_str)
|
|
if topic_info:
|
|
chat_topic = topic_info.get("name")
|
|
topic_skill = topic_info.get("skill")
|
|
# forum_topic_created service messages also reveal topic names
|
|
if hasattr(message, "forum_topic_created") and message.forum_topic_created:
|
|
created_name = message.forum_topic_created.name
|
|
if created_name:
|
|
self._cache_dm_topic_from_message(str(chat.id), thread_id_str, created_name)
|
|
if not chat_topic:
|
|
chat_topic = created_name
|
|
elif chat_type == "group" and thread_id_str:
|
|
# Forum topic skill binding via config.extra['group_topics']; accepts both
|
|
# [{"chat_id": ..., "topics": [...]}] and legacy {"-100...": [{"thread_id": 12}]}.
|
|
group_topics_config = self.config.extra.get("group_topics", [])
|
|
if isinstance(group_topics_config, dict):
|
|
group_topics_iter = [{"chat_id": cfg_chat_id, "topics": topics} for cfg_chat_id, topics in group_topics_config.items()]
|
|
elif isinstance(group_topics_config, list):
|
|
group_topics_iter = [entry for entry in group_topics_config if isinstance(entry, dict)]
|
|
else:
|
|
group_topics_iter = []
|
|
for chat_entry in group_topics_iter:
|
|
if str(chat_entry.get("chat_id", "")) != str(chat.id):
|
|
continue
|
|
topics = chat_entry.get("topics", [])
|
|
for topic in (topics if isinstance(topics, list) else []):
|
|
if not isinstance(topic, dict):
|
|
continue
|
|
tid = topic.get("thread_id")
|
|
if tid is not None and str(tid) == thread_id_str:
|
|
chat_topic = topic.get("name")
|
|
topic_skill = topic.get("skill")
|
|
break
|
|
break
|
|
return chat_topic, topic_skill
|
|
|
|
def _reply_context(self, message: Message) -> tuple:
|
|
"""``(reply_to_id, reply_to_text)`` for the replied-to message: Telegram's native partial quote
|
|
first, then text/caption, rich echo, then the sent index."""
|
|
if not message.reply_to_message:
|
|
return None, None
|
|
reply_to_id = str(message.reply_to_message.message_id)
|
|
quote = getattr(message, "quote", None)
|
|
quote_text = getattr(quote, "text", None) if quote is not None else None
|
|
if quote_text:
|
|
return reply_to_id, quote_text
|
|
reply_to_text = message.reply_to_message.text or message.reply_to_message.caption or None
|
|
if not reply_to_text:
|
|
reply_to_text = self._extract_rich_reply_text(message.reply_to_message)
|
|
if not reply_to_text:
|
|
try:
|
|
from gateway import rich_sent_store
|
|
reply_to_text = rich_sent_store.lookup(str(message.chat.id), reply_to_id)
|
|
except Exception:
|
|
# Extract reply context if this message is a reply. Prefer Telegram's native partial quote
|
|
# (message.quote, TextQuote) so a user replying to a single selected substring of a prior
|
|
# multi-section message doesn't get the whole replied-to message injected into the agent's
|
|
# context — which can cause the agent to act on unrelated actionable-looking text the user
|
|
# didn't quote (#22619). Fall back to the full replied-to message text / caption when no
|
|
# native quote is present.
|
|
reply_to_text = None
|
|
return reply_to_id, reply_to_text
|
|
|
|
def _build_message_event(self, message: Message, msg_type: MessageType, update_id: Optional[int] = None) -> MessageEvent:
|
|
"""Build a MessageEvent from a Telegram message. ``update_id`` lets ``/restart`` record the
|
|
triggering offset so the new gateway process advances past it."""
|
|
chat = message.chat
|
|
user = message.from_user
|
|
telegram_chat_type = self._chat_type_str(chat) # str() so PTB enums and plain-string mocks both work
|
|
chat_type = "group" if telegram_chat_type in {"group", "supergroup"} else ("channel" if telegram_chat_type == "channel" else "dm")
|
|
# Shared normalizer so gating and session routing agree (reply-UI anchors dropped, General → "1").
|
|
# Resolve routable thread id for DM topics and forum group topics via the shared normalizer, so
|
|
# gating and session routing agree on one value. Only real topic/forum messages keep a thread id;
|
|
# ordinary reply-UI anchors are dropped (they are not durable session threads and sends against them
|
|
# hit 'Message thread not found', #3206), while forum General-topic messages
|
|
# (message_thread_id=None) normalize to the General-topic id so replies route back to General
|
|
# (#22423).
|
|
thread_id_str = self._effective_message_thread_id(message)
|
|
chat_topic, topic_skill = self._resolve_topic_binding(message, chat_type, thread_id_str)
|
|
has_full_name = hasattr(chat, "full_name")
|
|
if user:
|
|
user_name = user.full_name
|
|
elif has_full_name and chat_type == "dm":
|
|
user_name = chat.full_name
|
|
else:
|
|
user_name = chat.title if chat_type == "channel" else None
|
|
source = self.build_source(
|
|
chat_id=str(chat.id), chat_name=chat.title or (chat.full_name if has_full_name else None), chat_type=chat_type,
|
|
user_id=(str(user.id) if user else (str(chat.id) if chat_type in {"dm", "channel"} else None)),
|
|
user_name=user_name, thread_id=thread_id_str, chat_topic=chat_topic, message_id=str(message.message_id),
|
|
is_bot=bool(getattr(user, "is_bot", False)) if user else False)
|
|
reply_to_id, reply_to_text = self._reply_context(message)
|
|
from gateway.platforms.base import resolve_channel_prompt # per-channel/topic ephemeral prompt
|
|
from plugins.platforms.telegram.telegram_context import group_identity_prompt
|
|
_chat_id_str = str(chat.id)
|
|
channel_prompt = resolve_channel_prompt(self.config.extra, thread_id_str or _chat_id_str, _chat_id_str if thread_id_str else None)
|
|
return MessageEvent(
|
|
text=message.text or "", message_type=msg_type, source=source, raw_message=message,
|
|
message_id=str(message.message_id), platform_update_id=update_id,
|
|
reply_to_message_id=reply_to_id, reply_to_text=reply_to_text, auto_skill=topic_skill,
|
|
channel_prompt=group_identity_prompt(self, message, channel_prompt),
|
|
timestamp=message.date)
|
|
|
|
# -- Message reactions (processing lifecycle) --
|
|
|
|
def _reactions_enabled(self) -> bool:
|
|
"""Reactions enabled via ``extra.reactions`` (YAML, per profile) or TELEGRAM_REACTIONS."""
|
|
configured = self.config.extra.get("reactions")
|
|
if configured is None:
|
|
configured = _scoped_gate_env("TELEGRAM_REACTIONS", "false")
|
|
return str(configured).lower() not in {"false", "0", "no"}
|
|
|
|
async def _set_reaction(self, chat_id: str, message_id: str, emoji: Optional[str]) -> bool:
|
|
"""Set a single emoji reaction (``None`` clears all bot-set reactions, the documented Bot API way)."""
|
|
if not self._bot:
|
|
return False
|
|
try:
|
|
await self._bot.set_message_reaction(chat_id=normalize_telegram_chat_id(chat_id), message_id=int(message_id), reaction=emoji)
|
|
return True
|
|
except Exception as e:
|
|
if emoji is None:
|
|
logger.debug("[%s] clear reactions failed: %s", self.name, _redact_telegram_error_text(e))
|
|
else:
|
|
logger.debug("[%s] set_message_reaction failed (%s): %s", self.name, emoji, _redact_telegram_error_text(e))
|
|
return False
|
|
|
|
async def _clear_reactions(self, chat_id: str, message_id: str) -> bool:
|
|
"""Clear all bot-set reactions."""
|
|
return await self._set_reaction(chat_id, message_id, None)
|
|
|
|
async def on_processing_start(self, event: MessageEvent) -> None:
|
|
"""Add an in-progress reaction when message processing begins."""
|
|
if not self._reactions_enabled():
|
|
return
|
|
chat_id = getattr(event.source, "chat_id", None)
|
|
message_id = getattr(event, "message_id", None)
|
|
if chat_id and message_id:
|
|
await self._set_reaction(chat_id, message_id, "\U0001f440")
|
|
|
|
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
|
|
"""Swap the in-progress reaction for a final success/failure reaction (set_message_reaction
|
|
replaces, not adds); CANCELLED explicitly clears the 👀."""
|
|
if not self._reactions_enabled():
|
|
return
|
|
chat_id = getattr(event.source, "chat_id", None)
|
|
message_id = getattr(event, "message_id", None)
|
|
if not (chat_id and message_id):
|
|
return
|
|
if outcome == ProcessingOutcome.CANCELLED:
|
|
await self._clear_reactions(chat_id, message_id)
|
|
else:
|
|
await self._set_reaction(chat_id, message_id, "\U0001f44d" if outcome == ProcessingOutcome.SUCCESS else "\U0001f44e")
|
|
|
|
|
|
# -- Plugin registration glue: register(ctx) plus the hook implementations (adapter factory, YAML→env/extra
|
|
# config, setup wizard, standalone sender).
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────── Plugin migration glue (#41112 /
|
|
# #3823) Added when the Telegram adapter (+ its telegram_network satellite) moved from gateway/platforms/
|
|
# into this bundled plugin. Mirrors the Discord (#24356) / Slack migrations: a register(ctx) entry point
|
|
# plus hook implementations that replace the per-platform core touchpoints (the Platform.TELEGRAM branch in
|
|
# gateway/run.py, the telegram_cfg YAML→env/extra block in gateway/config.py, the _setup_telegram wizard +
|
|
# _PLATFORMS["telegram"] static dict in hermes_cli/{setup,gateway}.py, and the _send_telegram dispatch in
|
|
# tools/send_message_tool.py). Telegram uses the generic token connected check, so no is_connected override
|
|
# is needed. ──────────────────────────────────────────────────────────────────────────
|
|
def _resolve_notifications_mode() -> str:
|
|
"""Notification mode (all/important) from env, else config.yaml display.platforms.telegram.notifications."""
|
|
mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "")
|
|
if not mode:
|
|
try:
|
|
from gateway.config import load_gateway_config
|
|
from gateway.run import cfg_get
|
|
_raw = cfg_get(load_gateway_config(), "display", "platforms", "telegram", "notifications")
|
|
if _raw not in {None, ""}:
|
|
mode = str(_raw).strip().lower()
|
|
except Exception:
|
|
pass
|
|
mode = mode or "important"
|
|
if mode not in {"all", "important"}:
|
|
logger.warning("Unknown telegram notifications mode '%s', defaulting to 'important' (valid: all, important)", mode)
|
|
mode = "important"
|
|
return mode
|
|
|
|
|
|
def _build_adapter(config):
|
|
"""Construct TelegramAdapter and apply the notification mode."""
|
|
adapter = TelegramAdapter(config)
|
|
try:
|
|
adapter._notifications_mode = _resolve_notifications_mode()
|
|
except Exception:
|
|
adapter._notifications_mode = "important"
|
|
return adapter
|
|
|
|
|
|
def _is_connected(config) -> bool:
|
|
"""Connected when a bot token is configured (env or PlatformConfig.token); the SDK being importable is
|
|
not enough or the plugin-enable pass would enable Telegram on any machine with it installed."""
|
|
token = getattr(config, "token", None)
|
|
if not token:
|
|
import hermes_cli.gateway as gateway_mod
|
|
token = gateway_mod.get_env_value("TELEGRAM_BOT_TOKEN") or ""
|
|
return bool(str(token).strip())
|
|
|
|
|
|
async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False):
|
|
"""Out-of-process delivery (standalone_sender_fn) so deliver=telegram cron jobs succeed without the
|
|
gateway; delegates to the REST ``_send_telegram`` sender."""
|
|
token = getattr(pconfig, "token", None)
|
|
if not token:
|
|
from agent.secret_scope import get_secret # profile-scoped: never borrow another profile's token
|
|
token = get_secret("TELEGRAM_BOT_TOKEN", "") or ""
|
|
disable_link_previews = bool(getattr(pconfig, "extra", {}) and pconfig.extra.get("disable_link_previews"))
|
|
from tools.send_message_tool import _send_telegram
|
|
return await _send_telegram(
|
|
token, chat_id, message, media_files=media_files, thread_id=thread_id,
|
|
disable_link_previews=disable_link_previews, force_document=force_document)
|
|
|
|
|
|
def interactive_setup() -> None:
|
|
"""Configure Telegram credentials and allowlist via the CLI setup wizard (lazy import)."""
|
|
from hermes_cli import setup as _setup_mod
|
|
setup_platforms._setup_telegram()
|
|
|
|
|
|
def _apply_yaml_config(yaml_cfg: dict, telegram_cfg: dict) -> dict | None:
|
|
"""Translate config.yaml telegram: keys into TELEGRAM_* env vars and PlatformConfig.extra. Env vars
|
|
take precedence over YAML. Returns extras to merge into PlatformConfig.extra, or None.
|
|
|
|
Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy telegram_cfg block from
|
|
gateway/config.py::load_gateway_config().
|
|
"""
|
|
import json as _json
|
|
from gateway.platforms._shared import yaml_env_setter
|
|
extras: dict = {}
|
|
# Under multiplex a secondary profile's settings must NOT hit the process-global env (first-writer-wins
|
|
# would pin them for every profile, #72348); yaml_env_setter skips the write under its scope and the
|
|
# values flow via extra/secret scope instead.
|
|
_set_env = yaml_env_setter()
|
|
|
|
def _bridge_lower(key: str, env: str) -> None:
|
|
if key in telegram_cfg:
|
|
extras.setdefault(key, telegram_cfg[key])
|
|
_set_env(env, str(telegram_cfg[key]).lower())
|
|
|
|
def _bridge_gate(key: str, env: str, value: Any, *, seed_extra: bool = False) -> None:
|
|
"""CSV allowlist gate: list → comma-joined; env write skipped under multiplex secret scope."""
|
|
if value is None:
|
|
return
|
|
if seed_extra:
|
|
extras.setdefault(key, value)
|
|
_set_env(env, value)
|
|
|
|
if "disable_topic_auto_rename" in telegram_cfg:
|
|
extras.setdefault("disable_topic_auto_rename", telegram_cfg["disable_topic_auto_rename"])
|
|
_effective_rm = telegram_cfg.get("require_mention", yaml_cfg.get("require_mention"))
|
|
if _effective_rm is not None:
|
|
_set_env("TELEGRAM_REQUIRE_MENTION", str(_effective_rm).lower())
|
|
if "mention_patterns" in telegram_cfg:
|
|
_set_env("TELEGRAM_MENTION_PATTERNS", _json.dumps(telegram_cfg["mention_patterns"]))
|
|
for key, env in (
|
|
("exclusive_bot_mentions", "TELEGRAM_EXCLUSIVE_BOT_MENTIONS"), ("allow_bots", "TELEGRAM_ALLOW_BOTS"),
|
|
("bots_require_mention", "TELEGRAM_BOTS_REQUIRE_MENTION"),
|
|
("guest_mode", "TELEGRAM_GUEST_MODE", ), ("observe_unmentioned_group_messages", "TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES")):
|
|
_bridge_lower(key, env)
|
|
# No extras seed for allowed_chats / allowed_topics / group_allowed_chats: the shared-key loop already
|
|
# bridges them with their original type and this merge would clobber it.
|
|
for key, env, seed in (
|
|
("free_response_chats", "TELEGRAM_FREE_RESPONSE_CHATS", True), ("free_response_topics", "TELEGRAM_FREE_RESPONSE_TOPICS", False),
|
|
("allowed_chats", "TELEGRAM_ALLOWED_CHATS", False), ("allowed_topics", "TELEGRAM_ALLOWED_TOPICS", False),
|
|
("ignored_threads", "TELEGRAM_IGNORED_THREADS", True)):
|
|
_bridge_gate(key, env, telegram_cfg.get(key), seed_extra=seed)
|
|
_bridge_lower("reactions", "TELEGRAM_REACTIONS")
|
|
if "proxy_url" in telegram_cfg:
|
|
_set_env("TELEGRAM_PROXY", str(telegram_cfg["proxy_url"]).strip())
|
|
_telegram_extra = telegram_cfg.get("extra") if isinstance(telegram_cfg.get("extra"), dict) else {}
|
|
_telegram_rtm = telegram_cfg["reply_to_mode"] if "reply_to_mode" in telegram_cfg else _telegram_extra.get("reply_to_mode")
|
|
if _telegram_rtm is not None:
|
|
_set_env("TELEGRAM_REPLY_TO_MODE", "off" if _telegram_rtm is False else str(_telegram_rtm).lower())
|
|
_bridge_gate("allow_from", "TELEGRAM_ALLOWED_USERS", telegram_cfg.get("allow_from"))
|
|
_bridge_gate(
|
|
"group_allow_from", "TELEGRAM_GROUP_ALLOWED_USERS", telegram_cfg.get("group_allow_from") or _telegram_extra.get("group_allow_from"))
|
|
_bridge_gate(
|
|
"group_allowed_chats", "TELEGRAM_GROUP_ALLOWED_CHATS",
|
|
telegram_cfg.get("group_allowed_chats") or _telegram_extra.get("group_allowed_chats"))
|
|
for _key in ("guest_mode", "disable_link_previews", "observe_unmentioned_group_messages", "free_response_topics"):
|
|
if _key in telegram_cfg:
|
|
extras.setdefault(_key, telegram_cfg[_key])
|
|
# Pass through telegram-specific extra keys but EXCLUDE generic shared-config keys: _merge_platform_map
|
|
# already applied top-level-over-nested precedence and re-emitting them via dict.update() would undo it.
|
|
_GENERIC_MERGE_KEYS = {
|
|
"reply_prefix", "reply_in_thread", "reply_to_mode", "unauthorized_dm_behavior", "notice_delivery",
|
|
"require_mention", "channel_skill_bindings", "channel_prompts", "gateway_restart_notification", "allow_from",
|
|
"allow_admin_from", "dm_policy", "group_policy"}
|
|
for _k, _v in _telegram_extra.items():
|
|
if _k not in _GENERIC_MERGE_KEYS:
|
|
extras.setdefault(_k, _v)
|
|
return extras or None
|
|
|
|
|
|
def register(ctx) -> None:
|
|
"""Plugin entry point — called by the Hermes plugin system."""
|
|
ctx.register_platform(
|
|
name="telegram", label="Telegram", adapter_factory=_build_adapter, check_fn=telegram_deps_present,
|
|
ensure_deps_fn=check_telegram_requirements, is_connected=_is_connected, required_env=["TELEGRAM_BOT_TOKEN"],
|
|
install_hint="Run `hermes setup` to install Telegram support.", setup_fn=interactive_setup, apply_yaml_config_fn=_apply_yaml_config,
|
|
allowed_users_env="TELEGRAM_ALLOWED_USERS", allow_all_env="TELEGRAM_ALLOW_ALL_USERS", cron_deliver_env_var="TELEGRAM_HOME_CHANNEL",
|
|
standalone_sender_fn=_standalone_send, max_message_length=4096, emoji="✈️", allow_update_command=True)
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
import threading # noqa: F401,E402
|
|
|
|
|
|
_PLUGIN_COMPAT_LAZY = {
|
|
'atomic_replace': ('utils', 'atomic_replace'),
|
|
'cache_document_from_bytes': ('gateway.platforms.base', 'cache_document_from_bytes'),
|
|
}
|
|
|
|
|
|
def __getattr__(name): # PEP 562 — lazy so no import cycles
|
|
target = _PLUGIN_COMPAT_LAZY.get(name)
|
|
if target is None:
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
import importlib
|
|
from hermes_cli.plugin_compat import warn_once
|
|
warn_once(__name__, name, *target)
|
|
return getattr(importlib.import_module(target[0]), target[1])
|
|
# ---- END PLUGIN-COMPAT ----
|