8343 lines
410 KiB
Python
8343 lines
410 KiB
Python
"""
|
|
Telegram platform adapter.
|
|
|
|
Uses python-telegram-bot library for:
|
|
- Receiving messages from users/groups
|
|
- Sending responses back
|
|
- Handling media and commands
|
|
"""
|
|
|
|
import asyncio
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
from agent.deadline import run_bounded_async
|
|
|
|
|
|
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 _scoped_gate_env(name: str, default: str = "") -> str:
|
|
"""Read a TELEGRAM_*/GATEWAY_* gate env var per-profile.
|
|
|
|
Under multiplex_profiles the process env is first-writer-wins, so raw ``os.getenv`` can
|
|
return ANOTHER profile's allowlist. Falls back to ``os.getenv`` outside multiplex.
|
|
"""
|
|
try:
|
|
from gateway.authz_mixin import _platform_gate_env
|
|
|
|
return _platform_gate_env(name, default)
|
|
except Exception:
|
|
return (os.getenv(name) or default).strip()
|
|
|
|
|
|
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):
|
|
"""Await with a wall-clock (thread-timer) deadline that survives a blocked event loop.
|
|
|
|
Wrapper over ``run_bounded_async``: abandons cancellation-shielded tasks (PTB/httpcore
|
|
init inside anyio scopes) and runs ``on_abandon`` detached so an abandoned initialize()
|
|
can't leak an httpx pool. Raises ``asyncio.TimeoutError`` on expiry (PTB retry ladder).
|
|
"""
|
|
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 exceptions and re-raises accumulate ``__context__`` chains, so
|
|
classifiers must inspect the whole graph, not just the top frame.
|
|
"""
|
|
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
|
|
cause = getattr(cur, "__cause__", None)
|
|
context = getattr(cur, "__context__", None)
|
|
if cause is not None:
|
|
stack.append(cause)
|
|
if context is not None:
|
|
stack.append(context)
|
|
|
|
|
|
async def _first_completed(*futures: "asyncio.Future") -> None:
|
|
"""Return when the first of ``futures`` completes; losers are NOT cancelled."""
|
|
await asyncio.wait(set(futures), return_when=asyncio.FIRST_COMPLETED)
|
|
|
|
|
|
async def _shutdown_abandoned_app(app) -> None:
|
|
"""Release a half-built PTB app's httpx transports after init was abandoned.
|
|
|
|
``app.shutdown()`` is gated on ``_initialized`` flags a wedged ``initialize()`` never set,
|
|
so it no-ops and leaks the pool. ``HTTPXRequest`` builds its client eagerly and its
|
|
``shutdown()`` gates only on ``client.is_closed``, so we also close transports 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)
|
|
requests = getattr(bot, "_request", None) if bot is not None else None
|
|
if not requests:
|
|
return
|
|
for request in requests:
|
|
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 = Any
|
|
Bot = Any
|
|
Message = Any
|
|
InlineKeyboardButton = Any
|
|
InlineKeyboardMarkup = Any
|
|
LinkPreviewOptions = None
|
|
Application = Any
|
|
CommandHandler = Any
|
|
CallbackQueryHandler = Any
|
|
InlineQueryHandler = Any
|
|
TypeHandler = Any
|
|
TelegramMessageHandler = Any
|
|
HTTPXRequest = Any
|
|
filters = None
|
|
ParseMode = None
|
|
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, MessageEvent, MessageType, ProcessingOutcome, 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 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 a ``flood_control:{wait}`` SendResult so the caller's retry machinery
|
|
# (delivery ledger, streaming fallback) owns the wait instead of pinning a worker.
|
|
_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 audio length in whole seconds for outgoing voice/audio (None if unreadable).
|
|
|
|
Telegram only derives duration from metadata for short clips; longer ones render as 0:00,
|
|
so we pass it explicitly. Tries wave (WAV), mutagen, then ffprobe — mirrors
|
|
``gateway.run._probe_audio_duration``. Blocking: call via ``asyncio.to_thread``.
|
|
"""
|
|
ext = os.path.splitext(path)[1].lower()
|
|
if ext == ".wav":
|
|
try:
|
|
import wave
|
|
with wave.open(path, "rb") as wf:
|
|
rate = wf.getframerate() or 0
|
|
if rate:
|
|
secs = _coerce_duration_seconds(wf.getnframes() / float(rate))
|
|
if secs is not None:
|
|
return secs
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
import mutagen
|
|
audio = mutagen.File(path)
|
|
secs = _coerce_duration_seconds(getattr(getattr(audio, "info", None), "length", None))
|
|
if secs is not None:
|
|
return secs
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
import shutil
|
|
import subprocess
|
|
if shutil.which("ffprobe"):
|
|
proc = subprocess.run(
|
|
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
"-of", "default=noprint_wrappers=1:nokey=1", path],
|
|
capture_output=True, text=True, 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 probe: is python-telegram-bot importable? Must never install anything.
|
|
|
|
Registry ``check_fn`` (status displays, config loading). The ACTIVE lazy-installer is
|
|
``check_telegram_requirements`` (``ensure_deps_fn``, run from ``create_adapter()``).
|
|
"""
|
|
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:
|
|
from telegram import Update as _Update, Bot as _Bot, Message as _Message
|
|
from telegram import InlineKeyboardButton as _IKB, InlineKeyboardMarkup as _IKM
|
|
try:
|
|
from telegram import LinkPreviewOptions as _LPO
|
|
except ImportError:
|
|
_LPO = None
|
|
from telegram.ext import (
|
|
Application as _App, CommandHandler as _CH, CallbackQueryHandler as _CQH,
|
|
InlineQueryHandler as _IQH, MessageHandler as _MH, ContextTypes as _CT,
|
|
filters as _filters, TypeHandler as _TH,
|
|
)
|
|
from telegram.constants import ParseMode as _PM, ChatType as _CtT
|
|
from telegram.request import HTTPXRequest as _HR
|
|
except ImportError:
|
|
return False
|
|
Update = _Update
|
|
Bot = _Bot
|
|
Message = _Message
|
|
InlineKeyboardButton = _IKB
|
|
InlineKeyboardMarkup = _IKM
|
|
LinkPreviewOptions = _LPO
|
|
Application = _App
|
|
CommandHandler = _CH
|
|
CallbackQueryHandler = _CQH
|
|
InlineQueryHandler = _IQH
|
|
TelegramMessageHandler = _MH
|
|
ContextTypes = _CT
|
|
filters = _filters
|
|
ParseMode = _PM
|
|
ChatType = _CtT
|
|
HTTPXRequest = _HR
|
|
TypeHandler = _TH
|
|
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)
|
|
# italic: word-boundary guarded so snake_case like my_variable_name survives
|
|
cleaned = re.sub(r'(?<!\w)_([^_]+)_(?!\w)', r'\1', cleaned)
|
|
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 ``(N/M)`` chunk markers onto their own line after a closing code fence.
|
|
|
|
``truncate_message()`` appends the indicator to a chunk that may end with a synthesized
|
|
closing fence, yielding ````` \\(1/2\\)`` — Telegram rejects that as a fence and falls
|
|
back to plain text.
|
|
"""
|
|
return _CHUNK_INDICATOR_ON_FENCE_RE.sub(r'```\n\g<indicator>', text)
|
|
|
|
|
|
# MarkdownV2 has no table syntax ('|' is just an escaped literal), so pipe tables are
|
|
# converted to bullet groups by the shared 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,
|
|
)
|
|
|
|
|
|
# Rich-message newline normalization. Protected regions whose internal newlines must stay
|
|
# bare: fenced code blocks OR GFM pipe-table blocks (header row, delimiter row, data rows).
|
|
# Telegram renders both natively; injected hard breaks would corrupt them.
|
|
_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 single ``\\n`` to Markdown hard breaks (two trailing spaces) for sendRichMessage.
|
|
|
|
Markdown treats a lone ``\\n`` as a soft break, collapsing multi-line content into one
|
|
paragraph. ``\\n\\n``, fenced code and pipe-table blocks are left untouched.
|
|
"""
|
|
if not text or '\n' not in text:
|
|
return text
|
|
out: list[str] = []
|
|
# Inject hard breaks only in the prose between protected regions.
|
|
pos = 0
|
|
for m in _RICH_PROTECTED_REGION_RE.finditer(text):
|
|
prose = text[pos:m.start()]
|
|
out.append(re.sub(r'(?<!\n)\n(?!\n)', ' \n', prose))
|
|
out.append(m.group(0)) # protected region kept verbatim
|
|
pos = m.end()
|
|
tail = text[pos:]
|
|
out.append(re.sub(r'(?<!\n)\n(?!\n)', ' \n', tail))
|
|
return ''.join(out)
|
|
|
|
|
|
# Internal safety bounds (not user knobs) so no reconnect/teardown path can hang on a dead
|
|
# CLOSE-WAIT socket that PTB's polling task is blocked on in epoll and never wakes from.
|
|
_UPDATER_STOP_TIMEOUT = 15.0 # `await updater.stop()`, applied identically at every site
|
|
# Other disconnect() steps: short, so a cancellation-swallowing PTB close can't burn the
|
|
# gateway's fatal-handler budget before the reconnect queue is useful.
|
|
_DISCONNECT_STEP_TIMEOUT = 2.0
|
|
# start_polling() can hang on a degraded pool after _drain_polling_connections() (both
|
|
# primary and fallback endpoints unreachable); bound it so the heartbeat can recover.
|
|
_UPDATER_START_TIMEOUT = 30.0
|
|
# Initial connect is unhealthy until getUpdates completes one round trip. Unlike reconnect,
|
|
# bootstrap must fail closed so GatewayRunner disposes the adapter and retries fresh.
|
|
_INITIAL_POLLING_PROGRESS_TIMEOUT = 60.0
|
|
# shutdown()/initialize() on the getUpdates request rebuild the pool; a wedged CLOSE-WAIT
|
|
# socket can block that forever, freezing _polling_error_task and gating every escalation
|
|
# path behind its in-flight guard. Bound the drain so the ladder reaches fatal-restart.
|
|
_DRAIN_TIMEOUT = 15.0
|
|
# Cause-agnostic wedged-recovery watchdog: every recovery path gates on
|
|
# ``_polling_error_task.done()``, so a task wedged on an unbounded await leaves the gateway
|
|
# silently deaf. Healthy worst case is stop + 2x drain + start + 60s backoff ≈ 135s, so
|
|
# 300s in flight is unambiguously stuck and the heartbeat force-escalates.
|
|
_POLLING_ERROR_TASK_STUCK_TIMEOUT = 300.0
|
|
# A generation is unhealthy until getUpdates returns successfully; exceeds one idle long-poll.
|
|
_POLLING_PROGRESS_TIMEOUT = 60.0
|
|
# Telegram answers a long-poll within ~50s, so no round-trip for ~3x that — while get_me()
|
|
# is healthy and nothing is queued server-side — means the consumer is wedged on a socket
|
|
# that never raises (CLOSE-WAIT behind a TUN/proxy route flip) and no other probe sees it.
|
|
_POLLING_STALL_TIMEOUT = 150.0
|
|
# Telegram transcodes video before answering sendVideo, outlasting the 20s read timeout the
|
|
# rest of the Bot API uses. Only media sends get this budget; kept modest because it is also
|
|
# how long a user waits to hear the attachment failed.
|
|
_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)
|
|
# Bot API 10.1 Rich Messages cap raw text at 32,768 chars; above that use legacy chunking.
|
|
RICH_MESSAGE_MAX_CHARS = 32768
|
|
# Chunk near this length ⇒ a Telegram client-side split continuation is almost certain.
|
|
_SPLIT_THRESHOLD = 4000
|
|
MEDIA_GROUP_WAIT_SECONDS = 0.8
|
|
# Cap on inbound events held across a disconnect window; oldest dropped first.
|
|
HELD_INBOUND_MAX = 64
|
|
_GENERAL_TOPIC_THREAD_ID = "1"
|
|
# send() can race a disconnect/reconnect blip; failing "Not connected" (retryable=False)
|
|
# parks the answer in the delivery ledger until next boot. Wait briefly for _bot (or a
|
|
# replacement adapter) instead. Same idea as QQBot._wait_for_reconnection.
|
|
_RECONNECT_WAIT_SECONDS = 15.0
|
|
_RECONNECT_POLL_INTERVAL = 0.5
|
|
|
|
# edit_message applies MarkdownV2 only on the finalize=True path; without this flag
|
|
# stream_consumer._send_or_edit skips the final edit when raw text is unchanged.
|
|
REQUIRES_EDIT_FINALIZE: bool = True
|
|
# Retrying a turn-final edit burns the same flood budget while the answer sits undelivered.
|
|
FALLBACK_ON_FINAL_EDIT_FLOOD: bool = True
|
|
# A failed final edit can leave clients with a partial/non-durable preview; resend fresh.
|
|
RESEND_FINAL_ON_EMPTY_STREAM_FALLBACK: bool = True
|
|
|
|
# Adaptive text-batch ingress, tuned for "feels instant": ≤320 codepoints settle in
|
|
# ~180ms, ≤1024 in ~240ms, longer waits the configured cap. Always clamped to
|
|
# ``_text_batch_delay_seconds`` so an operator can lower the cap via env var.
|
|
_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)
|
|
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 (sendRichMessage / rich_message param) render constructs
|
|
# MarkdownV2 degrades (tables, task lists, <details>, block math). Keep opt-in: current
|
|
# clients make rich messages hard to copy as plain text, which is worse for command
|
|
# snippets and mobile handoffs. Enable via platforms.telegram.extra.rich_messages.
|
|
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
|
|
# Separate opt-in: macOS/Desktop can leave rich draft frames overlaid until redraw.
|
|
# rich_messages on + rich_drafts off keeps native draft *transport* and only skips
|
|
# rich draft *rendering*; the final reply still lands via sendRichMessage.
|
|
self._rich_drafts_enabled: bool = self._coerce_bool_extra("rich_drafts", False)
|
|
# Latched off after a capability failure (e.g. older PTB without the endpoint).
|
|
self._rich_send_disabled: bool = False
|
|
self._rich_draft_disabled: bool = False
|
|
# Transient sendChatAction failures recur on every keep-typing tick during a long
|
|
# model call; back off per chat so an outage doesn't spam the API/logs.
|
|
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 (see _calc_text_batch_delay).
|
|
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._pending_text_batches: Dict[str, MessageEvent] = {}
|
|
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
|
self._drop_delayed_deliveries = False
|
|
# Held across disconnect: PTB advances the polling 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_conflict_count: int = 0
|
|
self._polling_conflict_recovery_generation: Optional[int] = None
|
|
self._polling_network_error_count: int = 0
|
|
self._polling_generation: int = 0
|
|
self._polling_progress_event = asyncio.Event()
|
|
self._polling_progress_accepting: bool = False
|
|
self._polling_progress_verifier_task: Optional[asyncio.Task] = None
|
|
self._polling_teardown_started: bool = False
|
|
self._polling_error_callback_ref = None
|
|
self._polling_heartbeat_task: Optional[asyncio.Task] = None
|
|
# Stall watchdog: generation start and last successful getUpdates (None = unknown).
|
|
self._polling_generation_started_monotonic: Optional[float] = None
|
|
self._polling_last_progress_monotonic: Optional[float] = None
|
|
# Live @username. PTB caches getMe() in Bot._bot_user at initialize() and only rewrites
|
|
# it inside get_me(), so a BotFather rename leaves self._bot.username stale. All
|
|
# mention/routing comparisons read _current_bot_username() instead.
|
|
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
|
|
self._bot_identity_refresh_task: Optional[asyncio.Task] = None
|
|
# Consecutive heartbeat probes seeing queued updates the poller isn't consuming.
|
|
# get_me() can't see this (send path healthy, getUpdates wedged), so the heartbeat
|
|
# probes get_webhook_info().pending_update_count and escalates after two.
|
|
self._polling_pending_stuck_count: int = 0
|
|
# Consecutive probes finding the updater stopped (running=False) in polling mode with
|
|
# no reconnect in flight — the long-poll task is simply gone, so no probe or PTB
|
|
# error_callback ever fires and the gateway silently stops receiving.
|
|
self._polling_not_running_count: int = 0
|
|
# Degraded until getUpdates makes progress (start_polling() return and getMe() on the
|
|
# general path are NOT polling-health signals). While True, send() short-circuits to
|
|
# failure so callers (cron live-adapter branch) fall through to standalone delivery.
|
|
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: sets the bot's short description to "Online"/"Offline" on
|
|
# connect/clean disconnect — bots have no presence dot. Off by default because it
|
|
# mutates the GLOBAL profile; opt in via extra.status_indicator/status_online/status_offline.
|
|
self._status_indicator_enabled: bool = bool(
|
|
self.config.extra.get("status_indicator", False)
|
|
)
|
|
self._status_online_text: str = str(self.config.extra.get("status_online", "Online"))
|
|
self._status_offline_text: str = str(self.config.extra.get("status_offline", "Offline"))
|
|
self._dm_topics_config: List[Dict[str, Any]] = self.config.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 self.config.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
|
|
# confirm_id → session_key (see GatewayRunner._request_slash_confirm)
|
|
self._slash_confirm_state: Dict[str, str] = {}
|
|
# clarify_id → session_key (see GatewayRunner clarify_callback wiring)
|
|
self._clarify_state: Dict[str, str] = {}
|
|
# "important" (default): only final responses, approvals and slash confirmations
|
|
# notify; progress/streaming/status go out with disable_notification.
|
|
# "all": every message notifies (opt-in via display.platforms.telegram.notifications).
|
|
self._notifications_mode: str = "important"
|
|
# send_or_update_status(): {(chat_id, status_key) -> message_id} so repeat calls edit
|
|
# the same bubble instead of appending.
|
|
self._status_message_ids: Dict[tuple, str] = {}
|
|
# Last truncated mid-stream preview per (chat_id, message_id). Once an oversized
|
|
# stream saturates the 4096 cap every edit truncates to the SAME text; resending is a
|
|
# no-op that still burns flood budget (200s+ penalties). Entries dropped on finalize.
|
|
self._last_overflow_preview: Dict[tuple, str] = {}
|
|
# Post-connect housekeeping (command menu + DM topics) runs off the connect path so a
|
|
# slow Bot API call (set_my_commands stall) can't blow the gateway connect timeout.
|
|
self._post_connect_task: Optional[asyncio.Task] = None
|
|
|
|
@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()
|
|
# Drain the hold queue — PTB will not redeliver these events.
|
|
self._schedule_held_inbound_redispatch()
|
|
|
|
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 and refuse
|
|
# further holds (teardown salvage / late enqueue must not re-populate it).
|
|
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"]:
|
|
"""Return the live adapter if the reconnect watcher replaced us in ``runner.adapters``.
|
|
|
|
An in-flight ``send()`` still holds the old instance whose ``_bot`` stays None, so
|
|
waiting only on ``self._bot`` would drop the final reply.
|
|
"""
|
|
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 after a transient drop.
|
|
|
|
Returns True if sending can proceed; False on wait 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-error started — delayed flushes must not dispatch.
|
|
|
|
Buffered flushes sit behind an asyncio.sleep(); if disconnect wins the race they'd
|
|
spawn an agent on a torn-down session. Callers must NOT destroy the event (PTB already
|
|
advanced the offset) — use ``_hold_inbound_event`` and redispatch on reconnect.
|
|
"""
|
|
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.
|
|
|
|
Triggered by ``_mark_connected``, holds created while connected (cancel-after-pop),
|
|
and the end of a drain pass with leftovers. No-op while down or after permanent fatal.
|
|
"""
|
|
if self._is_permanent_fatal():
|
|
return
|
|
if self._should_drop_delayed_delivery():
|
|
return
|
|
held = getattr(self, "_held_inbound_events", None)
|
|
if not held:
|
|
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
|
|
# Already draining on another task — that pass schedules any follow-up itself.
|
|
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 right now.
|
|
|
|
By enqueue/flush time PTB has already acked the update and advanced the offset, so
|
|
destroying the event is silent permanent loss. Hold it, redispatch from
|
|
``_mark_connected`` (or immediately if connected). Capped, identity-deduped;
|
|
permanent fatal discards. ``schedule=False`` inside a drain (avoids poison-event loops).
|
|
"""
|
|
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 = self._held_inbound_events
|
|
for existing in held:
|
|
if existing is event:
|
|
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()
|
|
|
|
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 while teardown can still cancel the single tracked task.
|
|
"""
|
|
if prior is not None and prior is not asyncio.current_task() and not prior.done():
|
|
prior.cancel()
|
|
try:
|
|
await prior
|
|
except asyncio.CancelledError:
|
|
pass
|
|
if self._is_permanent_fatal():
|
|
held = getattr(self, "_held_inbound_events", None)
|
|
if held:
|
|
n = len(held)
|
|
held.clear()
|
|
logger.warning(
|
|
"[Telegram] Redispatch aborted; discarded %d held inbound under non-retryable fatal",
|
|
n,
|
|
)
|
|
return
|
|
held = getattr(self, "_held_inbound_events", None)
|
|
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():
|
|
# Disconnect/fatal mid-drain — re-hold current + remainder.
|
|
self._hold_inbound_event(event, where="redispatch-interrupted", schedule=False)
|
|
for rest in events[idx + 1 :]:
|
|
self._hold_inbound_event(
|
|
rest, where="redispatch-interrupted", schedule=False
|
|
)
|
|
return
|
|
try:
|
|
await self.handle_message(event)
|
|
except asyncio.CancelledError:
|
|
self._hold_inbound_event(event, where="redispatch-cancelled", schedule=False)
|
|
for rest in events[idx + 1 :]:
|
|
self._hold_inbound_event(rest, where="redispatch-cancelled", schedule=False)
|
|
raise
|
|
except Exception:
|
|
# Retryable failure: re-hold current + remainder but do NOT reschedule now —
|
|
# a poison event would tight-loop. 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._hold_inbound_event(event, where="redispatch-failed", schedule=False)
|
|
for rest in events[idx + 1 :]:
|
|
self._hold_inbound_event(rest, where="redispatch-failed", schedule=False)
|
|
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":
|
|
return {}
|
|
if (metadata or {}).get("notify"):
|
|
return {}
|
|
return {"disable_notification": True}
|
|
|
|
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 = str(chat_type or "dm").strip().lower() or "dm"
|
|
if normalized_chat_type == "private":
|
|
normalized_chat_type = "dm"
|
|
elif normalized_chat_type == "supergroup":
|
|
normalized_chat_type = "forum" if thread_id is not None else "group"
|
|
|
|
# Preferred: the auth callback GatewayRunner injects (set_authorization_check) → full
|
|
# _is_user_authorized chain. Unlike the __self__ introspection below it also works for
|
|
# a secondary 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
|
|
|
|
# Legacy: resolve the runner off the bound handler (bare-adapter tests, direct embedding).
|
|
runner = getattr(getattr(self, "_message_handler", None), "__self__", None)
|
|
auth_fn = getattr(runner, "_is_user_authorized", None)
|
|
if callable(auth_fn):
|
|
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,
|
|
)
|
|
allowed_csv = _scoped_gate_env("TELEGRAM_ALLOWED_USERS").strip()
|
|
if not allowed_csv:
|
|
# Fail-closed: no allowlist means deny unless GATEWAY_ALLOW_ALL_USERS is set.
|
|
return _scoped_gate_env("GATEWAY_ALLOW_ALL_USERS").lower() in {"true", "1", "yes"}
|
|
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
|
|
return "*" in allowed_ids or normalized_user_id in allowed_ids
|
|
|
|
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 cannot inject content via the broadcast path.
|
|
"""
|
|
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
|
|
chat_type = str(getattr(chat, "type", "dm")).strip().lower() or "dm"
|
|
if chat_type == "private":
|
|
chat_type = "dm"
|
|
elif chat_type == "supergroup":
|
|
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 = (
|
|
"forum"
|
|
if thread_id_raw is not None and (is_topic_message or is_forum_group)
|
|
else "group"
|
|
)
|
|
thread_id = None
|
|
thread_id_raw = getattr(message, "message_thread_id", None)
|
|
if thread_id_raw is not None:
|
|
is_topic_message = bool(getattr(message, "is_topic_message", False))
|
|
is_forum_group = getattr(chat, "is_forum", False) is True
|
|
if (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):
|
|
"""Build the SessionSource for a ``message_reaction`` update's actor.
|
|
|
|
Like ``_source_from_message_for_auth`` but reactions carry ``user`` (or ``actor_chat``
|
|
for an anonymous admin) and ``chat``, no ``Message`` and no thread id.
|
|
|
|
Raises ``ValueError`` when actor, chat or message identity is absent so the post-auth
|
|
boundary fails closed rather than authorizing an incomplete source.
|
|
"""
|
|
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"
|
|
)
|
|
chat_type = str(getattr(chat, "type", "dm")).strip().lower() or "dm"
|
|
if chat_type == "private":
|
|
chat_type = "dm"
|
|
elif chat_type == "supergroup":
|
|
# Reactions carry no message_thread_id; is_forum is the only forum signal.
|
|
chat_type = "forum" if getattr(chat, "is_forum", False) is True else "group"
|
|
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.
|
|
|
|
Early auth must not short-circuit the pairing handshake when
|
|
``unauthorized_dm_behavior`` resolves to ``pair`` — including an allowlist plus an
|
|
explicit platform override opting back into pairing.
|
|
"""
|
|
if source.chat_type != "dm":
|
|
return False
|
|
# Bound-handler ``__self__`` is None under multiplex (profile closure); ``gateway_runner``
|
|
# is injected on every adapter and 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:
|
|
return (
|
|
behavior_fn(
|
|
Platform.TELEGRAM,
|
|
profile=getattr(source, "profile", None)
|
|
or getattr(self, "_owner_profile", None),
|
|
)
|
|
== "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 text 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 when pairing is the effective
|
|
unauthorized-DM behavior, so the pairing flow can run.
|
|
"""
|
|
source = self._source_from_message_for_auth(message)
|
|
user_id = source.user_id
|
|
# No identity → group service message (pin, new_chat_members…) or channel post without
|
|
# sender_chat; nothing authorizable, defer to _should_process_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.
|
|
chat_type = source.chat_type or ""
|
|
if chat_type in ("group", "forum", "channel"):
|
|
adapter_allow_from = self.config.extra.get("group_allow_from")
|
|
else:
|
|
adapter_allow_from = self.config.extra.get("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):
|
|
try:
|
|
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,
|
|
)
|
|
)
|
|
except Exception:
|
|
pass
|
|
if authorized is None:
|
|
# Runner's full auth chain. Prefer the set_authorization_check callback: it survives
|
|
# multiplex handler wrapping, whereas bound-handler __self__ is None for a profile
|
|
# closure (which silently default-denied allowlisted group members).
|
|
runner = getattr(getattr(self, "_message_handler", None), "__self__", None)
|
|
auth_fn = getattr(runner, "_is_user_authorized", None)
|
|
has_callback = getattr(self, "_authorization_check", None) is not None
|
|
if has_callback or callable(auth_fn):
|
|
# 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 callable(auth_fn):
|
|
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:
|
|
allowed_csv = _scoped_gate_env("TELEGRAM_ALLOWED_USERS").strip()
|
|
if not allowed_csv:
|
|
return True
|
|
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
|
|
authorized = "*" in allowed_ids or user_id in allowed_ids
|
|
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]:
|
|
if not metadata:
|
|
return None
|
|
thread_id = metadata.get("thread_id") or metadata.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]:
|
|
if not metadata:
|
|
return None
|
|
topic_id = metadata.get("direct_messages_topic_id") or metadata.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]:
|
|
if not metadata:
|
|
return None
|
|
reply_to = metadata.get("telegram_reply_to_message_id")
|
|
return int(reply_to) if reply_to is not None else None
|
|
|
|
@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 bool(
|
|
metadata
|
|
and metadata.get("telegram_dm_topic_reply_fallback")
|
|
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 metadata and metadata.get("telegram_dm_topic_reply_fallback"))
|
|
|
|
@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 metadata and metadata.get("telegram_dm_topic_reply_fallback"):
|
|
if reply_to_mode == "off":
|
|
return None
|
|
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 (loop wakeups,
|
|
restart-resumed follow-ups) prefer the Hermes topic's ``message_thread_id`` so they stay
|
|
in the active lane — the native DM-topic id renders in a different chat lane.
|
|
``reply_to_mode="off"`` suppresses the anchor but keeps ``message_thread_id``.
|
|
"""
|
|
if metadata and metadata.get("telegram_dm_topic_reply_fallback"):
|
|
if reply_to_mode == "off":
|
|
return {"message_thread_id": cls._message_thread_id_for_send(thread_id)}
|
|
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).
|
|
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}
|
|
direct_topic_id = cls._metadata_direct_messages_topic_id(metadata)
|
|
if direct_topic_id is not None:
|
|
return {
|
|
"message_thread_id": None, "direct_messages_topic_id": int(direct_topic_id)
|
|
}
|
|
return {}
|
|
return {"message_thread_id": cls._message_thread_id_for_send(thread_id)}
|
|
direct_topic_id = cls._metadata_direct_messages_topic_id(metadata)
|
|
if direct_topic_id is not None:
|
|
return {"message_thread_id": None, "direct_messages_topic_id": int(direct_topic_id)}
|
|
return {"message_thread_id": cls._message_thread_id_for_send(thread_id)}
|
|
|
|
def _thread_kwargs_for_draft(
|
|
self, chat_id: str, metadata: Optional[Dict[str, Any]]
|
|
) -> Dict[str, Any]:
|
|
"""Routing kwargs for ``sendMessageDraft`` / ``sendRichMessageDraft``.
|
|
|
|
Reuses ``_thread_kwargs_for_send`` so DM topics get an integer ``message_thread_id`` —
|
|
Telegram rejects the raw string ``thread_id``, which disabled draft streaming for the turn.
|
|
"""
|
|
thread_id = self._metadata_thread_id(metadata)
|
|
reply_to_id = self._reply_to_message_id_for_send(None, metadata)
|
|
kwargs = self._thread_kwargs_for_send(
|
|
chat_id, thread_id, metadata, reply_to_message_id=reply_to_id,
|
|
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 — omitting it hides the bubble entirely.
|
|
if not thread_id:
|
|
return None
|
|
return int(thread_id)
|
|
|
|
@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.
|
|
|
|
Otherwise ``gateway.run._recover_telegram_topic_thread_id`` keeps steering inbound to
|
|
the dead thread. Best-effort: never raises from a send-fallback path. Rows are
|
|
namespaced by profile; under ``profile_routes`` the send's ``hermes_profile`` metadata
|
|
wins over the adapter's own stamp, falling back to ``"default"``.
|
|
"""
|
|
if chat_id is None or thread_id is None:
|
|
return
|
|
store = getattr(self, "_session_store", None)
|
|
if store is None:
|
|
return
|
|
db = getattr(store, "_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.
|
|
|
|
Cases: (1) stale anchor — reply target deleted ("message to be replied not found");
|
|
(2) anchor-less synthetic send whose ``direct_messages_topic_id`` Bot API rejects with a
|
|
topic/thread BadRequest. Retry without routing rather than drop the message.
|
|
"""
|
|
if not (metadata and metadata.get("telegram_dm_topic_reply_fallback")):
|
|
return False
|
|
if 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 metadata.get("direct_messages_topic_id"): # topic id rejected → plain DM send
|
|
topic_markers = (
|
|
"direct_messages_topic", "message thread not found", "thread not found",
|
|
"topic_closed", "topic_deleted", "topic not found",
|
|
)
|
|
if any(marker in err_lower for marker in topic_markers):
|
|
return True
|
|
return False
|
|
|
|
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.
|
|
|
|
Deliberately narrower than "not network error": BadRequest/RetryAfter are transient at
|
|
connect time and must keep retrying. Type-based only; never match on message text.
|
|
"""
|
|
name = error.__class__.__name__.lower()
|
|
if name 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 _looks_like_connect_timeout(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.
|
|
"""
|
|
for cur in _iter_exception_graph(error):
|
|
name = cur.__class__.__name__.lower()
|
|
text = str(cur).lower()
|
|
if "connecttimeout" in name or "connect timeout" in text or "connect timed out" in text:
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def _looks_like_pool_timeout(error: Exception) -> bool:
|
|
"""True when a TimedOut wraps ``httpx.PoolTimeout``: the request never left the process.
|
|
|
|
PTB's message says "Request was *not* sent to Telegram", so re-sending cannot duplicate
|
|
(unlike a generic TimedOut). Matches the wrapped class AND the 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; finalization may re-send rich and delete the preview.
|
|
def _content_fits_rich_limits(self, content: str) -> bool:
|
|
"""Pre-check the 32,768-char cap only; other rich limits (blocks, nesting, columns)
|
|
surface as BadRequest, which ``_is_rich_fallback_error`` treats as 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 (sync auto-child) 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,
|
|
)
|
|
_RICH_CJK_RE = re.compile(
|
|
"["
|
|
"\u3040-\u30ff" # Hiragana, Katakana
|
|
"\u3400-\u4dbf" # CJK Extension A
|
|
"\u4e00-\u9fff" # CJK Unified Ideographs
|
|
"\uac00-\ud7af" # Hangul syllables
|
|
"\uf900-\ufaff" # CJK Compatibility Ideographs
|
|
"\U00020000-\U000323af" # CJK extensions and compatibility supplement
|
|
"]"
|
|
)
|
|
|
|
def _has_telegram_desktop_details_math_crash_shape(self, content: str) -> bool:
|
|
"""True for math inside a <details> block — crashes Telegram Desktop 6.9.1 (tdesktop#30808).
|
|
|
|
The Bot API accepts the payload, so we must skip rich delivery up front.
|
|
"""
|
|
if not content:
|
|
return False
|
|
for details_block in self._RICH_DETAILS_RE.findall(content):
|
|
if self._RICH_MATH_IN_DETAILS_RE.search(details_block):
|
|
return True
|
|
return False
|
|
|
|
def _has_telegram_desktop_cjk_rich_garble_shape(self, content: str) -> bool:
|
|
"""True for CJK content: Telegram Mac/Desktop rich rendering leaves overlapping glyphs."""
|
|
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.
|
|
"""
|
|
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_eligible(self, content: str) -> bool:
|
|
"""Rich eligibility ignoring ``expect_edits``.
|
|
|
|
``_try_edit_rich`` needs this: a streamed preview carries ``expect_edits=True`` to stay
|
|
editable mid-stream, but the FINAL edit should still upgrade to rich.
|
|
"""
|
|
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 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 _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:
|
|
"""Whether to replace a streamed preview with a fresh rich final — DM topics only.
|
|
|
|
Root DMs stay off: draft streaming has no preview message_id and an in-place rich edit
|
|
would duplicate a live draft. DM *topics* often reject sendMessageDraft and degrade to
|
|
edit-in-place, whose MarkdownV2 preview Telegram refuses to rich-edit — a fresh
|
|
sendRichMessage plus deleting the preview is the only way to keep native tables.
|
|
"""
|
|
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 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."""
|
|
if (
|
|
getattr(self, "_rich_messages_enabled", True)
|
|
and not getattr(self, "_rich_send_disabled", False)
|
|
and self._bot_supports_rich()
|
|
):
|
|
return self.RICH_MESSAGE_MAX_CHARS
|
|
return None
|
|
|
|
def _rich_message_payload(
|
|
self, content: str, *, skip_entity_detection: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""Build the ``InputRichMessage`` from RAW markdown (single newlines → hard breaks).
|
|
|
|
Never pass ``format_message(content)`` — 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.
|
|
"""
|
|
name = exc.__class__.__name__.lower()
|
|
if name in {"endpointnotfound", "invalidtoken"}:
|
|
return True
|
|
if isinstance(exc, (AttributeError, TypeError, NotImplementedError)):
|
|
return True
|
|
if 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 on purpose: anything not clearly permanent is transient — the
|
|
rich request may have reached Telegram, so a legacy resend risks a duplicate.
|
|
"""
|
|
if self._is_bad_request_error(exc):
|
|
return True
|
|
if 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 telegram_dm_topic_reply_fallback path is an
|
|
explicit opt-in to "message_thread_id alone is enough" — don't fail loud just 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 bool(metadata and metadata.get("telegram_dm_topic_reply_fallback"))
|
|
)
|
|
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, let legacy
|
|
handle it" (DM-topic fail-loud case; legacy owns the refuse 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
|
|
)
|
|
# Refusing to send outside the requested DM topic — defer to legacy (canonical
|
|
# fail-loud SendResult). 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
|
|
|
|
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()."""
|
|
err_str = str(exc).lower()
|
|
try:
|
|
from telegram.error import TimedOut as _TimedOut
|
|
except (ImportError, AttributeError):
|
|
_TimedOut = None
|
|
is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str
|
|
is_connect_timeout = self._looks_like_connect_timeout(exc)
|
|
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=(is_connect_timeout or not is_timeout),
|
|
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`` send.
|
|
|
|
Returns a SendResult (success, or a transient failure the caller must NOT
|
|
legacy-resend), or ``None`` = fall back to legacy MarkdownV2 (permanent/capability
|
|
error or DM-topic skip).
|
|
"""
|
|
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: Dict[str, Any] = {
|
|
"chat_id": normalize_telegram_chat_id(chat_id),
|
|
"rich_message": self._rich_message_payload(content),
|
|
}
|
|
# Only forward non-None routing keys: direct_messages_topic_id comes paired
|
|
# with message_thread_id=None, which must not hit the raw endpoint.
|
|
payload.update({k: v for k, v in thread_kwargs.items() if v is not None})
|
|
payload.update(self._notification_kwargs(metadata))
|
|
if getattr(self, "_disable_link_previews", False):
|
|
payload["link_preview_options"] = {"is_disabled": True}
|
|
if reply_to_id is not None:
|
|
# sendRichMessage takes reply_parameters, NOT the legacy reply_to_message_id
|
|
# scalar; the Bot API silently ignores unknown params, dropping the anchor.
|
|
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._is_rich_fallback_error(exc):
|
|
if self._is_rich_capability_error(exc):
|
|
# Endpoint missing — latch rich off to avoid a doomed roundtrip per send.
|
|
self._rich_send_disabled = True
|
|
logger.debug(
|
|
"[%s] sendRichMessage rejected (%s) — falling back to MarkdownV2",
|
|
self.name, _redact_telegram_error_text(exc),
|
|
)
|
|
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)
|
|
message_id = None
|
|
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)
|
|
|
|
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``).
|
|
|
|
Lets a streamed preview finalize as rich without send+delete. Same error
|
|
contract as :meth:`_try_send_rich`: success → SendResult(True); permanent/
|
|
capability → ``None`` (legacy edit; capability latches rich off); transient →
|
|
SendResult(False) with retry semantics (may already be edited; no legacy resend).
|
|
"""
|
|
payload: Dict[str, Any] = {
|
|
"chat_id": normalize_telegram_chat_id(chat_id),
|
|
"message_id": int(message_id),
|
|
"rich_message": self._rich_message_payload(content),
|
|
}
|
|
# No topic routing on edits: message_thread_id/direct_messages_topic_id make
|
|
# Telegram reject the rich edit and force the legacy table-to-bullets fallback.
|
|
if getattr(self, "_disable_link_previews", False):
|
|
payload["link_preview_options"] = {"is_disabled": True}
|
|
try:
|
|
# Raw result; no return_type=Message (see _try_send_rich).
|
|
await self._bot.do_api_request("editMessageText", api_kwargs=payload)
|
|
except Exception as exc:
|
|
if self._is_rich_fallback_error(exc):
|
|
if self._is_rich_capability_error(exc):
|
|
self._rich_send_disabled = True
|
|
# "Message is not modified" = successful no-op; skip the redundant legacy edit.
|
|
if "not modified" in str(exc).lower():
|
|
return SendResult(success=True, message_id=message_id)
|
|
logger.debug(
|
|
"[%s] rich editMessageText rejected (%s) — falling back to MarkdownV2 edit",
|
|
self.name, _redact_telegram_error_text(exc),
|
|
)
|
|
return None
|
|
if "not modified" in str(exc).lower():
|
|
return SendResult(success=True, message_id=message_id)
|
|
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 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()
|
|
)
|
|
|
|
async def _try_send_rich_draft(
|
|
self, chat_id: str, draft_id: int, content: str, metadata: Optional[Dict[str, Any]]
|
|
) -> bool:
|
|
"""Emit one ``sendRichMessageDraft`` preview frame; True on success.
|
|
|
|
Draft frames are ephemeral (overwritten by the next frame / final send), so a
|
|
lost or duplicate frame is harmless: any failure returns False and the caller
|
|
renders the legacy draft. Capability failures latch ``_rich_draft_disabled``.
|
|
"""
|
|
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:
|
|
ok = await self._bot.do_api_request("sendRichMessageDraft", api_kwargs=payload)
|
|
return bool(ok)
|
|
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.
|
|
|
|
Network errors (esp. via proxies like sing-box) leave half-closed httpx
|
|
connections occupying pool slots until ``Pool timeout: All connections in the
|
|
connection pool are occupied.`` Only ``_request[0]`` (getUpdates) is reset;
|
|
the general request (``_request[1]``) stays untouched so concurrent sends/edits
|
|
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
|
|
shutdown_ok = False
|
|
try:
|
|
# Bounded: a wedged CLOSE-WAIT socket can hang this close forever and freeze
|
|
# the reconnect ladder. Wall-clock thread deadline, not asyncio.wait_for:
|
|
# httpcore's pool close runs under AsyncShieldCancellation and wedges wait_for.
|
|
await _await_with_thread_deadline(polling_req.shutdown(), timeout=_DRAIN_TIMEOUT)
|
|
shutdown_ok = True
|
|
except Exception:
|
|
logger.debug(
|
|
"[%s] Polling request shutdown failed/timed out (non-fatal)", self.name, exc_info=True
|
|
)
|
|
if not shutdown_ok:
|
|
# 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 first.
|
|
self._orphan_and_rebuild_polling_client(polling_req)
|
|
try:
|
|
await _await_with_thread_deadline(polling_req.initialize(), timeout=_DRAIN_TIMEOUT)
|
|
logger.debug("[%s] Polling request pool drained before reconnect", self.name)
|
|
except Exception:
|
|
logger.debug(
|
|
"[%s] Polling request re-initialize failed/timed out (non-fatal)", self.name, exc_info=True
|
|
)
|
|
self._orphan_and_rebuild_polling_client(polling_req)
|
|
|
|
def _orphan_and_rebuild_polling_client(self, polling_req) -> None:
|
|
"""Replace a wedged HTTPXRequest client after a hung aclose().
|
|
|
|
``initialize()`` only calls ``_build_client()`` when the client reports
|
|
``is_closed``; after an abandoned shutdown() that flag stays false and polling
|
|
would reuse the dead connection. Swap in a fresh client and close the old one
|
|
in a detached, bounded background task so it can't block the reconnect ladder.
|
|
"""
|
|
old = getattr(polling_req, "_client", None)
|
|
build = getattr(polling_req, "_build_client", None)
|
|
if old is None or not callable(build):
|
|
return
|
|
if 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 so this cleanup can't hang and leak one task per reconnect.
|
|
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 _begin_polling_generation(self) -> tuple[int, asyncio.Event]:
|
|
"""Start accepting progress for a new getUpdates polling generation."""
|
|
if self._teardown_started:
|
|
self._polling_progress_accepting = False
|
|
self._send_path_degraded = True
|
|
progress = getattr(self, "_polling_progress_event", None)
|
|
if progress is None:
|
|
progress = asyncio.Event()
|
|
self._polling_progress_event = progress
|
|
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.
|
|
self._polling_generation_started_monotonic = time.monotonic()
|
|
self._polling_last_progress_monotonic = None
|
|
return self._polling_generation, self._polling_progress_event
|
|
|
|
def _record_polling_progress(self, generation: int) -> None:
|
|
"""Record successful getUpdates I/O for the current generation only."""
|
|
if self._teardown_started:
|
|
return
|
|
if not self._polling_progress_accepting:
|
|
return
|
|
if generation != self._polling_generation:
|
|
return
|
|
if not self._polling_progress_event.is_set():
|
|
# First confirmed round-trip resolves the "health pending" log line both
|
|
# reconnect paths end on; otherwise "healthy" and "hung" log identically.
|
|
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
|
|
|
|
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. Separate method so it is shared and testable.
|
|
"""
|
|
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
|
|
# (UTF-8 replacement decoding, BOM rejection).
|
|
envelope = request.parse_json_payload(payload)
|
|
except Exception:
|
|
return
|
|
if isinstance(envelope, dict) and envelope.get("ok") is True and "result" in envelope:
|
|
self._record_polling_progress(generation)
|
|
|
|
def _instrument_polling_request(self, request):
|
|
"""Instrument one dedicated PTB getUpdates request with progress tracking.
|
|
|
|
PTB request classes use ``__slots__`` and on Python 3.13 have no ``__dict__``,
|
|
so ``request.do_request = wrapper`` raises AttributeError. Instead re-tag the
|
|
instance to a thin ``__slots__ = ()`` subclass overriding ``do_request`` —
|
|
identical layout makes the ``__class__`` swap legal; works for test doubles too.
|
|
"""
|
|
adapter = self
|
|
base_cls = type(request)
|
|
|
|
class _InstrumentedPollingRequest(base_cls):
|
|
__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 — a concurrent recovery task may already have
|
|
replaced ``self._polling_progress_event`` with a newer generation's event.
|
|
"""
|
|
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:
|
|
return
|
|
if generation != self._polling_generation:
|
|
return
|
|
if error_callback is not None:
|
|
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._polling_progress_accepting = False
|
|
self._send_path_degraded = True
|
|
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._polling_progress_accepting = False
|
|
self._send_path_degraded = True
|
|
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 = asyncio.Lock()
|
|
self._general_request_drain_lock = 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.
|
|
|
|
When httpx reports pool exhaustion PTB guarantees the request was not sent, so
|
|
resetting the wedged pool 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():
|
|
try:
|
|
await _await_with_thread_deadline(general_req.shutdown(), timeout=_DRAIN_TIMEOUT)
|
|
except Exception:
|
|
logger.debug(
|
|
"[%s] General request shutdown failed/timed out after pool timeout (non-fatal)",
|
|
self.name, exc_info=True,
|
|
)
|
|
try:
|
|
await _await_with_thread_deadline(general_req.initialize(), timeout=_DRAIN_TIMEOUT)
|
|
logger.warning("[%s] General request pool drained after Telegram pool timeout", self.name)
|
|
except Exception:
|
|
logger.debug(
|
|
"[%s] General request re-initialize failed/timed out after pool timeout (non-fatal)",
|
|
self.name, exc_info=True,
|
|
)
|
|
|
|
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 _schedule_polling_recovery(self, error: Exception, *, reason: str) -> None:
|
|
"""Schedule background polling recovery without failing gateway startup.
|
|
|
|
A transient bootstrap failure (deleteWebhook / initial start_polling) degrades
|
|
only this adapter; the reconnect ladder recovers in the background.
|
|
"""
|
|
if self._teardown_started:
|
|
return
|
|
if self.has_fatal_error:
|
|
return
|
|
if self._polling_error_task and not self._polling_error_task.done():
|
|
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`` fails closed on cold start.
|
|
|
|
Reconnects recover transient errors in background; cold start raises so
|
|
GatewayRunner disposes the partial adapter and retries with a fresh Application.
|
|
"""
|
|
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 a
|
|
# wedged deleteWebhook pin initial connect.
|
|
await _await_with_thread_deadline(
|
|
delete_webhook(drop_pending_updates=False), timeout=_UPDATER_START_TIMEOUT
|
|
)
|
|
return True
|
|
except Exception as err:
|
|
if self._looks_like_network_error(err):
|
|
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
|
|
raise
|
|
|
|
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 or a
|
|
missing first getUpdates success raises so GatewayRunner disposes this partial
|
|
adapter and retries with a fresh PTB Application.
|
|
"""
|
|
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 — the cold connect
|
|
# times out on G1 despite G2 succeeding, or G2 "heals" the partial app so
|
|
# GatewayRunner never disposes it. Capture the first error and fail immediately.
|
|
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:
|
|
# Registered for the whole generation: once the gate closes, delegate to
|
|
# the real callback 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: a wedged pool can hang
|
|
# start_polling() at bootstrap too. The TimeoutError is an OSError subclass,
|
|
# so the except below classifies it as a network error → background recovery.
|
|
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:
|
|
# Bind to THIS generation's event, not self._polling_progress_event.
|
|
progress_wait = asyncio.ensure_future(progress.wait())
|
|
error_wait = asyncio.ensure_future(strict_error_event.wait())
|
|
try:
|
|
await _await_with_thread_deadline(
|
|
_first_completed(progress_wait, error_wait),
|
|
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")
|
|
# 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 _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 cancellation-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."
|
|
)
|
|
logger.error("[%s] %s (likely CLOSE-WAIT socket)", self.name, message)
|
|
self._set_fatal_error("telegram_network_error", message, retryable=True)
|
|
await self._handoff_polling_fatal_error()
|
|
return False
|
|
except Exception:
|
|
pass
|
|
return True
|
|
|
|
async def _handle_polling_network_error(self, error: Exception) -> None:
|
|
"""Reconnect polling after a transient network interruption (NetworkError/TimedOut).
|
|
|
|
The host losing connectivity (sleep, WiFi switch, VPN) kills the long-poll silently
|
|
while the process lives. Exponential back-off (5s→60s cap) up to MAX_NETWORK_RETRIES,
|
|
then retryable-fatal so the supervisor restarts the gateway.
|
|
"""
|
|
if self._teardown_started:
|
|
return
|
|
if 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
|
|
)
|
|
logger.error("[%s] %s Last error: %s", self.name, message, _redact_telegram_error_text(error))
|
|
self._set_fatal_error("telegram_network_error", message, retryable=True)
|
|
await self._handoff_polling_fatal_error()
|
|
return
|
|
delay = min(BASE_DELAY * (2 ** (attempt - 1)), MAX_DELAY)
|
|
safe_error = _redact_telegram_error_text(error)
|
|
logger.warning(
|
|
"[%s] Telegram network error (attempt %d/%d), reconnecting in %ds. Error: %s",
|
|
self.name, attempt, MAX_NETWORK_RETRIES, delay, safe_error,
|
|
)
|
|
await asyncio.sleep(delay)
|
|
if self._teardown_started:
|
|
return
|
|
# Stable local ref: a concurrent disconnect() may set self._app = None while we
|
|
# await below; fail fast in one place instead of swapping in None mid-sequence.
|
|
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"):
|
|
return
|
|
if self._teardown_started:
|
|
return
|
|
# start_polling() bootstraps through the *general* pool before getUpdates; if
|
|
# stale proxy sockets exhausted it, draining only the polling pool can't recover.
|
|
# A confirmed pool timeout means the request was never sent, so rebuilding the
|
|
# general 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
|
|
safe_retry_error = _redact_telegram_error_text(retry_err)
|
|
logger.warning("[%s] Telegram polling reconnect failed: %s", self.name, safe_retry_error)
|
|
# 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, or heartbeat/pending-probe/error_callback each start a second one.
|
|
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 and receiving silently stops.
|
|
Probe ``get_me()`` every HEARTBEAT_INTERVAL on the *general* path (never the
|
|
getUpdates pool, so a healthy long-poll is not interrupted); any connect-level
|
|
failure feeds ``_handle_polling_network_error``. Unlike the one-shot generation
|
|
verifier this runs for the connection's lifetime, catching steady-state wedges.
|
|
"""
|
|
HEARTBEAT_INTERVAL = 90 # seconds between probes
|
|
PROBE_TIMEOUT = 15 # seconds before declaring the path dead
|
|
# Wedged-recovery watchdog, tracked locally so no _polling_error_task assignment
|
|
# needs a timestamp: note when a recovery task is first seen in-flight and
|
|
# force-escalate if the *same* task object still runs past the stuck timeout
|
|
# (a healthy ladder attempt finishes or chains to a new task well before then).
|
|
stuck_task_ref: Optional[asyncio.Task] = None
|
|
stuck_task_since = 0.0
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
|
if self._teardown_started:
|
|
return
|
|
if self.has_fatal_error:
|
|
return
|
|
# If the recovery task hung on an unbounded await, every other recovery
|
|
# path is gated behind it forever (alive but deaf). Force retryable-fatal
|
|
# so the background reconnector rebuilds the adapter.
|
|
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,
|
|
)
|
|
try:
|
|
recovery_task.cancel()
|
|
except Exception:
|
|
pass
|
|
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, so a BotFather rename is
|
|
# picked up here — adopt the reported handle before anything routes on it.
|
|
self._bot_identity_checked_at = time.monotonic()
|
|
self._note_bot_username(getattr(bot, "username", None))
|
|
# get_me() OK proves only the general/send path. PTB can report
|
|
# updater.running=True while the long-poll is wedged and DMs queue
|
|
# server-side; get_webhook_info().pending_update_count exposes that.
|
|
# Escalates only after two consecutive non-zero probes so a single
|
|
# in-flight update never trips recovery.
|
|
await self._probe_pending_updates(bot, PROBE_TIMEOUT)
|
|
# An empty queue can't hide a wedge forever: Telegram answers within ~50s,
|
|
# so no round-trip past the stall threshold ⇒ dead. Pure local-state check.
|
|
await self._check_polling_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:
|
|
if self._looks_like_network_error(probe_err):
|
|
self._schedule_polling_recovery(probe_err, reason="heartbeat probe")
|
|
continue
|
|
# Non-connectivity errors (e.g. TelegramError 401) aren't CLOSE-WAIT symptoms.
|
|
pass
|
|
|
|
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 (e.g. WSL2 socket
|
|
epoll keeps reporting readable); get_me() stays healthy on the general path, yet
|
|
DMs queue in the Bot API. A stuck queue over two consecutive probes ⇒ dead
|
|
consumer; recovery reuses ``_handle_polling_network_error``. Also covers the
|
|
updater having stopped entirely (``running=False``, no reconnect in flight),
|
|
where no queue can be reported against a live consumer.
|
|
"""
|
|
if self._teardown_started:
|
|
return
|
|
# Polling mode only: in webhook mode Telegram pushes and holds no server-side queue.
|
|
if 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._polling_error_task and not self._polling_error_task.done():
|
|
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):
|
|
# Updater stopped entirely with no reconnect in flight: the long-poll task is
|
|
# gone, general-path calls still succeed, so no error_callback/probe ever fires.
|
|
# Same ladder as a wedged consumer, debounced over two probes so a
|
|
# just-starting updater never trips it.
|
|
self._polling_pending_stuck_count = 0
|
|
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
|
|
if self._teardown_started:
|
|
return
|
|
logger.warning(
|
|
"[%s] Telegram updater is not running (long-poll task gone); "
|
|
"triggering polling restart",
|
|
self.name,
|
|
)
|
|
loop = asyncio.get_running_loop()
|
|
self._polling_error_task = loop.create_task(
|
|
self._handle_polling_network_error(
|
|
RuntimeError("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):
|
|
# Connectivity symptom for the get_me() path / outer handler, not a stuck-queue signal.
|
|
return
|
|
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
|
|
if self._teardown_started:
|
|
return
|
|
logger.warning(
|
|
"[%s] getUpdates consumer appears wedged (queue not draining); triggering polling restart",
|
|
self.name,
|
|
)
|
|
loop = asyncio.get_running_loop()
|
|
self._polling_error_task = loop.create_task(
|
|
self._handle_polling_network_error(
|
|
RuntimeError("getUpdates consumer wedged: pending updates not draining")
|
|
)
|
|
)
|
|
|
|
async def _check_polling_stall(self) -> None:
|
|
"""Watchdog the last successful getUpdates round-trip.
|
|
|
|
A long-poll can wedge without raising (TCP dies mid-read, e.g. TUN/proxy route
|
|
flip → CLOSE-WAIT): ``updater.running`` stays True, get_me() stays healthy and
|
|
with nothing queued ``pending_update_count`` stays 0 — every other probe is blind.
|
|
Telegram answers within ~50s, so no round-trip for ``_POLLING_STALL_TIMEOUT`` ⇒
|
|
unambiguously wedged; escalate through the bounded reconnect ladder.
|
|
"""
|
|
if self._webhook_mode:
|
|
return
|
|
if self._teardown_started:
|
|
return
|
|
if self.has_fatal_error:
|
|
return
|
|
if self._polling_error_task and not self._polling_error_task.done():
|
|
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
|
|
# (which owns the immediate post-start window) 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)
|
|
),
|
|
)
|
|
|
|
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.
|
|
|
|
The generation-bound event is set only by a successful getUpdates response; a
|
|
general-path getMe success classifies connectivity but cannot heal polling health.
|
|
Connectivity failures enter the guarded recovery ladder; auth/validation don't churn.
|
|
"""
|
|
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
|
|
try:
|
|
await asyncio.wait_for(progress.wait(), timeout=_POLLING_PROGRESS_TIMEOUT)
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
if self._teardown_started:
|
|
return
|
|
if progress.is_set() or self.has_fatal_error:
|
|
return
|
|
if not self._polling_progress_accepting:
|
|
return
|
|
if generation != self._polling_generation:
|
|
return
|
|
if progress is not self._polling_progress_event:
|
|
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._teardown_started:
|
|
return
|
|
if self.has_fatal_error or not self._polling_progress_accepting:
|
|
return
|
|
if generation != self._polling_generation:
|
|
return
|
|
if progress is not self._polling_progress_event or progress.is_set():
|
|
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._teardown_started:
|
|
return
|
|
if self.has_fatal_error or not self._polling_progress_accepting:
|
|
return
|
|
if generation != self._polling_generation:
|
|
return
|
|
if progress is not self._polling_progress_event or progress.is_set():
|
|
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`` (max_retries=-1) calls our ``error_callback``
|
|
*synchronously* on a TelegramError (incl. 409 Conflict), then re-checks
|
|
``while is_running()`` and polls again. Our callback only schedules async
|
|
recovery, so PTB keeps polling while we stop→sleep→start_polling: two sessions
|
|
overlap and Telegram returns a fresh 409 on a ~31s cadence. Setting PTB's private
|
|
``stop_event`` inside the callback makes its loop exit on the next tick; our async
|
|
``await updater.stop()`` (idempotent) + drain + ``start_polling()`` then builds a
|
|
fresh stop_event so the restart isn't poisoned.
|
|
|
|
Best-effort: the attribute is name-mangled and spelled differently across PTB
|
|
versions, so probe both; if absent, do nothing (prior racing behaviour, never
|
|
worse). Deliberately NOT flipping ``updater._running``: stop() raises when
|
|
running is already False and our handler guards on it, so clearing the flag
|
|
would skip the real teardown and leave stop_event set for the next start_polling().
|
|
"""
|
|
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:
|
|
if self._teardown_started:
|
|
return
|
|
if self.has_fatal_error and self.fatal_error_code == "telegram_polling_conflict":
|
|
return
|
|
# Transient 409s: the previous gateway process was killed (update / --replace
|
|
# handoff) but Telegram holds its getUpdates session open for up to ~30s.
|
|
# Strategy: stop the local updater, wait for the server-side session to expire
|
|
# (RETRY_DELAY grows per attempt), drain the pool, restart — MAX_CONFLICT_RETRIES
|
|
# times before going fatal. A failed retry must never return silently: an updater
|
|
# that is neither running nor fatal drops messages while reporting "connected".
|
|
self._polling_conflict_count += 1
|
|
MAX_CONFLICT_RETRIES = 5
|
|
# 15s, 25s, 35s, 45s, 55s — clears Telegram's ~30s session window without
|
|
# hammering the API on fast-restart loops.
|
|
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; fail fast here (where the except reschedules or escalates)
|
|
# instead of an AttributeError deep inside start_polling.
|
|
app = self._app
|
|
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 — the previous process's zombie or our own prior
|
|
# retry's still-expiring session. Without it each retry is immediately 409'd
|
|
# by the previous one, recreating the conflict we're recovering from.
|
|
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+ when PTB dispatches
|
|
# the error callback from a context without an attached loop.
|
|
loop = asyncio.get_running_loop()
|
|
self._polling_error_task = loop.create_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 may be
|
|
# suspended past the entry guard, and the bounded stop() await below yields the
|
|
# loop so it 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.
|
|
|
|
The runner bounds adapter cleanup in a child task, and ``disconnect()`` cancels
|
|
the tracked recovery and heartbeat tasks — so leaving the current notifier in
|
|
either field would cancel the fatal callback mid-decision. Release only the
|
|
current owner from whichever field tracks it.
|
|
"""
|
|
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+ createForumTopic).
|
|
|
|
Returns the message_thread_id, or None on failure.
|
|
"""
|
|
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 (DM topics or forum supergroups).
|
|
|
|
Returns the ``message_thread_id`` as a string, or ``None`` on failure.
|
|
"""
|
|
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
|
|
for candidate in entry.get("topics", []):
|
|
if candidate.get("name") == name:
|
|
topic_conf = candidate
|
|
break
|
|
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 a named
|
|
# delivery target asks for that were not predeclared in config.yaml.
|
|
platforms = config.setdefault("platforms", {})
|
|
telegram_config = platforms.setdefault("telegram", {})
|
|
extra = telegram_config.setdefault("extra", {})
|
|
dm_topics = extra.setdefault("dm_topics", [])
|
|
changed = False
|
|
matching_chat_entry = None
|
|
for chat_entry in dm_topics:
|
|
try:
|
|
chat_matches = int(chat_entry.get("chat_id", 0)) == int(chat_id)
|
|
except (TypeError, ValueError):
|
|
chat_matches = False
|
|
if not chat_matches:
|
|
continue
|
|
matching_chat_entry = chat_entry
|
|
for t in chat_entry.setdefault("topics", []):
|
|
if t.get("name") == topic_name:
|
|
if (replace_existing or not t.get("thread_id")) and t.get("thread_id") != thread_id:
|
|
t["thread_id"] = thread_id
|
|
changed = True
|
|
break
|
|
else:
|
|
chat_entry.setdefault("topics", []).append({"name": topic_name, "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 for specified chats.
|
|
|
|
``config.extra['dm_topics']`` is a list of ``{"chat_id": int, "topics": [{"name",
|
|
"icon_color", "thread_id"?, "skill"?}, ...]}``. Topics with a persisted thread_id
|
|
are cached without an API call; the rest are created and saved back to config.yaml.
|
|
"""
|
|
if not self._dm_topics_config:
|
|
return
|
|
for chat_entry in self._dm_topics_config:
|
|
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
|
|
icon_color = topic_conf.get("icon_color")
|
|
icon_emoji = topic_conf.get("icon_custom_emoji_id")
|
|
thread_id = await self._create_dm_topic(
|
|
chat_id=normalize_telegram_chat_id(chat_id), name=topic_name,
|
|
icon_color=icon_color, icon_custom_emoji_id=icon_emoji,
|
|
)
|
|
if thread_id:
|
|
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 when no heartbeat is running.
|
|
|
|
Webhook mode never calls ``get_me()`` again after ``initialize()`` (polling mode
|
|
does via the heartbeat), so a BotFather rename would break mention routing until restart.
|
|
"""
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(self._BOT_IDENTITY_TTL_SECONDS)
|
|
if self._teardown_started:
|
|
return
|
|
if 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 _run_post_connect_housekeeping(self) -> None:
|
|
"""Register the command menu, status indicator and DM topics off the connect path.
|
|
|
|
A slow Bot API call must not blow the gateway connect timeout. Every step is non-fatal.
|
|
"""
|
|
try:
|
|
# Command menu derives from the central COMMAND_REGISTRY.
|
|
try:
|
|
from telegram import (
|
|
BotCommand, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats,
|
|
BotCommandScopeDefault,
|
|
)
|
|
from hermes_cli.commands import telegram_menu_commands, telegram_menu_max_commands
|
|
if not self._bot:
|
|
return
|
|
# Telegram allows 100 commands but has an undocumented ~4KB payload limit;
|
|
# the default cap of 60 stays under it (tunable via extra.command_menu).
|
|
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]
|
|
# Register every scope: Telegram picks the narrowest matching one per chat type.
|
|
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)
|
|
# Forum topics don't inherit AllGroupChats; _ensure_forum_commands registers
|
|
# BotCommandScopeChat(chat_id) lazily on the first forum-topic message.
|
|
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,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"[%s] Could not register Telegram command menu: %s",
|
|
self.name, _redact_telegram_error_text(e), exc_info=True,
|
|
)
|
|
# "Online" in the bot's short description (opt-in via extra.status_indicator).
|
|
try:
|
|
await self._set_status_indicator(online=True)
|
|
except Exception:
|
|
pass
|
|
# DM topics (Bot API 9.4 Private Chat Topics); the bot works fine without them.
|
|
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 firing ``gateway_platform_event`` per inbound update.
|
|
|
|
Normalizes the update into a stable envelope (no raw SDK objects) and forwards
|
|
it with an internal source to the gateway-owned post-auth boundary. Registered
|
|
in a dedicated high group so it observes alongside, never displaces, the core
|
|
handlers. Malformed updates and dispatch errors never raise into PTB's loop.
|
|
"""
|
|
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 authorization chain
|
|
# before plugin dispatch. No callback = no trusted auth boundary ⇒ fail closed.
|
|
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)
|
|
return
|
|
|
|
def _source_for_platform_event_auth(self, update):
|
|
"""Route a supported update to its event-specific auth-source extractor.
|
|
|
|
A reaction carries the reactor, an edit the editor. Raises ``ValueError`` for
|
|
updates without a wired extractor 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, or ``None``.
|
|
|
|
Each event type has its own additive payload contract (hooks.md); raw SDK objects
|
|
never leave this boundary. Types without a contract (forward, chat-member) → ``None``.
|
|
"""
|
|
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
|
|
|
|
def _normalize_reaction_event(self, update) -> Optional[Dict[str, Any]]:
|
|
"""Normalize a ``message_reaction`` update (event_type ``reaction``).
|
|
|
|
Payload: ``emojis`` (unicode), ``custom_emoji_ids`` (PTB exposes ``custom_emoji_id``
|
|
with no ``.emoji``), ``chat_id``, ``message_id``, ``thread_id``.
|
|
"""
|
|
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 (
|
|
isinstance(chat_id, bool) or not isinstance(chat_id, (str, int))
|
|
or isinstance(message_id, bool) or not isinstance(message_id, (str, int))
|
|
):
|
|
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 not isinstance(custom_id, bool) and isinstance(custom_id, (str, int)):
|
|
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],
|
|
# Reactions carry no thread_id; don't guess or expose an adapter object.
|
|
"thread_id": None,
|
|
},
|
|
}
|
|
def _normalize_message_edited_event(self, update) -> Optional[Dict[str, Any]]:
|
|
"""Normalize an ``edited_message`` update into a ``message_edited`` event.
|
|
|
|
Payload (v1, additive): chat_id, message_id, thread_id (forum topic), text
|
|
(edited text or caption, bounded), edited_at (ISO 8601 UTC or None). No raw
|
|
PTB ``Message`` leaves this boundary; malformed identities return 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 (
|
|
isinstance(chat_id, bool)
|
|
or not isinstance(chat_id, (str, int))
|
|
or isinstance(message_id, bool)
|
|
or not isinstance(message_id, (str, int))
|
|
):
|
|
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 (
|
|
not isinstance(thread_id_raw, bool)
|
|
and isinstance(thread_id_raw, (str, int))
|
|
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``.
|
|
|
|
Single source of truth: initial connect and the transient-init rebuild both
|
|
call this, keeping the group-99 observer in lockstep with the core handlers.
|
|
"""
|
|
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
|
|
))
|
|
# Inline keyboard button callbacks (update prompts)
|
|
app.add_handler(CallbackQueryHandler(self._handle_callback_query))
|
|
# Inline command picker (@botname <query>). Inert until the owner enables
|
|
# inline mode via BotFather /setinline, so registering unconditionally is safe.
|
|
app.add_handler(InlineQueryHandler(self._handle_inline_query))
|
|
# gateway_platform_event observer (see _on_platform_update); group 99 so it
|
|
# observes alongside, never displaces, the 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 for the PTB app.
|
|
|
|
Picks the fallback-IP transport, an explicit proxy, or direct DNS, and
|
|
instruments the getUpdates request for polling-progress tracking.
|
|
"""
|
|
# PTB's pool_timeout=1s default trips "Pool timeout: All connections in the
|
|
# connection pool are occupied" on flaky networks; use 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),
|
|
# Not a duplicate of write_timeout: PTB routes requests carrying files to
|
|
# media_write_timeout. httpx budgets it per socket write (stall tolerance,
|
|
# not a size/bandwidth cap); 60s rides out congested-link buffer stalls.
|
|
"media_write_timeout": 60.0,
|
|
}
|
|
|
|
# CLOSE_WAIT fd leak: PTB builds its httpx.AsyncClient with no keepalive
|
|
# tuning, so httpx's default keepalive_expiry=5.0 applies. Behind a proxy a
|
|
# peer FIN can sit in CLOSE_WAIT longer, leaking fds in the general pool that
|
|
# _drain_polling_connections never resets. Inject platform_httpx_limits()
|
|
# while preserving PTB's max_connections; httpx_kwargs is spread last into
|
|
# PTB's client kwargs, so `limits` here wins.
|
|
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 request 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; ordinary requests keep the reusable pool.
|
|
_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 = None
|
|
_updates_limits = None
|
|
|
|
def _with_limits(httpx_kwargs: Optional[dict] = None) -> dict:
|
|
"""Merge tuned keepalive limits into httpx client kwargs (proxy/direct
|
|
branches only; a caller-supplied ``limits`` wins). The fallback-IP branch
|
|
must NOT use this — see the ``_transport_kwargs`` note below."""
|
|
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 = self._fallback_ips()
|
|
if disable_fallback:
|
|
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_targets = ["api.telegram.org", *fallback_ips]
|
|
proxy_url = resolve_proxy_url("TELEGRAM_PROXY", target_hosts=proxy_targets)
|
|
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/delete_webhook calls. httpx ignores the
|
|
# client-level `limits` kwarg when a custom `transport` is supplied, so
|
|
# this branch MUST pass the tuned limits straight into
|
|
# TelegramFallbackTransport — never via `_with_limits`.
|
|
_transport_kwargs: dict = {}
|
|
if _pool_limits is not None:
|
|
_transport_kwargs["limits"] = _pool_limits
|
|
_transport_kwargs["socket_options"] = tcp_keepalive_socket_options()
|
|
_updates_transport_kwargs = dict(_transport_kwargs)
|
|
if _updates_limits is not None:
|
|
_updates_transport_kwargs["limits"] = _updates_limits
|
|
request = HTTPXRequest(
|
|
**request_kwargs,
|
|
httpx_kwargs={
|
|
"transport": TelegramFallbackTransport(fallback_ips, **_transport_kwargs)
|
|
},
|
|
)
|
|
get_updates_request = HTTPXRequest(
|
|
**request_kwargs,
|
|
httpx_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 = HTTPXRequest(**request_kwargs, proxy=proxy_url, httpx_kwargs=_with_limits())
|
|
get_updates_request = HTTPXRequest(
|
|
**request_kwargs, proxy=proxy_url, httpx_kwargs={"limits": _updates_limits}
|
|
)
|
|
else:
|
|
if disable_fallback:
|
|
logger.info("[%s] Telegram fallback-IP transport disabled via env", self.name)
|
|
request = HTTPXRequest(**request_kwargs, httpx_kwargs=_with_limits())
|
|
get_updates_request = HTTPXRequest(
|
|
**request_kwargs, httpx_kwargs={"limits": _updates_limits}
|
|
)
|
|
|
|
get_updates_request = self._instrument_polling_request(get_updates_request)
|
|
return request, get_updates_request
|
|
|
|
async def _initialize_app_with_retries(self, builder) -> None:
|
|
"""Run ``app.initialize()`` with a bounded retry ladder for transient errors.
|
|
|
|
Rebuilds ``self._app``/``self._bot`` from ``builder`` after each failed
|
|
attempt; raises OSError when the per-attempt or total watchdog expires.
|
|
"""
|
|
# Each attempt is capped by _init_timeout so one unreachable fallback-IP
|
|
# chain can't block startup indefinitely.
|
|
_max_connect = 8
|
|
_init_timeout = env_float("HERMES_TELEGRAM_INIT_TIMEOUT", 30.0)
|
|
# Total watchdog: upper bound on the whole connect loop even if the retry
|
|
# loop itself silently stalls (per-attempt timeout plus margin for sleeps).
|
|
_total_deadline = (
|
|
asyncio.get_running_loop().time()
|
|
+ _init_timeout * _max_connect
|
|
+ 120.0 # extra margin for between-attempt sleeps + overhead
|
|
)
|
|
for _attempt in range(_max_connect):
|
|
rebuild_app = False
|
|
try:
|
|
# Total watchdog: the ladder must yield even if no attempt raised.
|
|
if asyncio.get_running_loop().time() >= _total_deadline:
|
|
raise OSError(
|
|
f"Telegram initialization timed out after {_max_connect} attempts "
|
|
f"({_init_timeout:.0f}s each) — total connect watchdog "
|
|
f"deadline ({_init_timeout * _max_connect + 120.0:.0f}s) exceeded. "
|
|
f"Check network connectivity to api.telegram.org "
|
|
f"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,
|
|
)
|
|
await _await_with_thread_deadline(
|
|
self._app.initialize(),
|
|
timeout=_init_timeout,
|
|
# On timeout the initialize() task is abandoned (it may be wedged
|
|
# in a shielded scope); best-effort release the half-built app's
|
|
# httpx client so it isn't leaked across the retry ladder.
|
|
on_abandon=lambda app=self._app: _shutdown_abandoned_app(app),
|
|
)
|
|
break
|
|
except asyncio.TimeoutError:
|
|
rebuild_app = True
|
|
if _attempt < _max_connect - 1:
|
|
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)
|
|
else:
|
|
raise OSError(
|
|
f"Telegram initialization timed out after {_max_connect} attempts "
|
|
f"({_init_timeout:.0f}s each). Check network connectivity to api.telegram.org "
|
|
f"or set HERMES_TELEGRAM_HTTP_CONNECT_TIMEOUT to a lower value."
|
|
)
|
|
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):
|
|
raise
|
|
if _attempt < _max_connect - 1:
|
|
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)
|
|
else:
|
|
raise
|
|
except BaseException:
|
|
# CancelledError etc.: log for the operator, then reraise to preserve
|
|
# cancellation semantics. Placed 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 (closed
|
|
# transports, half-built handlers): 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
|
|
# Keep core and observer handlers in lockstep after a rebuild.
|
|
self._register_handlers(self._app)
|
|
try:
|
|
await _shutdown_abandoned_app(old_app)
|
|
except Exception:
|
|
pass
|
|
|
|
async def _start_webhook_mode(self, webhook_url: str, *, is_reconnect: bool) -> None:
|
|
"""Start PTB's webhook server (Telegram pushes updates to us).
|
|
|
|
Lets cloud platforms (Fly.io, Railway) auto-wake suspended machines on
|
|
inbound HTTP. SECURITY: TELEGRAM_WEBHOOK_SECRET is REQUIRED — without it PTB
|
|
passes secret_token=None and the endpoint accepts forged updates from anyone
|
|
(GHSA-3vpc-7q5r-276h). Refuse to start rather than run fail-open.
|
|
"""
|
|
webhook_port = env_int("TELEGRAM_WEBHOOK_PORT", 8443)
|
|
# Bind host. Default "" → tornado opens one listening socket per address
|
|
# family (IPv4 + IPv6); "0.0.0.0" would be unreachable over IPv6-only
|
|
# private networks (e.g. Fly.io 6PN).
|
|
webhook_host = (
|
|
os.getenv("TELEGRAM_WEBHOOK_HOST", "").strip()
|
|
or str((self.config.extra or {}).get("webhook_host") or "").strip()
|
|
)
|
|
# Profile-scoped read: honors the profile's own secret; only an UNSCOPED
|
|
# read under multiplex (default-profile startup) falls back to process env.
|
|
from agent.secret_scope import UnscopedSecretError, get_secret
|
|
|
|
try:
|
|
webhook_secret = (get_secret("TELEGRAM_WEBHOOK_SECRET") or "").strip()
|
|
except UnscopedSecretError:
|
|
webhook_secret = os.getenv("TELEGRAM_WEBHOOK_SECRET", "").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,
|
|
# Webhooks are push-based (no server-side getUpdates queue), so this is
|
|
# a no-op in practice; mirrors the polling path's reconnect semantics.
|
|
drop_pending_updates=not is_reconnect,
|
|
)
|
|
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."""
|
|
# Clear any stale webhook so polling doesn't inherit it and silently stop
|
|
# receiving updates. Best-effort: a transient Bot API error must not fail
|
|
# gateway startup — degrade to background polling recovery instead.
|
|
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:
|
|
return
|
|
if self._polling_error_task and not self._polling_error_task.done():
|
|
return
|
|
if self._looks_like_polling_conflict(error):
|
|
# Stop PTB's network_retry_loop synchronously BEFORE scheduling the
|
|
# async recovery task: PTB calls this callback inside its loop and
|
|
# keeps polling, so PTB's retry and our stop->restart would overlap
|
|
# and produce a fresh 409. Disarming now lets recovery own polling.
|
|
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)
|
|
|
|
# Store reference for retry use in _handle_polling_conflict
|
|
self._polling_error_callback_ref = _polling_error_callback
|
|
|
|
polling_started = await self._start_polling_resilient(
|
|
# Cold first boot drops the stale Bot API queue; a watcher reconnect
|
|
# preserves it so messages sent while offline are delivered.
|
|
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 to Telegram via long polling, or a webhook server if
|
|
``TELEGRAM_WEBHOOK_URL`` is set (cloud deployments where inbound HTTP wakes
|
|
a suspended machine).
|
|
|
|
``is_reconnect``: False = cold boot (drop the stale Bot API queue); True =
|
|
watcher reconnect after an outage (preserve queued updates, otherwise every
|
|
message sent during the outage is silently lost — matching the network-error
|
|
ladder and 409 handler, which already pass ``drop_pending_updates=False``).
|
|
|
|
Webhook env: TELEGRAM_WEBHOOK_URL (public HTTPS URL), TELEGRAM_WEBHOOK_PORT
|
|
(default 8443), TELEGRAM_WEBHOOK_HOST (default: dual-stack all interfaces),
|
|
TELEGRAM_WEBHOOK_SECRET (update verification token).
|
|
"""
|
|
# Explicit connect() is the only operation allowed to reopen polling after a
|
|
# completed, serialized teardown. Background recovery never clears this fence.
|
|
self._polling_teardown_started = False
|
|
# Mode is re-evaluated on every explicit connection.
|
|
self._webhook_mode = False
|
|
|
|
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
|
|
# issuing an HTTP GET that would 404 (path must be readable by Hermes).
|
|
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 the core handlers: PTB dispatches the
|
|
# first matching handler per group, so pattern-scoped plugin handlers win
|
|
# for their own updates and everything else falls through to core.
|
|
self._wire_plugin_handlers(self._app)
|
|
self._register_handlers(self._app)
|
|
|
|
await self._initialize_app_with_retries(builder)
|
|
await self._app.start()
|
|
|
|
webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").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()
|
|
mode = "webhook" if self._webhook_mode else "polling"
|
|
# WARNING, not INFO: the "Connecting…" line above is WARNING and reaches
|
|
# the terminal (default stderr handler is WARNING-only); an INFO success
|
|
# line made healthy startups look stalled at "attempt 1/8". A real hang
|
|
# must be the *absence* of this line, not ambiguity.
|
|
logger.warning("[%s] Connected to Telegram (%s mode)", self.name, mode)
|
|
|
|
# Heartbeat loop only in polling mode: webhook mode has no long-poll
|
|
# socket to wedge in CLOSE-WAIT.
|
|
if not self._webhook_mode:
|
|
if self._polling_heartbeat_task and not self._polling_heartbeat_task.done():
|
|
self._polling_heartbeat_task.cancel()
|
|
self._polling_heartbeat_task = asyncio.ensure_future(self._polling_heartbeat_loop())
|
|
|
|
# Seed the live identity from PTB's initialize() cache, then keep it
|
|
# fresh: polling rides the heartbeat's get_me() probe; webhook mode has no
|
|
# probe, so it gets a low-frequency refresh loop — otherwise a BotFather
|
|
# rename breaks mention routing until restart.
|
|
self._note_bot_username(getattr(self._bot, "username", None))
|
|
self._bot_identity_checked_at = time.monotonic()
|
|
if self._webhook_mode:
|
|
identity_task = getattr(self, "_bot_identity_refresh_task", None)
|
|
if identity_task and not identity_task.done():
|
|
identity_task.cancel()
|
|
self._bot_identity_refresh_task = asyncio.ensure_future(
|
|
self._bot_identity_refresh_loop()
|
|
)
|
|
|
|
# Command-menu registration, DM-topic setup and the status indicator make
|
|
# Bot API calls that can stall for some tokens; inside connect() (which
|
|
# the gateway wraps in a timeout) one slow call would sink the whole
|
|
# connect even though transport is live. Defer to a cancellable task.
|
|
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
|
|
# (InvalidToken / Forbidden) can never self-heal, so marking them
|
|
# retryable put agents into a silent eternal reconnect loop. Reuse the
|
|
# runtime polling path's discriminator so both agree on what's transient.
|
|
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:
|
|
message = f"Telegram startup failed: {safe_error}"
|
|
self._set_fatal_error("telegram_connect_error", message, 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 (profile line) to the online/offline text.
|
|
|
|
Closest Bot API surface to presence — bots have no real online dot. No-op
|
|
unless ``extra.status_indicator`` is enabled; best-effort, failures are
|
|
logged at debug so they never block connect/disconnect.
|
|
"""
|
|
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
|
|
# Telegram caps short_description at 120 chars.
|
|
text = text[:120]
|
|
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),
|
|
)
|
|
|
|
async def _cancel_pending_delivery_tasks(self) -> None:
|
|
"""Cancel every delayed-delivery task family before disconnect completes.
|
|
|
|
Media-group, photo-batch, text-batch flush tasks plus the polling-error
|
|
recovery task all sit behind ``asyncio.sleep()``; left running they'd
|
|
dispatch ``handle_message`` into a torn-down session. Skips the current
|
|
task so the teardown coroutine doesn't cancel itself.
|
|
"""
|
|
current_task = asyncio.current_task()
|
|
pending_tasks: list[asyncio.Task] = []
|
|
awaitable_tasks: list[asyncio.Task] = []
|
|
seen: set[int] = set()
|
|
|
|
def collect(task: Optional[asyncio.Task]) -> None:
|
|
if not task or task.done() or task is current_task:
|
|
return
|
|
marker = id(task)
|
|
if marker in seen:
|
|
return
|
|
seen.add(marker)
|
|
pending_tasks.append(task)
|
|
if asyncio.isfuture(task) or asyncio.iscoroutine(task):
|
|
awaitable_tasks.append(task)
|
|
|
|
for task in list(self._media_group_tasks.values()):
|
|
collect(task)
|
|
for task in list(self._pending_photo_batch_tasks.values()):
|
|
collect(task)
|
|
for task in list(self._pending_text_batch_tasks.values()):
|
|
collect(task)
|
|
collect(getattr(self, "_polling_error_task", None))
|
|
collect(getattr(self, "_polling_progress_verifier_task", None))
|
|
# Hold-queue redispatch must be cancellable+awaitable on teardown so it
|
|
# cannot dispatch handle_message into a torn-down session.
|
|
collect(getattr(self, "_held_inbound_redispatch_task", None))
|
|
|
|
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 event in list(self._pending_text_batches.values()):
|
|
self._hold_inbound_event(event, where="text-batch-teardown")
|
|
for event in list(self._pending_photo_batches.values()):
|
|
self._hold_inbound_event(event, where="photo-batch-teardown")
|
|
for event in list(self._media_group_events.values()):
|
|
self._hold_inbound_event(event, where="media-group-teardown")
|
|
|
|
self._media_group_tasks.clear()
|
|
self._media_group_events.clear()
|
|
self._pending_photo_batch_tasks.clear()
|
|
self._pending_photo_batches.clear()
|
|
self._pending_text_batch_tasks.clear()
|
|
self._pending_text_batches.clear()
|
|
if getattr(self, "_polling_error_task", None) is not current_task:
|
|
self._polling_error_task = None
|
|
if getattr(self, "_polling_progress_verifier_task", None) is not current_task:
|
|
self._polling_progress_verifier_task = None
|
|
if getattr(self, "_held_inbound_redispatch_task", None) is not current_task:
|
|
self._held_inbound_redispatch_task = None
|
|
async def _await_disconnect_step(self, awaitable, timeout: float, step: str) -> bool:
|
|
"""Await one disconnect step; detach on timeout so teardown advances.
|
|
|
|
``asyncio.wait_for`` cancels an overdue child but then waits for it to exit,
|
|
so PTB close paths that swallow ``CancelledError`` on a half-dead socket
|
|
could wedge disconnect forever. The abandoned task is observed via
|
|
``_consume_abandoned_task``.
|
|
"""
|
|
task = asyncio.ensure_future(awaitable)
|
|
try:
|
|
if timeout <= 0:
|
|
done, _pending = await asyncio.wait({task})
|
|
else:
|
|
done, _pending = await asyncio.wait({task}, timeout=timeout)
|
|
except asyncio.CancelledError:
|
|
# asyncio.wait does NOT cancel its futures when itself cancelled; don't
|
|
# orphan the inner task on outer cancellation.
|
|
task.cancel()
|
|
task.add_done_callback(_consume_abandoned_task)
|
|
raise
|
|
if task in done:
|
|
# Intentional cancels (heartbeat / identity / lifecycle) surface as
|
|
# CancelledError — swallow so disconnect keeps advancing.
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
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
|
|
|
|
async def _cancel_task_attr(self, attr: str, label: str) -> None:
|
|
"""Cancel + bounded-await the task stored at ``self.<attr>``, then clear it.
|
|
|
|
getattr guards the object.__new__ test pattern (attribute may be missing).
|
|
"""
|
|
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 against teardown, and late handlers can't schedule tasks.
|
|
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.
|
|
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: list[asyncio.Task] = []
|
|
lifecycle_seen: set[int] = set()
|
|
for task in (
|
|
getattr(self, "_polling_error_task", None),
|
|
getattr(self, "_polling_progress_verifier_task", None),
|
|
):
|
|
if not task or task.done() or task is current_task:
|
|
continue
|
|
marker = id(task)
|
|
if marker in lifecycle_seen:
|
|
continue
|
|
lifecycle_seen.add(marker)
|
|
task.cancel()
|
|
if asyncio.isfuture(task) or asyncio.iscoroutine(task):
|
|
lifecycle_tasks.append(task)
|
|
if lifecycle_tasks:
|
|
await self._await_disconnect_step(
|
|
asyncio.gather(*lifecycle_tasks, return_exceptions=True),
|
|
_DISCONNECT_STEP_TIMEOUT, "lifecycle-task cancel",
|
|
)
|
|
if getattr(self, "_polling_error_task", None) is not current_task:
|
|
self._polling_error_task = None
|
|
if getattr(self, "_polling_progress_verifier_task", None) is not current_task:
|
|
self._polling_progress_verifier_task = None
|
|
|
|
# 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.
|
|
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 before tearing down the app so its probe cannot fire
|
|
# get_me() into a half-shutdown bot client; same fence for the webhook-mode
|
|
# identity refresh loop.
|
|
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 (before app
|
|
# shutdown closes it). Opt-in, non-fatal; a hard crash leaves the
|
|
# last-known status — the expected limitation of a profile-text indicator.
|
|
try:
|
|
await self._await_disconnect_step(
|
|
self._set_status_indicator(online=False),
|
|
_DISCONNECT_STEP_TIMEOUT, "status-indicator update",
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
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() on epoll
|
|
# forever; on timeout fall through to app.stop()/shutdown().
|
|
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.
|
|
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
|
|
elif mode == "all":
|
|
return True
|
|
else: # "first" (default)
|
|
return chunk_index == 0
|
|
|
|
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)
|
|
|
|
try:
|
|
# Bot API 10.1 rich fast-path (sendRichMessage renders tables/task lists
|
|
# natively). Falls through to legacy MarkdownV2 on permanent/capability
|
|
# errors or DM-topic routing skips; returns directly on success or on a
|
|
# transient failure (which must NOT be legacy-resent).
|
|
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:
|
|
# Re-trigger typing ONLY for intermediate sends; on the final
|
|
# reply (metadata["notify"]) the refresh loop is already torn
|
|
# down and re-arming Telegram's ~5s timer leaves the bubble
|
|
# lingering (no Bot API call cancels it).
|
|
if rich_result.success and not (metadata or {}).get("notify"):
|
|
try:
|
|
await self.send_typing(chat_id, metadata=metadata)
|
|
except Exception:
|
|
pass # Typing failures are non-fatal
|
|
return rich_result
|
|
|
|
formatted = self.format_message(content)
|
|
chunks = self.truncate_message(formatted, 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 so Telegram doesn't reject the chunk.
|
|
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
|
|
|
|
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]
|
|
|
|
for i, chunk in enumerate(chunks):
|
|
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, i
|
|
)
|
|
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")
|
|
|
|
msg = None
|
|
for _send_attempt in range(3):
|
|
try:
|
|
# Try Markdown first, fall back to plain text if it fails
|
|
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),
|
|
}
|
|
try:
|
|
msg = 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)
|
|
msg = await self._bot.send_message(
|
|
text=_strip_mdv2(chunk), parse_mode=None, **send_kwargs,
|
|
)
|
|
else:
|
|
raise
|
|
break # success
|
|
except _NetErr as send_err:
|
|
# BadRequest subclasses NetworkError in PTB but is permanent;
|
|
# handle specific cases instead of blindly retrying.
|
|
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
|
|
)
|
|
# Telegram returns one-off "thread not found" flakes that
|
|
# recover on immediate retry: try the same thread_id once
|
|
# (no sleep) before falling back to a plain send.
|
|
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
|
|
# Second failure: thread is genuinely gone. Retry without
|
|
# message_thread_id and prune the stale binding so future
|
|
# inbound messages aren't redirected back to it.
|
|
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
|
|
err_lower = str(send_err).lower()
|
|
if "message to be replied not found" in err_lower and reply_to_id is not None:
|
|
if private_dm_topic_send:
|
|
safe_send_error = _redact_telegram_error_text(send_err)
|
|
return SendResult(
|
|
success=False, error=safe_send_error, retryable=False
|
|
)
|
|
# Reply target deleted before we could reply. For private-
|
|
# topic fallback sends, message_thread_id is only valid with
|
|
# the reply anchor, so drop both together.
|
|
safe_send_error = _redact_telegram_error_text(send_err)
|
|
logger.warning(
|
|
"[%s] Reply target deleted, retrying without reply_to: %s",
|
|
self.name, safe_send_error,
|
|
)
|
|
reply_to_id = None
|
|
if metadata and metadata.get("telegram_dm_topic_reply_fallback"):
|
|
thread_kwargs = {}
|
|
effective_thread_id = None
|
|
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
|
|
# Other BadRequest errors are permanent — don't retry
|
|
raise
|
|
# TimedOut also subclasses NetworkError. A generic timeout may have
|
|
# reached Telegram, so don't retry; a wrapped ConnectTimeout (no
|
|
# connection) or an httpx pool timeout (explicitly not sent) is
|
|
# safe to retry and prevents silent drops.
|
|
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:
|
|
wait = 2 ** _send_attempt
|
|
safe_send_error = _redact_telegram_error_text(send_err)
|
|
logger.warning("[%s] Network error on send (attempt %d/3), retrying in %ds: %s",
|
|
self.name, _send_attempt + 1, wait, safe_send_error)
|
|
await asyncio.sleep(wait)
|
|
else:
|
|
raise
|
|
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)
|
|
# Mirror the edit path: never sleep a long server RetryAfter
|
|
# verbatim — it once pinned send() for 97 minutes and froze
|
|
# inbound on every platform from the gateway boot path.
|
|
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
|
|
raise
|
|
message_ids.append(str(msg.message_id))
|
|
|
|
# Re-trigger typing: Telegram clears typing state when a message lands,
|
|
# so the bubble would vanish mid-response after intermediate progress
|
|
# messages. Skip on the FINAL reply (metadata["notify"]): the refresh
|
|
# loop is already cancelled, so re-arming Telegram's ~5s timer would
|
|
# leave the indicator lingering (no stop-typing API exists).
|
|
if not (metadata or {}).get("notify"):
|
|
try:
|
|
await self.send_typing(chat_id, metadata=metadata)
|
|
except Exception:
|
|
pass # Typing failures are non-fatal
|
|
|
|
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 and sends the remainder.
|
|
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 usually means the request may have reached Telegram — mark
|
|
# non-retryable so _send_with_retry() doesn't re-send. Exceptions: a
|
|
# wrapped ConnectTimeout and an httpx pool timeout are safe to re-send.
|
|
_to = locals().get("_TimedOut")
|
|
is_timeout = (_to and isinstance(e, _to)) or "timed out" in err_str
|
|
is_connect_timeout = self._looks_like_connect_timeout(e)
|
|
is_pool_timeout = self._looks_like_pool_timeout(e)
|
|
return SendResult(
|
|
success=False,
|
|
error=safe_error,
|
|
retryable=(is_connect_timeout or is_pool_timeout 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 key.
|
|
|
|
First call sends and remembers the message id; later calls with the same
|
|
(chat_id, status_key) edit it in place. If the edit fails (deleted, too
|
|
old, …) the cached id is dropped and a fresh message is sent.
|
|
"""
|
|
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_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): when content has constructs the MarkdownV2
|
|
# edit degrades (tables, task lists, <details>, block math), edit the preview
|
|
# IN PLACE via rich_message — no fresh send + delete, so no duplicate
|
|
# preview. Done before the 4,096 pre-flight because the rich cap is 32,768;
|
|
# a rich table over the MarkdownV2 limit must not be split into legacy
|
|
# chunks. Falls back to the legacy path 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: if content already exceeds the limit, split-and-deliver
|
|
# without a doomed edit. Mid-stream (finalize=False) we truncate instead:
|
|
# splitting moves the edit target to a continuation, and the next token
|
|
# chunk re-edits the full text into it → infinite duplication loop. Full
|
|
# content is delivered when finalize=True.
|
|
_preview_key = (str(chat_id), str(message_id))
|
|
_saturated_preview = False
|
|
if finalize:
|
|
# The final edit always delivers real (full) content.
|
|
self._last_overflow_preview.pop(_preview_key, None)
|
|
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 (~1 edit/0.8s trips 200s+ penalties and hangs final delivery).
|
|
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 a real edit later.
|
|
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)
|
|
|
|
formatted = self.format_message(content)
|
|
try:
|
|
await self._edit_text(chat_id, message_id, formatted, ParseMode.MARKDOWN_V2)
|
|
except Exception as fmt_err:
|
|
# "Message is not modified" is a no-op, not an error
|
|
if "not modified" in str(fmt_err).lower():
|
|
return SendResult(success=True, message_id=message_id)
|
|
safe_format_error = _redact_telegram_error_text(fmt_err)
|
|
logger.warning(
|
|
"[%s] MarkdownV2 edit failed, falling back to plain text: %s",
|
|
self.name, safe_format_error,
|
|
)
|
|
_plain = _strip_mdv2(content) if content else content
|
|
await self._edit_text(chat_id, message_id, _plain)
|
|
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-and-deliver: parse_mode formatting (MarkdownV2 escapes)
|
|
# can inflate the payload past the limit even when raw text was under.
|
|
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.
|
|
truncated = self._truncate_stream_overflow_preview(content)
|
|
if self._last_overflow_preview.get(_preview_key) == truncated:
|
|
# Saturated-preview dedup (see pre-flight path above).
|
|
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
|
|
)
|
|
return SendResult(success=False, error=safe_retry_error)
|
|
# Transient network errors must not permanently disable progress-message
|
|
# editing: mark retryable so the caller keeps trying next update cycle.
|
|
_transient_markers = (
|
|
"connecterror", "connect error", "connection error", "networkerror",
|
|
"network error", "timed out", "readtimeout", "writetimeout",
|
|
"server disconnected", "temporarily unavailable", "temporary failure", "httpx",
|
|
)
|
|
_is_transient = any(m in err_str for m in _transient_markers)
|
|
if _is_transient:
|
|
safe_error = _redact_telegram_error_text(e)
|
|
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)
|
|
safe_error = _redact_telegram_error_text(e)
|
|
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.
|
|
|
|
Streaming edits must keep targeting the original message; splitting
|
|
mid-stream would move the active id and repeat the overflow cycle. Final
|
|
edits 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 _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 (``(1/N)`` suffix preserved), then send
|
|
the remaining chunks as replies to the previous chunk. Returns
|
|
``SendResult(success=True, message_id=<last-chunk-id>,
|
|
continuation_message_ids=(...))`` so the consumer keeps editing the most
|
|
recent visible message. ``success=False`` only if the first-chunk edit
|
|
itself fails — a real adapter problem, not an overflow.
|
|
"""
|
|
chunks = self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)
|
|
if len(chunks) <= 1:
|
|
# Defensive: caller pre-flighted, but a single chunk just edits normally.
|
|
chunks = [content]
|
|
|
|
# Step 1 — edit the existing message with the first chunk.
|
|
first_chunk = chunks[0]
|
|
try:
|
|
if finalize:
|
|
# Mirror edit_message's happy path: format_message + parse_mode.
|
|
formatted = _separate_chunk_indicator_from_fence(self.format_message(first_chunk))
|
|
try:
|
|
await self._edit_text(chat_id, message_id, formatted, ParseMode.MARKDOWN_V2)
|
|
except Exception as fmt_err:
|
|
if "not modified" not in str(fmt_err).lower():
|
|
logger.warning(
|
|
"[%s] Overflow split: MarkdownV2 first-chunk edit "
|
|
"failed, falling back to plain text: %s",
|
|
self.name, _redact_telegram_error_text(fmt_err),
|
|
)
|
|
await self._edit_text(chat_id, message_id, _strip_mdv2(first_chunk))
|
|
else:
|
|
await self._edit_text(chat_id, message_id, first_chunk)
|
|
except Exception as e:
|
|
err_str = str(e).lower()
|
|
if "not modified" in err_str:
|
|
# First chunk identical to current text — still send continuations.
|
|
pass
|
|
else:
|
|
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))
|
|
|
|
# Step 2 — send remaining chunks as reply-threaded continuations. Calls
|
|
# self._bot.send_message directly to skip self.send's pre-chunking (chunks
|
|
# are already sized). Best-effort MarkdownV2 with plain fallback, like send().
|
|
continuation_ids: list[str] = []
|
|
delivered_chunks = [first_chunk]
|
|
prev_id = message_id
|
|
thread_id = self._metadata_thread_id(metadata)
|
|
for chunk in chunks[1:]:
|
|
sent_msg = None
|
|
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
|
|
)
|
|
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:
|
|
# On finalize the MarkdownV2 attempt failed: degrade to stripped
|
|
# text, never the raw chunk (raw ** / ``` would render
|
|
# literally); streaming previews stay raw.
|
|
text = _strip_mdv2(chunk) if finalize else chunk
|
|
sent_msg = 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,
|
|
**self._link_preview_kwargs(),
|
|
**self._notification_kwargs(metadata),
|
|
)
|
|
break
|
|
except Exception as send_err:
|
|
if "reply message not found" in str(send_err).lower():
|
|
# Drop the reply anchor and retry. Private DM topic fallback
|
|
# needs anchor + topic id together; forum topics keep thread id.
|
|
retry_thread_kwargs = (
|
|
{}
|
|
if metadata and metadata.get("telegram_dm_topic_reply_fallback")
|
|
else self._thread_kwargs_for_send(
|
|
chat_id, thread_id, metadata, reply_to_message_id=None
|
|
)
|
|
)
|
|
try:
|
|
sent_msg = await self._bot.send_message(
|
|
chat_id=normalize_telegram_chat_id(chat_id),
|
|
text=_strip_mdv2(chunk) if finalize else chunk,
|
|
**retry_thread_kwargs,
|
|
**self._link_preview_kwargs(),
|
|
**self._notification_kwargs(metadata),
|
|
)
|
|
break
|
|
except Exception as _retry_err:
|
|
logger.warning(
|
|
"[%s] Overflow continuation no-reply retry failed: %s",
|
|
self.name, _redact_telegram_error_text(_retry_err),
|
|
)
|
|
sent_msg = None
|
|
break
|
|
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),
|
|
)
|
|
sent_msg = None
|
|
break
|
|
if sent_msg is None:
|
|
# Partial delivery: do NOT report success — the stream consumer treats
|
|
# a successful edit as final delivery on got_done, which would suppress
|
|
# fallback delivery and leave the topic clipped.
|
|
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).
|
|
|
|
Used by the stream consumer's fresh-final cleanup to remove long-lived
|
|
previews. Failures are non-fatal — the caller leaves the preview in place.
|
|
"""
|
|
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:
|
|
"""Telegram supports sendMessageDraft for private chats only (Bot API 9.5);
|
|
groups/supergroups/channels use the edit-based path. Also requires PTB >= 22.6
|
|
(``send_message_draft``); older installs fall back to edits even on DMs.
|
|
|
|
``rich_drafts`` controls draft *format*, not availability: with rich final
|
|
delivery but no rich drafts, keep the preview ephemeral and persist via
|
|
``sendRichMessage`` — an in-place edit can't be relied on to upgrade through
|
|
``rich_message``, and the fallback formatter turns tables into bullets.
|
|
"""
|
|
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 Telegram's native draft API.
|
|
|
|
Uses ``sendRichMessageDraft`` (Bot API 10.1) when rich is enabled and
|
|
supported, else plain ``sendMessageDraft``. Reusing ``draft_id`` animates
|
|
the preview. The caller sends the final text via ``send``; the draft clears
|
|
on the client (no Bot API "promotes" a draft to a real message).
|
|
"""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="not_connected")
|
|
|
|
# Rich draft fast-path: preview with the same raw markdown the final
|
|
# sendRichMessage persists. Any failure degrades to the plain draft below.
|
|
if self._should_attempt_rich_draft(content) and await self._try_send_rich_draft(
|
|
chat_id, draft_id, content, metadata
|
|
):
|
|
# Drafts have no message_id; report success without one.
|
|
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]
|
|
|
|
# Apply the same MarkdownV2 conversion as ``send`` so the draft doesn't snap
|
|
# from raw to formatted at the end; try MarkdownV2 then plain so one bad
|
|
# token never kills draft streaming. Exception: when the persistent
|
|
# response will be a Rich Message but rich drafts are disabled, send a raw
|
|
# preview — the legacy formatter would rewrite 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_modes = (False,) if plain_rich_preview else (True, False)
|
|
draft_thread_kwargs = self._thread_kwargs_for_draft(chat_id, metadata)
|
|
for use_markdown in draft_modes:
|
|
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:
|
|
ok = await self._bot.send_message_draft(**kwargs)
|
|
if ok:
|
|
# Drafts have no message_id; success means the frame landed.
|
|
return SendResult(success=True, message_id=None)
|
|
return SendResult(success=False, error="draft_rejected")
|
|
except Exception as e:
|
|
# MarkdownV2 parse failure (BadRequest) → retry once as plain text.
|
|
# Anything else, or a plain-text failure, 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 message, retrying once without message_thread_id on 'Message
|
|
thread not found'. For control-style sends (approval prompts, pickers,
|
|
update prompts) that can carry a stale thread_id; ``send`` has its own.
|
|
"""
|
|
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: the topic is gone, so the
|
|
# state.db binding must go too. Control sends carry no gateway
|
|
# metadata, so the prune namespaces by this adapter's profile stamp.
|
|
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_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."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
try:
|
|
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"),
|
|
]])
|
|
msg = await self._send_control_message(
|
|
chat_id, text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard,
|
|
thread_id=self._metadata_thread_id(metadata), metadata=metadata,
|
|
reply_to_mode=self._reply_to_mode,
|
|
)
|
|
return SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e:
|
|
logger.warning("[%s] send_update_prompt failed: %s", self.name, _redact_telegram_error_text(e))
|
|
return SendResult(success=False, error=_redact_telegram_error_text(e))
|
|
|
|
# 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)
|
|
|
|
async def send_exec_approval(
|
|
self, chat_id: str, command: str, session_key: str,
|
|
description: str = "dangerous command",
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
allow_permanent: bool = True,
|
|
allow_session: bool = True,
|
|
smart_denied: bool = False,
|
|
) -> SendResult:
|
|
"""Send an inline-keyboard approval prompt; buttons call
|
|
``resolve_gateway_approval()`` like the text ``/approve`` flow."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
try:
|
|
text = self._format_exec_approval(command, description, smart_denied)
|
|
thread_id = self._metadata_thread_id(metadata)
|
|
|
|
# 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("✅ Allow Once", callback_data=f"ea:once:{approval_id}")]
|
|
if not smart_denied and allow_session:
|
|
buttons.append(
|
|
InlineKeyboardButton("✅ Session", callback_data=f"ea:session:{approval_id}")
|
|
)
|
|
if allow_permanent:
|
|
buttons.append(
|
|
InlineKeyboardButton("✅ Always", callback_data=f"ea:always:{approval_id}")
|
|
)
|
|
buttons.append(InlineKeyboardButton("❌ Deny", callback_data=f"ea:deny:{approval_id}"))
|
|
# 2x2 rows keep labels readable on mobile (a 4-button row truncates).
|
|
rows = [buttons[i:i + 2] for i in range(0, len(buttons), 2)]
|
|
keyboard = InlineKeyboardMarkup(rows)
|
|
|
|
msg = await self._send_control_message(
|
|
chat_id, text, parse_mode=ParseMode.HTML, reply_markup=keyboard,
|
|
thread_id=thread_id, metadata=metadata, reply_to_mode=self._reply_to_mode,
|
|
)
|
|
self._approval_state[approval_id] = session_key
|
|
return SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e:
|
|
logger.warning("[%s] send_exec_approval failed: %s", self.name, _redact_telegram_error_text(e))
|
|
return SendResult(success=False, error=_redact_telegram_error_text(e))
|
|
|
|
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."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
try:
|
|
preview = self.format_message(self._truncate_preview(message, 3800))
|
|
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}")],
|
|
])
|
|
msg = await self._send_control_message(
|
|
chat_id, preview, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard,
|
|
thread_id=self._metadata_thread_id(metadata), metadata=metadata,
|
|
reply_to_mode=self._reply_to_mode,
|
|
)
|
|
self._slash_confirm_state[confirm_id] = session_key
|
|
return SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e:
|
|
logger.warning("[%s] send_slash_confirm failed: %s", self.name, _redact_telegram_error_text(e))
|
|
return SendResult(success=False, error=_redact_telegram_error_text(e))
|
|
|
|
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.
|
|
|
|
With ``choices``: one numbered button per option plus "✏️ Other (type
|
|
answer)", which flips the entry into text-capture mode. Without: plain
|
|
question, no buttons; the gateway's text-intercept captures the next message.
|
|
"""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
try:
|
|
text = f"❓ {_html.escape(question)}"
|
|
thread_id = self._metadata_thread_id(metadata)
|
|
|
|
if choices:
|
|
# Full option text goes in the body (mobile truncates button labels);
|
|
# buttons keep short numeric labels.
|
|
option_lines = "\n".join(
|
|
f"{i + 1}. {_html.escape(str(c))}" for i, c in enumerate(choices)
|
|
)
|
|
text += f"\n\n{option_lines}"
|
|
|
|
keyboard = None
|
|
if 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)
|
|
|
|
msg = await self._send_control_message(
|
|
chat_id, text, parse_mode=ParseMode.HTML, reply_markup=keyboard,
|
|
thread_id=thread_id, metadata=metadata,
|
|
)
|
|
self._clarify_state[clarify_id] = session_key
|
|
return SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e:
|
|
logger.warning("[%s] send_clarify failed: %s", self.name, _redact_telegram_error_text(e))
|
|
return SendResult(success=False, error=_redact_telegram_error_text(e))
|
|
|
|
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."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
try:
|
|
from hermes_cli.providers import get_label
|
|
except ImportError:
|
|
def get_label(slug):
|
|
return slug
|
|
try:
|
|
keyboard, provider_page_info = self._build_provider_keyboard(providers, 0)
|
|
provider_label = get_label(current_provider)
|
|
text = self.format_message(
|
|
f"⚙ *Model Configuration*\n\n"
|
|
f"Current model: `{current_model or 'unknown'}`\n"
|
|
f"Provider: {provider_label}\n\n"
|
|
f"Select a provider:{provider_page_info}"
|
|
)
|
|
msg = await self._send_control_message(
|
|
chat_id, text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard,
|
|
thread_id=metadata.get("thread_id") if metadata else None, metadata=metadata,
|
|
reply_to_mode=self._reply_to_mode,
|
|
)
|
|
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 SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e:
|
|
logger.warning("[%s] send_model_picker failed: %s", self.name, _redact_telegram_error_text(e))
|
|
return SendResult(success=False, error=_redact_telegram_error_text(e))
|
|
|
|
_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}``.
|
|
"""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
try:
|
|
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")
|
|
# Two buttons per row keeps labels readable on mobile.
|
|
keyboard = InlineKeyboardMarkup([buttons[i:i + 2] for i in range(0, len(buttons), 2)])
|
|
msg = await self._send_control_message(
|
|
chat_id, self.format_message(title), parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard,
|
|
thread_id=metadata.get("thread_id") if metadata else None, metadata=metadata,
|
|
reply_to_mode=self._reply_to_mode,
|
|
)
|
|
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 SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e:
|
|
logger.warning("[%s] send_choice_picker failed: %s", self.name, _redact_telegram_error_text(e))
|
|
return SendResult(success=False, error=_redact_telegram_error_text(e))
|
|
|
|
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/config state via someone else's picker message.
|
|
if not await self._callback_authorized(
|
|
query, self._callback_ctx(query), "⛔ You are not authorized to change this setting."
|
|
):
|
|
return
|
|
try:
|
|
idx = int(data[3:])
|
|
choice = state["choices"][idx]
|
|
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}"
|
|
try:
|
|
await query.edit_message_text(
|
|
text=self.format_message(result_text), parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None
|
|
)
|
|
except Exception:
|
|
try:
|
|
await query.edit_message_text(text=result_text, parse_mode=None, reply_markup=None)
|
|
except Exception:
|
|
pass
|
|
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 _build_provider_keyboard(self, providers: list, page: int = 0) -> tuple:
|
|
"""Paginated top-level provider keyboard, folding provider families.
|
|
|
|
Families (Kimi/Moonshot, MiniMax, xAI...) become one ``mpg:<gid>`` button that
|
|
drills into members; singles (and one-member groups) are direct ``mp:<slug>``.
|
|
Uses the shared ``group_providers`` fold so it matches the CLI picker.
|
|
"""
|
|
try:
|
|
from hermes_cli.models 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:
|
|
for p in providers:
|
|
buttons.append(self._provider_button(p))
|
|
|
|
page_buttons, page_meta = self._format_choice_page(buttons, page, self._PROVIDER_PAGE_SIZE)
|
|
rows = [page_buttons[i : i + 2] for i in range(0, len(page_buttons), 2)]
|
|
if page_meta["total_pages"] > 1:
|
|
rows.append(self._picker_nav_row(page_meta["page"], page_meta["total_pages"], "mpv"))
|
|
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
|
return InlineKeyboardMarkup(rows), page_meta["page_info"]
|
|
|
|
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}"))
|
|
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
|
if page_meta["total_pages"] > 1:
|
|
rows.append(self._picker_nav_row(page_meta["page"], page_meta["total_pages"], "mg"))
|
|
rows.append(self._picker_back_cancel_row())
|
|
return InlineKeyboardMarkup(rows), page_meta["page_info"]
|
|
|
|
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,
|
|
)
|
|
|
|
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,
|
|
f"⚙ *Model Configuration*\n\n"
|
|
f"Current model: `{state['current_model'] or 'unknown'}`\n"
|
|
f"Provider: {provider_label}\n\n"
|
|
f"Select a provider:{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
|
|
try:
|
|
await query.edit_message_text(
|
|
text=self.format_message(result_text), parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None
|
|
)
|
|
except Exception:
|
|
# Markdown parse failure — retry as plain text
|
|
try:
|
|
await query.edit_message_text(text=result_text, parse_mode=None, reply_markup=None)
|
|
except Exception:
|
|
pass
|
|
await query.answer(text="Switch failed." if switch_failed else "Model switched!")
|
|
self._model_picker_state.pop(chat_id, 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
|
|
try:
|
|
from hermes_cli.providers import get_label
|
|
except ImportError:
|
|
def get_label(slug):
|
|
return slug
|
|
|
|
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
|
|
try:
|
|
page = int(data[3:])
|
|
except ValueError:
|
|
await query.answer(text="Invalid page.")
|
|
return
|
|
await self._picker_show_models(query, state, page)
|
|
|
|
elif data.startswith("mpv:"):
|
|
# Provider page navigation
|
|
try:
|
|
page = int(data[4:])
|
|
except ValueError:
|
|
await query.answer(text="Invalid page.")
|
|
return
|
|
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 None:
|
|
return
|
|
_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 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
|
|
buttons = [self._provider_button(p) for p in members]
|
|
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
|
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)
|
|
page = int(state.get("provider_page", 0) or 0)
|
|
await self._picker_show_providers(query, state, page, 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 by ``clarify_timeout``
|
|
or gateway restarted) — otherwise the tap leaves a misleading ✓ the agent never sees."""
|
|
try:
|
|
await query.answer(text="⚠️ This prompt expired — please /retry.")
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await query.edit_message_text(
|
|
text=(
|
|
f"❓ {_html.escape(query.message.text or '')}\n\n"
|
|
"<i>⚠️ This question expired or the session reset — please /retry.</i>"
|
|
),
|
|
parse_mode=ParseMode.HTML,
|
|
reply_markup=None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
async def _handle_inline_query(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
|
|
"""Answer ``@botname <query>`` with a searchable command/skill picker.
|
|
|
|
The BotCommand menu is capped (100/scope, ~4KB; 60-slot Hermes default), so most
|
|
skill commands never fit the ``/`` menu. Inline mode is uncapped: results are computed
|
|
per keystroke, paginated 50 at a time (Telegram's per-answer max). Tapping a result
|
|
sends the command text into the chat as the user, so dispatch flows through the normal
|
|
command path — this handler only *offers* text and is read-only by construction.
|
|
|
|
Inline queries arrive from ANY chat (even ones the bot isn't in), so results are only
|
|
served to users passing the same auth as inline-button callbacks; unauthorized users
|
|
get an empty list so the installed-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."""
|
|
caller_id = str(getattr(query.from_user, "id", ""))
|
|
if self._is_callback_user_authorized(
|
|
caller_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.
|
|
if data.startswith(("mp:", "mpg:", "mpv:", "mm:", "mc:", "mb", "mx", "mg:")):
|
|
chat_id = str(query.message.chat_id) if query.message else None
|
|
if chat_id:
|
|
await self._handle_model_picker_callback(query, data, chat_id)
|
|
return
|
|
if data.startswith("cp:"):
|
|
chat_id = str(query.message.chat_id) if query.message else None
|
|
if chat_id:
|
|
await self._handle_choice_picker_callback(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 _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
|
|
if not await self._callback_authorized(query, cb, "⛔ You are not authorized to approve commands."):
|
|
return
|
|
session_key = self._approval_state.pop(approval_id, None)
|
|
if not session_key:
|
|
await query.answer(text="This approval has already been resolved.")
|
|
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:
|
|
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. "
|
|
f"It already timed out (and was denied) or was resolved elsewhere."
|
|
)
|
|
await query.answer(text=label)
|
|
try:
|
|
await query.edit_message_text(
|
|
text=self.format_message(edit_text), parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None
|
|
)
|
|
except Exception:
|
|
pass # non-fatal if edit fails
|
|
# Typing was paused when the approval was sent (gateway/run.py); the text /approve
|
|
# and /deny paths resume it too — without this, typing stays paused for the turn.
|
|
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]
|
|
if not await self._callback_authorized(query, cb, "⛔ You are not authorized to answer this prompt."):
|
|
return
|
|
session_key = self._slash_confirm_state.pop(confirm_id, None)
|
|
if not session_key:
|
|
await query.answer(text="This prompt has already been resolved.")
|
|
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)
|
|
try:
|
|
await query.edit_message_text(
|
|
text=self.format_message(f"{label} by {user_display}"),
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
reply_markup=None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
# The runner stored a handler keyed by session_key; run it and, if it returns a
|
|
# string, send that as a follow-up in the same chat.
|
|
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 = getattr(query.message, "chat", None)
|
|
chat_type = getattr(chat, "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(),
|
|
}
|
|
chat_type_value = getattr(chat_type, "value", chat_type)
|
|
is_private_chat = str(chat_type_value).lower() in {
|
|
"private", str(ChatType.PRIVATE).lower(),
|
|
str(getattr(ChatType.PRIVATE, "value", ChatType.PRIVATE)).lower(),
|
|
}
|
|
if thread_id is not None and is_private_chat and prompt_message_id is not None:
|
|
reply_to_id = int(prompt_message_id)
|
|
send_kwargs["reply_to_message_id"] = reply_to_id
|
|
send_kwargs.update(self._thread_kwargs_for_send(
|
|
str(query.message.chat_id), str(thread_id),
|
|
{"thread_id": str(thread_id), "telegram_dm_topic_reply_fallback": True},
|
|
reply_to_message_id=reply_to_id, reply_to_mode=self._reply_to_mode,
|
|
))
|
|
elif thread_id is not None:
|
|
send_kwargs.update(self._thread_kwargs_for_send(
|
|
str(query.message.chat_id), str(thread_id), {"thread_id": str(thread_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]
|
|
if not await self._callback_authorized(query, cb, "⛔ You are not authorized to answer this prompt."):
|
|
return
|
|
session_key = self._clarify_state.get(clarify_id)
|
|
if not session_key:
|
|
await query.answer(text="This prompt has already been resolved.")
|
|
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 in this session. Do NOT pop _clarify_state yet — still needed if
|
|
# the user is slow and 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.")
|
|
try:
|
|
await query.edit_message_text(
|
|
text=f"❓ {query.message.text or ''}\n\n<i>Awaiting typed response from {_html.escape(user_display)}…</i>",
|
|
parse_mode=ParseMode.HTML,
|
|
reply_markup=None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
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): entry vanished. Echo the index so the agent
|
|
# at least sees an intentional response rather than nothing.
|
|
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]}")
|
|
try:
|
|
await query.edit_message_text(
|
|
text=f"❓ {_html.escape(query.message.text or '')}\n\n<b>{_html.escape(user_display)}:</b> {_html.escape(resolved_text)}",
|
|
parse_mode=ParseMode.HTML,
|
|
reply_markup=None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
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.")
|
|
label = "Yes" if answer == "y" else "No"
|
|
try:
|
|
await query.edit_message_text(
|
|
text=self.format_message(f"⚕ Update prompt answered: *{label}*"),
|
|
parse_mode=ParseMode.MARKDOWN_V2,
|
|
reply_markup=None,
|
|
)
|
|
except Exception:
|
|
pass # non-fatal if edit fails
|
|
try:
|
|
from hermes_constants import get_hermes_home
|
|
home = get_hermes_home()
|
|
response_path = 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 = sticky sender-rule
|
|
# change that keeps the keyboard tappable; False = per-email one-shot that strips it on success.
|
|
_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
|
|
script_path = _Path.home() / ".hermes" / "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
|
|
cmd = [str(script_path), arg, *extra_args]
|
|
success = False
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd, 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
|
|
user_display = getattr(query.from_user, "first_name", "User")
|
|
original_text = (query.message.text or "") if query.message else ""
|
|
appended = f"{original_text}\n— {label} by {user_display}"
|
|
try:
|
|
if is_state_verb:
|
|
# Sticky state change: KEEP keyboard so further actions can stack on this email.
|
|
await query.edit_message_text(text=appended)
|
|
else:
|
|
# One-shot: strip keyboard so the action can't fire twice.
|
|
await query.edit_message_text(text=appended, reply_markup=None)
|
|
except Exception:
|
|
pass
|
|
|
|
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_mb = int(file_size or 0) / (1024 * 1024)
|
|
size_text = f"{size_mb:.1f} MB"
|
|
except (TypeError, ValueError):
|
|
size_text = "unknown size"
|
|
return (
|
|
f"[Telegram {label} skipped: file size {size_text} exceeds the "
|
|
f"{limit_mb} MB limit. Ask the user to send a smaller file.]"
|
|
)
|
|
|
|
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)
|
|
file_size = getattr(source, "file_size", None)
|
|
try:
|
|
size = int(file_size or 0)
|
|
except (TypeError, ValueError):
|
|
size = 0
|
|
if size <= 0:
|
|
return True, None
|
|
if 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,
|
|
)
|
|
|
|
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).
|
|
_voice_ext = os.path.splitext(audio_path)[1].lower()
|
|
if kwargs.get("is_voice") and _voice_ext 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)
|
|
# Auto-TTS captions carry the agent's markdown reply: MarkdownV2 when it fits the
|
|
# 1024-char cap, plain text fallback when it overflows or Bot API rejects it.
|
|
_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))
|
|
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 = None
|
|
_last_parse_error: Optional[Exception] = None
|
|
for _cap_text, _cap_parse_mode in _caption_variants:
|
|
try:
|
|
msg = 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,
|
|
)
|
|
break
|
|
except Exception as _cap_error:
|
|
# Only retry plain on entity-parse failures; anything else is a real error.
|
|
if (_cap_parse_mode is not None
|
|
and ("parse" in str(_cap_error).lower()
|
|
or "entit" in str(_cap_error).lower())):
|
|
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
|
|
if msg is None:
|
|
raise _last_parse_error or RuntimeError(
|
|
"Telegram send_voice failed for all caption variants"
|
|
)
|
|
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=caption[:1024] if caption else None, 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:
|
|
try:
|
|
os.unlink(_transcoded_voice_path)
|
|
except OSError:
|
|
pass
|
|
|
|
async def send_multiple_images(
|
|
self, chat_id: str, images: List[tuple], metadata: Optional[Dict[str, Any]] = None,
|
|
human_delay: float = 0.0,
|
|
) -> None:
|
|
"""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; a failed chunk also falls back to the base per-image loop.
|
|
"""
|
|
if not self._bot:
|
|
return
|
|
if not images:
|
|
return
|
|
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
|
|
)
|
|
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
|
return
|
|
animations: List[tuple] = []
|
|
photos: List[tuple] = []
|
|
for image_url, alt_text in images:
|
|
if not image_url.startswith("file://") and self._is_animation_url(image_url):
|
|
animations.append((image_url, alt_text))
|
|
else:
|
|
photos.append((image_url, alt_text))
|
|
if animations:
|
|
await super().send_multiple_images(chat_id, animations, metadata, human_delay=human_delay)
|
|
if not photos:
|
|
return
|
|
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:
|
|
caption = alt_text[:1024] if alt_text else None
|
|
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
|
|
fh = open(local_path, "rb")
|
|
opened_files.append(fh)
|
|
media.append(InputMediaPhoto(media=fh, caption=caption))
|
|
else:
|
|
media.append(InputMediaPhoto(media=image_url, caption=caption))
|
|
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:
|
|
try:
|
|
fh.seek(0)
|
|
except Exception:
|
|
pass
|
|
|
|
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,
|
|
)
|
|
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,
|
|
)
|
|
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
|
|
finally:
|
|
for fh in opened_files:
|
|
try:
|
|
fh.close()
|
|
except Exception:
|
|
pass
|
|
|
|
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."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
try:
|
|
if not os.path.exists(image_path):
|
|
return SendResult(success=False, error=self._missing_media_path_error("Image", image_path))
|
|
with open(image_path, "rb") as image_file:
|
|
msg = await self._send_media(
|
|
self._bot.send_photo, chat_id, reply_to, metadata, "photo",
|
|
reset_media=lambda: image_file.seek(0), photo=image_file,
|
|
caption=caption[:1024] if caption else None,
|
|
)
|
|
return SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e:
|
|
error_str = str(e)
|
|
# Dimension errors are expected for valid images Telegram refuses as photos
|
|
# (screenshots, extreme aspect ratios) → INFO; anything else → WARNING.
|
|
is_dim_error = "Photo_invalid_dimensions" in error_str or "PHOTO_INVALID_DIMENSIONS" in error_str
|
|
if is_dim_error:
|
|
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().send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata)
|
|
|
|
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."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
try:
|
|
if not os.path.exists(file_path):
|
|
return SendResult(success=False, error=self._missing_media_path_error("File", file_path))
|
|
with open(file_path, "rb") as f:
|
|
msg = await self._send_media(
|
|
self._bot.send_document, chat_id, reply_to, metadata, "document",
|
|
reset_media=lambda: f.seek(0), document=f,
|
|
filename=file_name or os.path.basename(file_path),
|
|
caption=caption[:1024] if caption else None,
|
|
)
|
|
return SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e:
|
|
logger.warning("[%s] Failed to send document: %s", self.name, _redact_telegram_error_text(e))
|
|
return await super().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."""
|
|
if not self._bot:
|
|
return SendResult(success=False, error="Not connected")
|
|
try:
|
|
if not os.path.exists(video_path):
|
|
return SendResult(success=False, error=self._missing_media_path_error("Video", video_path))
|
|
with open(video_path, "rb") as f:
|
|
msg = await self._send_media(
|
|
self._bot.send_video, chat_id, reply_to, metadata, "video",
|
|
reset_media=lambda: f.seek(0), video=f, caption=caption[:1024] if caption else None,
|
|
)
|
|
return SendResult(success=True, message_id=str(msg.message_id))
|
|
except Exception as e:
|
|
logger.warning("[%s] Failed to send video: %s", self.name, _redact_telegram_error_text(e))
|
|
return await super().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 = caption[:1024] if caption else None
|
|
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=caption[:1024] if caption else None,
|
|
)
|
|
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."""
|
|
retry_after = getattr(exc, "retry_after", None)
|
|
if retry_after 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 = {}
|
|
loop = asyncio.get_running_loop()
|
|
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
|
|
delay = max(1.0, min(delay, 300.0))
|
|
self._telegram_typing_cooldown_until[str(chat_id)] = loop.time() + delay
|
|
|
|
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
|
|
try:
|
|
_typing_thread = self._metadata_thread_id(metadata)
|
|
_is_dm_topic = bool(metadata and metadata.get("telegram_dm_topic_reply_fallback"))
|
|
message_thread_id = self._message_thread_id_for_typing(_typing_thread)
|
|
await self._bot.send_chat_action(
|
|
chat_id=normalize_telegram_chat_id(chat_id), action="typing",
|
|
message_thread_id=message_thread_id,
|
|
)
|
|
self._telegram_typing_cooldown_until.pop(str(chat_id), None)
|
|
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 self._bot.send_chat_action(
|
|
chat_id=normalize_telegram_chat_id(chat_id), action="typing"
|
|
)
|
|
self._telegram_typing_cooldown_until.pop(str(chat_id), None)
|
|
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)
|
|
# Typing failures are non-fatal; debug only.
|
|
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 = "group"
|
|
if chat.is_forum:
|
|
chat_type = "forum"
|
|
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 blocks/inline code are stashed behind placeholders first so they're 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
|
|
|
|
text = content
|
|
# 0) GFM pipe tables → Telegram-friendly row groups, before the MarkdownV2 conversions.
|
|
text = _wrap_markdown_tables(text)
|
|
|
|
# 1) Protect fenced code blocks; per MarkdownV2 spec \ and ` inside pre/code must be escaped.
|
|
def _protect_fenced(m):
|
|
raw = m.group(0)
|
|
# Split off opening ``` (with optional language) and closing ```
|
|
open_end = raw.index('\n') + 1 if '\n' in raw[3:] else 3
|
|
opening = raw[:open_end]
|
|
body_and_close = raw[open_end:]
|
|
body = body_and_close[:-3]
|
|
body = body.replace('\\', '\\\\').replace('`', '\\`')
|
|
return _ph(opening + 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):
|
|
display = _escape_mdv2(m.group(1))
|
|
url = m.group(2).replace('\\', '\\\\').replace(')', '\\)')
|
|
return _ph(f'[{display}]({url})')
|
|
|
|
text = re.sub(r'\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)', _convert_link, text)
|
|
|
|
# 4) Headers (## Title) → bold *Title*, stripping redundant ** inside the header
|
|
def _convert_header(m):
|
|
inner = m.group(1).strip()
|
|
inner = re.sub(r'\*\*(.+?)\*\*', r'\1', inner)
|
|
return _ph(f'*{_escape_mdv2(inner)}*')
|
|
|
|
text = re.sub(r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE)
|
|
# 5) Bold: **text** → *text*
|
|
text = re.sub(r'\*\*(.+?)\*\*', lambda m: _ph(f'*{_escape_mdv2(m.group(1))}*'), text)
|
|
# 6) Italic: *text* → _text_. [^*\n]+ keeps matches on one line, or * bullet lists corrupt.
|
|
text = re.sub(r'\*([^*\n]+)\*', lambda m: _ph(f'_{_escape_mdv2(m.group(1))}_'), text)
|
|
# 7) Strikethrough: ~~text~~ → ~text~
|
|
text = re.sub(r'~~(.+?)~~', lambda m: _ph(f'~{_escape_mdv2(m.group(1))}~'), text)
|
|
# 8) Spoiler: ||text|| kept as-is (protect from | escaping)
|
|
text = re.sub(r'\|\|(.+?)\|\|', lambda m: _ph(f'||{_escape_mdv2(m.group(1))}||'), text)
|
|
|
|
# 9) Blockquotes: protect leading > from escaping. Also expandable quotes
|
|
# (**> starts, trailing || ends — that || must stay unescaped).
|
|
def _convert_blockquote(m):
|
|
prefix = m.group(1) # >, >>, >>>, **>, or **>> etc.
|
|
content = m.group(2)
|
|
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.
|
|
_code_split = re.split(r'(```[\s\S]*?```|`[^`]+`)', text)
|
|
_safe_parts = []
|
|
for _idx, _seg in enumerate(_code_split):
|
|
if _idx % 2 == 1:
|
|
_safe_parts.append(_seg) # inside code — untouched
|
|
else:
|
|
def _esc_bare(m, _seg=_seg):
|
|
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
|
|
_safe_parts.append(re.sub(r'[(){}]', _esc_bare, _seg))
|
|
text = ''.join(_safe_parts)
|
|
return text
|
|
|
|
# ── 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 os.getenv(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 (with ``require_mention``:
|
|
observe chatter in the transcript, 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_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 = str(getattr(getattr(message, "chat", None), "id", ""))
|
|
if not chat_id:
|
|
return False
|
|
thread_id = self._effective_message_thread_id(message)
|
|
topic_id = str(thread_id) if thread_id is not None else self._GENERAL_TOPIC_THREAD_ID
|
|
return f"{chat_id}:{topic_id}" 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``
|
|
(auth allowlist for user-less sources) ∩ ``allowed_chats`` (response gate) when set."""
|
|
group_allowed = self._telegram_group_allowed_chats()
|
|
if not group_allowed:
|
|
return set()
|
|
response_allowed = self._telegram_allowed_chats()
|
|
if response_allowed:
|
|
return group_allowed & response_allowed
|
|
return 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")
|
|
values = raw if isinstance(raw, list) else str(raw).split(",")
|
|
ignored: set[int] = set()
|
|
for value in values:
|
|
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 = os.getenv("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 []
|
|
return compile_mention_patterns(
|
|
patterns, log_prefix=self.name, platform_label="telegram",
|
|
display_label="Telegram", logger_=logger,
|
|
)
|
|
|
|
def _is_group_chat(self, message: Message) -> bool:
|
|
chat = getattr(message, "chat", None)
|
|
if not chat:
|
|
return False
|
|
chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower()
|
|
return chat_type in {"group", "supergroup"}
|
|
|
|
@classmethod
|
|
def _effective_message_thread_id(cls, message: Message) -> Optional[str]:
|
|
"""Return the routable thread id for a Telegram message.
|
|
|
|
Forum General-topic messages arrive with ``message_thread_id=None`` but Telegram
|
|
addresses that topic as id ``1``; conversely, plain group/DM replies carry a reply-UI
|
|
anchor in ``message_thread_id`` that is NOT a routing id. Gating, skill binding and
|
|
outbound routing must all agree on this one normalized value.
|
|
"""
|
|
chat = getattr(message, "chat", None)
|
|
chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower() if chat else ""
|
|
raw = getattr(message, "message_thread_id", None)
|
|
is_topic_message = bool(getattr(message, "is_topic_message", False))
|
|
is_forum_group = chat_type in ("group", "supergroup") and getattr(chat, "is_forum", False) is True
|
|
if raw is not None:
|
|
if is_forum_group or (chat_type in ("group", "supergroup") and is_topic_message):
|
|
return str(raw)
|
|
if chat_type == "private" and is_topic_message:
|
|
return str(raw)
|
|
return None
|
|
if is_forum_group:
|
|
return cls._GENERAL_TOPIC_THREAD_ID
|
|
return 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)
|
|
# How long an observed identity is trusted before the heartbeat re-checks.
|
|
_BOT_IDENTITY_TTL_SECONDS = 300.0
|
|
|
|
def _current_bot_username(self) -> str:
|
|
"""Return this bot's live @username (lowercased, no leading ``@``).
|
|
|
|
Prefers the last observed handle over PTB's ``get_me()`` cache: ``Bot.username`` is
|
|
only refreshed by ``get_me()``, so after a BotFather rename it keeps the stale handle
|
|
and every mention comparison silently stops matching.
|
|
"""
|
|
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.
|
|
|
|
Telegram stamps the *current* username on our own messages and on ``reply_to_message``
|
|
when a user replies to us, so renames are observable without getMe. Only trusted when
|
|
the user id matches this bot, so a foreign handle can never be adopted.
|
|
"""
|
|
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 None:
|
|
continue
|
|
if getattr(candidate, "id", None) != bot_id:
|
|
continue
|
|
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 that can be smaller than the TTL on a fresh host.
|
|
"""
|
|
checked_at = getattr(self, "_bot_identity_checked_at", None)
|
|
if checked_at is None:
|
|
return False
|
|
return (time.monotonic() - checked_at) < self._BOT_IDENTITY_TTL_SECONDS
|
|
|
|
async def _refresh_bot_identity(self, *, force: bool = False) -> None:
|
|
"""Re-read bot identity from Telegram when the cache may be stale.
|
|
|
|
``get_me()`` rewrites PTB's ``Bot._bot_user`` in place, repairing every consumer of
|
|
``self._bot.username``. 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))
|
|
|
|
@classmethod
|
|
def _extract_bot_mention_usernames(cls, message: Message, self_username: str = "") -> set[str]:
|
|
"""Extract explicit bot usernames mentioned in text/captions.
|
|
|
|
Foreign handles count only when bot-shaped (``...bot``) so human @handles never act
|
|
as routing hints; ``self_username`` opts our OWN handle in regardless of shape
|
|
(collectible usernames like @jarvis). Entity mentions are authoritative; the raw-text
|
|
fallback is deliberately narrow (no emails / arbitrary substrings).
|
|
"""
|
|
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))
|
|
|
|
def _iter_sources():
|
|
yield getattr(message, "text", None) or "", getattr(message, "entities", None) or []
|
|
yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or []
|
|
for source_text, entities in _iter_sources():
|
|
for entity in entities:
|
|
entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
|
|
if entity_type not in {"mention", "bot_command"}:
|
|
continue
|
|
offset = int(getattr(entity, "offset", -1))
|
|
length = int(getattr(entity, "length", 0))
|
|
if offset < 0 or length <= 0:
|
|
continue
|
|
entity_text = TelegramAdapter._telegram_entity_text(source_text, offset, length).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 (no separate mention); its suffix
|
|
# is an explicit bot address for exclusive multi-bot routing.
|
|
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 and do not
|
|
# regex-rescue URL/code spans the server did not mark as mentions.
|
|
for raw_text, entities in _iter_sources():
|
|
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:
|
|
raw = source_text.encode("utf-16-le")
|
|
start = offset * 2
|
|
end = (offset + length) * 2
|
|
return raw[start:end].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
|
|
|
|
def _iter_sources():
|
|
yield getattr(message, "text", None) or "", getattr(message, "entities", None) or []
|
|
yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or []
|
|
|
|
# Server-side MessageEntity values are authoritative (mention=@username,
|
|
# text_mention=user without public handle): raw substrings like
|
|
# "foo@hermes_bot.example" or handles inside URLs/code are not mentions.
|
|
for source_text, entities in _iter_sources():
|
|
for entity in entities:
|
|
entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
|
|
if entity_type == "mention" and expected:
|
|
offset = int(getattr(entity, "offset", -1))
|
|
length = int(getattr(entity, "length", 0))
|
|
if offset < 0 or length <= 0:
|
|
continue
|
|
if self._telegram_entity_text(source_text, offset, length).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`` is a single bot_command entity with no mention entity.
|
|
# It is what Telegram's group command menu produces, so it must count
|
|
# as a direct address or require_mention groups lose slash commands.
|
|
offset = int(getattr(entity, "offset", -1))
|
|
length = int(getattr(entity, "length", 0))
|
|
if offset < 0 or length <= 0:
|
|
continue
|
|
command_text = self._telegram_entity_text(source_text, offset, length)
|
|
at_index = command_text.find("@")
|
|
if at_index < 0:
|
|
continue
|
|
if 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.
|
|
|
|
Called when routing is about to discard a message naming other bots but not us — the
|
|
symptom of a stale handle after a rename. TTL-bounded to one getMe per
|
|
``_BOT_IDENTITY_TTL_SECONDS``; fire-and-forget, the current message routes as-is.
|
|
"""
|
|
existing = getattr(self, "_bot_identity_refresh_task", None)
|
|
if existing is not None and not existing.done():
|
|
return
|
|
if 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:
|
|
"""Return True when explicit bot handles target other bots, not this one.
|
|
|
|
Groups may hold several Hermes bots; ``@bot3 hi @bot4`` must not wake ``@bot1`` via
|
|
reply/wake-word fallbacks. Foreign handles are limited to the ``...bot`` shape so
|
|
human @handles never suppress us; our own handle is matched by identity.
|
|
"""
|
|
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 identity out of band (TTL bounded) so the mistake self-corrects.
|
|
self._schedule_bot_identity_recheck()
|
|
return excludes_self
|
|
|
|
def _message_matches_mention_patterns(self, message: Message) -> bool:
|
|
if not self._mention_patterns:
|
|
return False
|
|
for candidate in (getattr(message, "text", None), getattr(message, "caption", None)):
|
|
if not candidate:
|
|
continue
|
|
for pattern in self._mention_patterns:
|
|
if pattern.search(candidate):
|
|
return True
|
|
return False
|
|
|
|
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
|
|
username = re.escape(bot_username)
|
|
cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip()
|
|
return cleaned or text
|
|
|
|
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):
|
|
return False
|
|
if not self._telegram_observe_unmentioned_group_messages():
|
|
return False
|
|
if not self._is_group_chat(message):
|
|
return False
|
|
thread_id = getattr(message, "message_thread_id", None)
|
|
allowed_topics = self._telegram_allowed_topics()
|
|
if allowed_topics:
|
|
topic_id = str(thread_id) if thread_id is not None else self._GENERAL_TOPIC_THREAD_ID
|
|
if topic_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):
|
|
return False
|
|
chat_id_str = str(getattr(getattr(message, "chat", None), "id", ""))
|
|
if self._telegram_exclusive_bot_mentions() and self._explicit_bot_mentions_exclude_self(message):
|
|
return False
|
|
allowed = self._telegram_observe_allowed_chats()
|
|
# Observed context is shared at chat/topic scope, so require an explicit chat
|
|
# allowlist: it limits shared history to operator-approved groups and lets gateway
|
|
# auth pass once the shared source drops the per-sender user_id.
|
|
if not allowed or chat_id_str not in allowed:
|
|
return False
|
|
# Only observe messages the require_mention gate would skip; anything that would be
|
|
# processed normally belongs to the dispatcher.
|
|
if chat_id_str in self._telegram_free_response_chats():
|
|
return False
|
|
if self._telegram_is_free_response_topic(message):
|
|
return False
|
|
if not self._telegram_require_mention():
|
|
return False
|
|
if self._is_reply_to_bot(message):
|
|
return False
|
|
if 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"
|
|
sender = event.source.user_name or user_id
|
|
return f"[{sender}|{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
|
|
chat_id_str = str(getattr(getattr(raw_message, "chat", None), "id", ""))
|
|
allowed = self._telegram_observe_allowed_chats()
|
|
if not allowed or chat_id_str not in allowed:
|
|
return event
|
|
shared_source = self._telegram_group_observe_shared_source(event.source)
|
|
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 — SlashAccessPolicy.is_admin(None) is always False. Still inject prompt.
|
|
return dataclasses.replace(event, channel_prompt=channel_prompt)
|
|
return dataclasses.replace(
|
|
event,
|
|
text=self._telegram_group_observe_attributed_text(event),
|
|
source=shared_source,
|
|
channel_prompt=channel_prompt,
|
|
)
|
|
|
|
def _media_message_type(self, msg: Message) -> MessageType:
|
|
"""Classify a Telegram media message into a MessageType."""
|
|
if msg.sticker:
|
|
return MessageType.STICKER
|
|
if msg.photo:
|
|
return MessageType.PHOTO
|
|
if msg.video:
|
|
return MessageType.VIDEO
|
|
if msg.audio:
|
|
return MessageType.AUDIO
|
|
if msg.voice:
|
|
return MessageType.VOICE
|
|
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)`` where status is ``"none"`` (no attachment),
|
|
``"oversized"`` (skipped, ``cached`` is the raw file_size), ``"failed"``
|
|
(download error, logged), ``"unreadable"`` (cache rejected it) 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
|
|
max_bytes = getattr(self, "_max_doc_bytes", 20 * 1024 * 1024)
|
|
file_size = getattr(source, "file_size", None)
|
|
try:
|
|
size = int(file_size or 0)
|
|
except (TypeError, ValueError):
|
|
size = 0
|
|
if not (0 < size <= max_bytes):
|
|
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.
|
|
|
|
Bounded by ``_max_doc_bytes`` like the addressed document path; 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; every other type is always cached.
|
|
event.text = self._append_observed_note(event.text, "[Observed Telegram attachment could not be read, not cached.]")
|
|
return
|
|
if status != "ok":
|
|
return
|
|
event.media_urls = [cached.path]
|
|
event.media_types = [cached.media_type]
|
|
if 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, cached.context_note())
|
|
logger.info("[Telegram] Cached observed group %s at %s", 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":
|
|
return
|
|
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, f"[Replied-to {cached.kind} '{cached.display_name}' saved at: {cached.path}]"
|
|
)
|
|
logger.info("[Telegram] Cached replied-to %s at %s", cached.kind, cached.path)
|
|
|
|
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 ""
|
|
if not existing:
|
|
return note
|
|
return f"{existing}\n\n{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/cache to BOTH the user and the agent.
|
|
|
|
A failed download (typically a transient CDN error) leaves event.media_urls empty;
|
|
without this the turn dispatches silently — user thinks it was delivered, agent sees
|
|
nothing. Reply asking to retry, and append an agent-visible observed note.
|
|
"""
|
|
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)
|
|
agent_note = (
|
|
f"[The user attempted to send a {kind}{named} but it could not be downloaded ({exc.__class__.__name__}); they have been asked to retry.]"
|
|
)
|
|
event.text = self._append_observed_note(event.text, agent_note)
|
|
|
|
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
|
|
try:
|
|
event = event or self._build_message_event(message, msg_type, update_id=update_id)
|
|
shared_source = self._telegram_group_observe_shared_source(event.source)
|
|
session_entry = store.get_or_create_session(shared_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)
|
|
adapter_name = getattr(self, "name", "telegram")
|
|
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:
|
|
adapter_name = getattr(self, "name", "telegram")
|
|
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.
|
|
|
|
In groups where the bot sees its own messages, getUpdates returns them as updates;
|
|
they must not count as incoming unread in the Hermes inbox.
|
|
"""
|
|
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 _should_process_message(self, message: Message, *, is_command: bool = False) -> bool:
|
|
"""Apply Telegram group trigger rules.
|
|
|
|
DMs are unrestricted. Group messages are accepted when the chat passes ``allowed_chats``
|
|
(a hard gate; only the ``guest_mode`` explicit-@mention bypass crosses it) and then any
|
|
of: ``free_response_chats``/topic, ``require_mention`` off, reply to the bot, @mention,
|
|
or a regex wake-word match. Slash commands get no special treatment under
|
|
``require_mention``; ``/cmd@botname`` and ``@botname /cmd`` count as mentions.
|
|
"""
|
|
# Learn the live handle BEFORE any mention gate routes on it (a rename would
|
|
# otherwise make the exclusive-mention gate misread messages addressed to us),
|
|
# then drop our own echoed messages so they never count as incoming unread.
|
|
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)
|
|
allowed_topics = self._telegram_allowed_topics()
|
|
if allowed_topics:
|
|
topic_id = str(thread_id) if thread_id is not None else self._GENERAL_TOPIC_THREAD_ID
|
|
if topic_id not in allowed_topics:
|
|
return False
|
|
|
|
# ignored_threads applies to both groups and DM topics
|
|
if thread_id is not None:
|
|
try:
|
|
if int(thread_id) in self._telegram_ignored_threads():
|
|
return False
|
|
except (TypeError, ValueError):
|
|
logger.warning("[%s] Ignoring non-numeric Telegram message_thread_id: %r", self.name, thread_id)
|
|
if not self._is_group_chat(message):
|
|
# Root DM (non-topic): ignore if ignore_root_dm is configured
|
|
if thread_id is None and self.config.extra.get("ignore_root_dm", False):
|
|
chat_id = str(getattr(getattr(message, "chat", None), "id", ""))
|
|
if not is_command and chat_id in self._dm_topic_chat_ids:
|
|
return False
|
|
return True
|
|
chat_id_str = str(getattr(getattr(message, "chat", None), "id", ""))
|
|
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:
|
|
return True
|
|
if chat_id_str in self._telegram_free_response_chats():
|
|
return True
|
|
if self._telegram_is_free_response_topic(message):
|
|
return True
|
|
if not self._telegram_require_mention():
|
|
return True
|
|
if 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.
|
|
|
|
Forum topics don't inherit AllGroupChats scope (Telegram resolves via
|
|
BotCommandScopeChat), so register on first message for the topic-view menu.
|
|
"""
|
|
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 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.
|
|
|
|
Channel broadcasts arrive as ``update.channel_post``, not ``update.message``; using
|
|
``effective_message`` keeps handlers from consuming them without building an event.
|
|
"""
|
|
return getattr(update, "effective_message", None) or getattr(update, "message", None)
|
|
|
|
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 path.
|
|
if not self._is_user_authorized_from_message(msg):
|
|
logger.warning(
|
|
"[Telegram] Blocked unauthorized user %s in chat %s",
|
|
getattr(getattr(msg, "from_user", None), "id", None),
|
|
getattr(getattr(msg, "chat", None), "id", None),
|
|
)
|
|
return
|
|
if not self._should_process_message(msg):
|
|
if self._should_observe_unmentioned_group_message(msg):
|
|
self._observe_unmentioned_group_message(msg, MessageType.TEXT, update_id=update.update_id)
|
|
return
|
|
await self._ensure_forum_commands(update.message)
|
|
event = self._build_message_event(msg, MessageType.TEXT, update_id=update.update_id)
|
|
event.text = self._clean_bot_trigger_text(event.text)
|
|
await self._cache_replied_media(msg, event)
|
|
event = self._apply_telegram_group_observe_attribution(event)
|
|
self._enqueue_text_event(event)
|
|
|
|
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):
|
|
logger.warning(
|
|
"[Telegram] Blocked unauthorized user %s in chat %s",
|
|
getattr(getattr(msg, "from_user", None), "id", None),
|
|
getattr(getattr(msg, "chat", None), "id", None),
|
|
)
|
|
return
|
|
await self._ensure_forum_commands(msg)
|
|
event = self._build_message_event(msg, MessageType.COMMAND, update_id=update.update_id)
|
|
event.text = self._clean_bot_trigger_text(event.text)
|
|
await self._cache_replied_media(msg, event)
|
|
event = self._apply_telegram_group_observe_attribution(event)
|
|
# A >4096-char command paste arrives as a near-limit COMMAND chunk plus TEXT
|
|
# continuations; dispatching immediately would orphan them (and interrupt the
|
|
# agent). Near-limit commands go through text batching; short ones stay immediate.
|
|
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):
|
|
logger.warning(
|
|
"[Telegram] Blocked unauthorized user %s in chat %s",
|
|
getattr(getattr(msg, "from_user", None), "id", None),
|
|
getattr(getattr(msg, "chat", None), "id", None),
|
|
)
|
|
return
|
|
if not self._should_process_message(msg):
|
|
if self._should_observe_unmentioned_group_message(msg):
|
|
self._observe_unmentioned_group_message(msg, MessageType.LOCATION, update_id=update.update_id)
|
|
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.append(f"latitude: {lat}")
|
|
parts.append(f"longitude: {lon}")
|
|
parts.append(f"Map: https://www.google.com/maps/search/?api=1&query={lat},{lon}")
|
|
parts.append("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)
|
|
event = self._apply_telegram_group_observe_attribution(event)
|
|
await self.handle_message(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, not the raw inbound thread id."""
|
|
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_text_batch(self, key: str) -> None:
|
|
"""Wait for the quiet period then dispatch the aggregated text."""
|
|
current_task = asyncio.current_task()
|
|
event = None
|
|
try:
|
|
# Adaptive delay: near-split-point last chunk → long delay (continuation almost
|
|
# certain); short/medium totals → capped fast delays; else configured cap. All
|
|
# tiers are min()'d with the operator's ``_text_batch_delay_seconds`` override.
|
|
pending = self._pending_text_batches.get(key)
|
|
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:
|
|
delay = self._text_batch_split_delay_seconds
|
|
elif total_len <= self._TEXT_BATCH_FAST_LEN:
|
|
delay = min(self._text_batch_delay_seconds, self._TEXT_BATCH_FAST_DELAY_S)
|
|
elif total_len <= self._TEXT_BATCH_SHORT_LEN:
|
|
delay = min(self._text_batch_delay_seconds, self._TEXT_BATCH_SHORT_DELAY_S)
|
|
else:
|
|
delay = self._text_batch_delay_seconds
|
|
await asyncio.sleep(delay)
|
|
event = self._pending_text_batches.pop(key, None)
|
|
if not event:
|
|
return
|
|
if self._should_drop_delayed_delivery():
|
|
self._hold_inbound_event(event, where="text-flush")
|
|
event = None
|
|
return
|
|
logger.info("[Telegram] Flushing text batch %s (%d chars)", key, len(event.text or ""))
|
|
await self.handle_message(event)
|
|
event = None
|
|
except asyncio.CancelledError:
|
|
# Cancelled after pop but before durable dispatch — hold, don't lose.
|
|
if event is not None:
|
|
self._hold_inbound_event(event, where="text-flush-cancelled")
|
|
raise
|
|
finally:
|
|
if self._pending_text_batch_tasks.get(key) is current_task:
|
|
self._pending_text_batch_tasks.pop(key, None)
|
|
|
|
# -- 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)
|
|
if media_group_id:
|
|
return f"{session_key}:album:{media_group_id}"
|
|
return 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."""
|
|
current_task = asyncio.current_task()
|
|
event = None
|
|
try:
|
|
await asyncio.sleep(self._media_batch_delay_seconds)
|
|
event = self._pending_photo_batches.pop(batch_key, None)
|
|
if not event:
|
|
return
|
|
if self._should_drop_delayed_delivery():
|
|
self._hold_inbound_event(event, where="photo-flush")
|
|
event = None
|
|
return
|
|
logger.info("[Telegram] Flushing photo batch %s with %d image(s)", batch_key, len(event.media_urls))
|
|
await self.handle_message(event)
|
|
event = None
|
|
except asyncio.CancelledError:
|
|
if event is not None:
|
|
self._hold_inbound_event(event, where="photo-flush-cancelled")
|
|
raise
|
|
finally:
|
|
if self._pending_photo_batch_tasks.get(batch_key) is current_task:
|
|
self._pending_photo_batch_tasks.pop(batch_key, None)
|
|
|
|
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
|
|
existing = self._pending_photo_batches.get(batch_key)
|
|
if existing is None:
|
|
self._pending_photo_batches[batch_key] = event
|
|
else:
|
|
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)
|
|
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)
|
|
|
|
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. Video resolves ``ext``/``mime`` from the file path.
|
|
"""
|
|
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":
|
|
if getattr(file_obj, "file_path", None):
|
|
for candidate in SUPPORTED_VIDEO_TYPES:
|
|
if file_obj.file_path.lower().endswith(candidate):
|
|
ext = candidate
|
|
break
|
|
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 _handle_media_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Handle incoming media messages, downloading images to local cache."""
|
|
if not update.message:
|
|
return
|
|
if not self._is_user_authorized_from_message(update.message):
|
|
logger.info(
|
|
"[Telegram] Blocked media from unauthorized user %s in chat %s",
|
|
getattr(getattr(update.message, "from_user", None), "id", None),
|
|
getattr(getattr(update.message, "chat", None), "id", None),
|
|
)
|
|
return
|
|
if not self._should_process_message(update.message):
|
|
if self._should_observe_unmentioned_group_message(update.message):
|
|
_m = update.message
|
|
_observe_type = self._media_message_type(_m)
|
|
_event = self._build_message_event(_m, _observe_type, update_id=update.update_id)
|
|
if _m.caption:
|
|
_event.text = self._clean_bot_trigger_text(_m.caption)
|
|
await self._cache_observed_media(_m, _event)
|
|
self._observe_unmentioned_group_message(_m, _event.message_type, update_id=update.update_id, event=_event)
|
|
return
|
|
msg = update.message
|
|
msg_type = self._media_message_type(msg)
|
|
event = self._build_message_event(msg, msg_type, update_id=update.update_id)
|
|
if msg.caption:
|
|
event.text = self._clean_bot_trigger_text(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)
|
|
event = self._apply_telegram_group_observe_attribution(event)
|
|
await self.handle_message(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:
|
|
photo = msg.photo[-1] # PhotoSize list sorted by size; largest last
|
|
file_obj = await photo.get_file()
|
|
image_bytes = await file_obj.download_as_bytearray()
|
|
ext = ".jpg"
|
|
if file_obj.file_path:
|
|
for candidate in [".png", ".webp", ".gif", ".jpeg", ".jpg"]:
|
|
if file_obj.file_path.lower().endswith(candidate):
|
|
ext = candidate
|
|
break
|
|
cached_path = await cache_image_from_bytes_async(bytes(image_bytes), ext=ext)
|
|
event.media_urls = [cached_path]
|
|
event.media_types = [f"image/{ext.lstrip('.')}"]
|
|
logger.info("[Telegram] Cached user photo at %s", cached_path)
|
|
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:
|
|
doc = msg.document
|
|
try:
|
|
ext = ""
|
|
original_filename = doc.file_name or ""
|
|
if original_filename:
|
|
_, ext = os.path.splitext(original_filename)
|
|
ext = ext.lower()
|
|
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:
|
|
mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}
|
|
ext = mime_to_ext.get(doc_mime, "")
|
|
|
|
# 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:
|
|
limit_mb = self._max_doc_bytes // (1024 * 1024)
|
|
event.text = f"The document is too large or its size could not be verified. Maximum: {limit_mb} MB."
|
|
logger.info("[Telegram] Document too large: %s bytes", doc.file_size)
|
|
await self.handle_message(event)
|
|
return
|
|
|
|
# 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)
|
|
event.text = f"Image document '{original_filename or doc_mime or ext or 'unknown'}' could not be read as an image."
|
|
await self.handle_message(event)
|
|
return
|
|
event.message_type = MessageType.PHOTO
|
|
event.media_urls = [cached_path]
|
|
event.media_types = [doc_mime if doc_mime.startswith("image/") else _TELEGRAM_IMAGE_EXT_TO_MIME.get(image_ext, "image/jpeg")]
|
|
logger.info("[Telegram] Cached user image-document at %s", cached_path)
|
|
await self._route_photo_event(msg, event)
|
|
return
|
|
if not ext and doc.mime_type:
|
|
video_mime_to_ext = {v: k for k, v in SUPPORTED_VIDEO_TYPES.items()}
|
|
ext = video_mime_to_ext.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()
|
|
cached_path = await cache_video_from_bytes_async(bytes(video_bytes), ext=ext)
|
|
event.media_urls = [cached_path]
|
|
event.media_types = [SUPPORTED_VIDEO_TYPES[ext]]
|
|
event.message_type = MessageType.VIDEO
|
|
logger.info("[Telegram] Cached user video document at %s", cached_path)
|
|
await self.handle_message(event)
|
|
return
|
|
|
|
# Any file type is accepted (authorization is the gate, not the extension).
|
|
# Image documents already returned above — an ext-in-SUPPORTED_IMAGE_DOCUMENT_TYPES
|
|
# branch here would be dead code. Unknown types get application/octet-stream.
|
|
file_obj = await doc.get_file()
|
|
doc_bytes = await file_obj.download_as_bytearray()
|
|
raw_bytes = bytes(doc_bytes)
|
|
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:
|
|
event.text = f"Document '{original_filename or doc_mime or ext or 'unknown'}' could not be cached."
|
|
await self.handle_message(event)
|
|
return
|
|
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 = original_filename or f"document{ext or '.txt'}"
|
|
display_name = re.sub(r'[^\w.\- ]', '_', display_name)
|
|
injection = f"[Content of {display_name}]:\n{text_content}"
|
|
if event.text:
|
|
event.text = f"{injection}\n\n{event.text}"
|
|
else:
|
|
event.text = 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
|
|
)
|
|
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.
|
|
|
|
Forwarding each item immediately would make the gateway treat the second image as a
|
|
new message that interrupts the first.
|
|
"""
|
|
if self._should_drop_delayed_delivery():
|
|
self._hold_inbound_event(event, where="media-group-enqueue")
|
|
return
|
|
existing = self._media_group_events.get(media_group_id)
|
|
if existing is None:
|
|
self._media_group_events[media_group_id] = event
|
|
else:
|
|
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)
|
|
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:
|
|
current_task = asyncio.current_task()
|
|
event = None
|
|
try:
|
|
await asyncio.sleep(self.MEDIA_GROUP_WAIT_SECONDS)
|
|
event = self._media_group_events.pop(media_group_id, None)
|
|
if event is None:
|
|
return
|
|
if self._should_drop_delayed_delivery():
|
|
self._hold_inbound_event(event, where="media-group-flush")
|
|
event = None
|
|
return
|
|
await self.handle_message(event)
|
|
event = None
|
|
except asyncio.CancelledError:
|
|
# Cancelled after pop but before durable dispatch — hold, don't lose.
|
|
if event is not None:
|
|
self._hold_inbound_event(event, where="media-group-flush-cancelled")
|
|
raise
|
|
finally:
|
|
if self._media_group_tasks.get(media_group_id) is current_task:
|
|
self._media_group_tasks.pop(media_group_id, None)
|
|
|
|
async def _handle_sticker(self, msg: Message, event: "MessageEvent") -> None:
|
|
"""Describe a sticker via vision, cached by file_unique_id.
|
|
|
|
Animated/video stickers can't be analyzed as static images; they 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
|
|
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 = await vision_analyze_tool(image_url=cached_path, user_prompt=STICKER_VISION_PROMPT)
|
|
result = json.loads(result_json)
|
|
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(f"a sticker with emoji {emoji}" if emoji else "a sticker", 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(f"a sticker with emoji {emoji}" if emoji else "a sticker", 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:
|
|
# Canonical loader: honors managed-scope overlay + ${VAR} expansion.
|
|
from hermes_cli.config import load_config_readonly
|
|
config = load_config_readonly()
|
|
dm_topics = config.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
|
|
# chat_id set gives O(1) root-DM ignore lookup
|
|
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:
|
|
cache_key = f"{cid}:{name}"
|
|
if cache_key not in self._dm_topics:
|
|
self._dm_topics[cache_key] = int(tid)
|
|
logger.info("[%s] Hot-loaded DM topic from config: %s -> thread_id=%s", self.name, cache_key, 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
|
|
# Cache miss — hot-reload in case topics were added externally, then retry.
|
|
self._reload_dm_topics_from_config()
|
|
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):
|
|
text = value.get("text")
|
|
if text is not None:
|
|
return cls._flatten_rich_inline_text(text)
|
|
children = value.get("children")
|
|
if children is not None:
|
|
return cls._flatten_rich_inline_text(children)
|
|
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
|
|
block_type = block.get("type")
|
|
if block_type == "list":
|
|
for item in block.get("items", []):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
item_text = cls._flatten_rich_blocks(item.get("blocks"))
|
|
if not item_text:
|
|
continue
|
|
label = item.get("label")
|
|
item_lines = item_text.splitlines()
|
|
if not item_lines:
|
|
continue
|
|
first_line = item_lines[0]
|
|
if label:
|
|
first_line = f"{label} {first_line}".strip()
|
|
lines.append(first_line)
|
|
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:
|
|
api_kwargs = getattr(reply_to_message, "api_kwargs", None)
|
|
getter = getattr(api_kwargs, "get", None)
|
|
if not callable(getter):
|
|
return None
|
|
rich_message = getter("rich_message")
|
|
rich_getter = getattr(rich_message, "get", None)
|
|
if not callable(rich_getter):
|
|
return None
|
|
text = cls._flatten_rich_blocks(rich_getter("blocks")).strip()
|
|
return text 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):
|
|
topics = chat_entry.get("topics", [])
|
|
if not isinstance(topics, list):
|
|
topics = []
|
|
for topic in topics:
|
|
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:
|
|
"""Return ``(reply_to_id, reply_to_text)`` for the replied-to message, if any.
|
|
|
|
Prefers Telegram's native partial quote so quoting one substring doesn't inject the
|
|
whole replied-to message; falls back to 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:
|
|
# Native rich-message echo first; local send-time index only as fallback.
|
|
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:
|
|
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 (otherwise it is re-delivered when PTB's shutdown ACK fails).
|
|
"""
|
|
chat = message.chat
|
|
user = message.from_user
|
|
# Normalize via str() so PTB enums (ChatType.CHANNEL) and plain-string mocks both work.
|
|
telegram_chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower()
|
|
chat_type = "dm"
|
|
if telegram_chat_type in {"group", "supergroup"}:
|
|
chat_type = "group"
|
|
elif telegram_chat_type == "channel":
|
|
chat_type = "channel"
|
|
|
|
# Shared normalizer so gating and session routing agree: reply-UI anchors are dropped
|
|
# (sends against them hit 'Message thread not found'); forum General-topic messages
|
|
# normalize to the General id so replies route back there.
|
|
thread_id_str = self._effective_message_thread_id(message)
|
|
chat_topic, topic_skill = self._resolve_topic_binding(message, chat_type, thread_id_str)
|
|
source = self.build_source(
|
|
chat_id=str(chat.id),
|
|
chat_name=chat.title or (chat.full_name if hasattr(chat, "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.full_name
|
|
if user
|
|
else (
|
|
chat.full_name
|
|
if hasattr(chat, "full_name") and chat_type == "dm"
|
|
else (chat.title if chat_type == "channel" else None)
|
|
)
|
|
),
|
|
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)
|
|
|
|
# Per-channel/topic ephemeral prompt
|
|
from gateway.platforms.base import resolve_channel_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=_channel_prompt,
|
|
timestamp=message.date,
|
|
)
|
|
|
|
# -- Message reactions (processing lifecycle) --
|
|
|
|
def _reactions_enabled(self) -> bool:
|
|
"""Reactions enabled via TELEGRAM_REACTIONS env/config."""
|
|
return os.getenv("TELEGRAM_REACTIONS", "false").lower() not in {"false", "0", "no"}
|
|
|
|
async def _set_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
|
|
"""Set a single emoji reaction on a Telegram message."""
|
|
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:
|
|
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 (``reaction=None`` is 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=None
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
logger.debug("[%s] clear reactions failed: %s", self.name, _redact_telegram_error_text(e))
|
|
return False
|
|
|
|
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.
|
|
|
|
Telegram's set_message_reaction replaces (not adds), so no remove step. CANCELLED
|
|
explicitly clears the 👀 so it doesn't linger when the cancel was the last activity.
|
|
"""
|
|
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) that replace the former
|
|
# per-platform core touchpoints in gateway/, hermes_cli/ and tools/send_message_tool.py.
|
|
|
|
|
|
def _resolve_notifications_mode() -> str:
|
|
"""Notification mode (all/important) from env, else config.yaml
|
|
display.platforms.telegram.notifications, default 'important'."""
|
|
mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "")
|
|
if not mode:
|
|
try:
|
|
from gateway.config import load_gateway_config
|
|
from gateway.run import cfg_get
|
|
_gw_cfg = load_gateway_config()
|
|
_raw = cfg_get(_gw_cfg, "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).
|
|
|
|
check_telegram_requirements() only checks the SDK is importable; without this gate the
|
|
plugin-enable pass would enable Telegram on any machine with the SDK 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 contract) so deliver=telegram cron jobs
|
|
succeed without the gateway; delegates to the REST ``_send_telegram`` sender."""
|
|
token = getattr(pconfig, "token", None)
|
|
if not token:
|
|
# Profile-scoped read: don't borrow another profile's env-bridged token under multiplex.
|
|
from agent.secret_scope import get_secret
|
|
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_mod._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.
|
|
"""
|
|
import json as _json
|
|
extras: dict = {}
|
|
# Under multiplex a secondary profile's authorization gates must NOT hit the process-global
|
|
# env (first-writer-wins would pin them for every profile); they flow via extra/secret scope.
|
|
try:
|
|
from agent.secret_scope import current_secret_scope, is_multiplex_active
|
|
_skip_env_bridge = bool(is_multiplex_active() and current_secret_scope() is not None)
|
|
except Exception:
|
|
_skip_env_bridge = False
|
|
|
|
def _set_env(env: str, value: str) -> None:
|
|
if not os.getenv(env):
|
|
os.environ[env] = value
|
|
|
|
def _bridge_lower(key: str, env: str) -> None:
|
|
if key in telegram_cfg:
|
|
_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; skipped under multiplex secret scope."""
|
|
if value is None:
|
|
return
|
|
if seed_extra:
|
|
extras.setdefault(key, value)
|
|
if isinstance(value, list):
|
|
value = ",".join(str(v) for v in value)
|
|
if not _skip_env_bridge:
|
|
_set_env(env, str(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"]))
|
|
_bridge_lower("exclusive_bot_mentions", "TELEGRAM_EXCLUSIVE_BOT_MENTIONS")
|
|
_bridge_lower("allow_bots", "TELEGRAM_ALLOW_BOTS")
|
|
_bridge_lower("guest_mode", "TELEGRAM_GUEST_MODE")
|
|
_bridge_lower("observe_unmentioned_group_messages", "TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES")
|
|
# 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.
|
|
_bridge_gate("free_response_chats", "TELEGRAM_FREE_RESPONSE_CHATS", telegram_cfg.get("free_response_chats"), seed_extra=True)
|
|
_bridge_gate("free_response_topics", "TELEGRAM_FREE_RESPONSE_TOPICS", telegram_cfg.get("free_response_topics"))
|
|
_bridge_gate("allowed_chats", "TELEGRAM_ALLOWED_CHATS", telegram_cfg.get("allowed_chats"))
|
|
_bridge_gate("allowed_topics", "TELEGRAM_ALLOWED_TOPICS", telegram_cfg.get("allowed_topics"))
|
|
_bridge_gate("ignored_threads", "TELEGRAM_IGNORED_THREADS", telegram_cfg.get("ignored_threads"), seed_extra=True)
|
|
_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 our return is
|
|
# merged via dict.update(), so re-emitting them 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,
|
|
)
|
|
|