From d975985e86cdd3d8a9f3d55c555af5487d05424e Mon Sep 17 00:00:00 2001 From: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:26:34 -0500 Subject: [PATCH 01/10] refactor(telegram): extract inbound ingest/event building into TelegramIngestMixin (adapter god-file slice) Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> --- plugins/platforms/telegram/adapter.py | 1285 +--------------- .../platforms/telegram/telegram_inbound.py | 1352 +++++++++++++++++ 2 files changed, 1362 insertions(+), 1275 deletions(-) create mode 100644 plugins/platforms/telegram/telegram_inbound.py diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index ea6258b9f2dc6..4ac952040b31e 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -25,19 +25,6 @@ logger = logging.getLogger(__name__) -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 "" - - def _scoped_gate_env(name: str, default: str = "") -> str: """Read a TELEGRAM_*/GATEWAY_* authorization gate env var per-profile. @@ -307,21 +294,13 @@ class _MockContextTypes: ) from utils import atomic_replace, env_float, env_int -_TELEGRAM_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"} -_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", -} +from plugins.platforms.telegram.telegram_inbound import ( + TelegramIngestMixin, + _TELEGRAM_IMAGE_EXTENSIONS, + _TELEGRAM_IMAGE_EXT_TO_MIME, + _TELEGRAM_IMAGE_MIME_TO_EXT, + _redact_telegram_error_text, +) def _coerce_duration_seconds(value: Any) -> Optional[int]: """Round a raw length to whole positive seconds, or None if unusable.""" @@ -614,7 +593,7 @@ class _PollingLifecycleAbort(RuntimeError): """Internal control flow for polling startup fenced by teardown.""" -class TelegramAdapter(BasePlatformAdapter): +class TelegramAdapter(TelegramIngestMixin, BasePlatformAdapter): """ Telegram bot adapter. @@ -739,34 +718,8 @@ def __init__(self, config: PlatformConfig): min_value=1.0, max_value=300.0, ) - # Buffer rapid/album photo updates so Telegram image bursts are handled - # as a single MessageEvent instead of self-interrupting multiple 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] = {} - # Buffer rapid text messages so Telegram client-side splits of long - # messages are aggregated into a single MessageEvent. Lower defaults - # (0.3s / 1.0s instead of 0.6s / 2.0s) let short replies stream - # without a noticeable wait — combined with the adaptive fast-path - # in ``_calc_text_batch_delay`` below, ≤320-codepoint replies settle - # in ~180ms. All bounds are conservative for Telegram's - # ~1 edit/s flood envelope. - self._text_batch_delay_seconds = self._env_float_clamped( - "HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS", - 0.3, - min_value=0.08, - max_value=2.0, - ) - self._text_batch_split_delay_seconds = self._env_float_clamped( - "HERMES_TELEGRAM_TEXT_BATCH_SPLIT_DELAY_SECONDS", - 1.0, - min_value=self._text_batch_delay_seconds, - max_value=4.0, - ) - self._pending_text_batches: Dict[str, MessageEvent] = {} - self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {} + # Inbound ingest batching/grouping state lives on TelegramIngestMixin. + self._init_ingest_state() self._drop_delayed_deliveries = False self._polling_error_task: Optional[asyncio.Task] = None self._polling_conflict_count: int = 0 @@ -8415,141 +8368,6 @@ def _apply_telegram_group_observe_attribution(self, event: MessageEvent) -> Mess 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 - - async def _cache_observed_media(self, msg: Message, event: MessageEvent) -> None: - """Cache an unmentioned group attachment and annotate the observed text. - - Passive group traffic, so downloads are bounded by the same - ``_max_doc_bytes`` limit as the addressed document path. Oversized or - unsupported attachments are noted in the transcript without downloading. - """ - from gateway.platforms.base import cache_media_bytes - - source, filename, mime, kind = self._observed_media_source(msg) - if source is None: - return - - 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): - limit_mb = max_bytes // (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)", file_size) - return - - 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 = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind) - except Exception as exc: - logger.warning("[Telegram] Failed to cache observed group media: %s", _redact_telegram_error_text(exc), exc_info=True) - return - - if cached is None: - # Only reachable for images that fail validation now — any other - # file type is always cached (authorization is the gate, not the - # extension). - event.text = self._append_observed_note( - event.text, "[Observed Telegram attachment could not be read, not cached.]" - ) - return - - event.media_urls = [cached.path] - event.media_types = [cached.media_type] - if cached.kind == "image": - event.message_type = MessageType.PHOTO - elif cached.kind == "video": - event.message_type = MessageType.VIDEO - elif cached.kind == "audio": - event.message_type = MessageType.AUDIO - 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.""" - from gateway.platforms.base import cache_media_bytes - - reply_msg = getattr(msg, "reply_to_message", None) - if reply_msg is None: - return - source, filename, mime, kind = self._observed_media_source(reply_msg) - if source is None: - return - - 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 - - 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 = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind) - except Exception as exc: - logger.warning("[Telegram] Failed to cache replied-to media: %s", _redact_telegram_error_text(exc), exc_info=True) - return - - if cached is None: - return - - event.media_urls.append(cached.path) - event.media_types.append(cached.media_type) - if len(event.media_urls) == 1: - if cached.kind == "image": - event.message_type = MessageType.PHOTO - elif cached.kind == "video": - event.message_type = MessageType.VIDEO - elif cached.kind == "audio": - event.message_type = MessageType.AUDIO - 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: @@ -8558,47 +8376,6 @@ def _append_observed_note(existing: Optional[str], note: str) -> str: 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 on BOTH ends instead of swallowing it. - - When download_as_bytearray()/cache_*_from_bytes() raises (typically a - transient httpx.ConnectError to Telegram's CDN), the attachment never - made it into event.media_urls. Without this, the handler falls through - and dispatches an empty turn: the user thinks the file was delivered, - the agent sees nothing, and the only record is a buried log warning. - - This (1) replies to the user in Telegram so they know to retry, and - (2) appends an agent-visible notice to event.text via the existing - observed-note channel so the agent knows an attachment was attempted - and failed — never a silent empty turn. No new event fields (the - structured-event refactor is out of scope per #23045). - """ - named = f" ({display_name})" if display_name else "" - try: - await msg.reply_text( - f"\u26a0\ufe0f Couldn't download your {kind}{named} " - f"({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 " - f"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, @@ -8634,117 +8411,6 @@ def _observe_unmentioned_group_message( 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: - """Return True when the message was sent by this bot itself. - - In some Telegram environments (groups, supergroups where the bot can - see its own messages), getUpdates returns the bot's own outgoing - messages as updates. These must be filtered out so they are not - counted as incoming unread messages 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 remain unrestricted. Group/supergroup messages are accepted when: - - the chat passes the ``allowed_chats`` whitelist (when set), or - ``guest_mode`` is enabled and the bot is explicitly mentioned - - the chat is explicitly allowlisted in ``free_response_chats`` - - ``require_mention`` is disabled - - the message replies to the bot - - the bot is @mentioned - - the text/caption matches a configured regex wake-word pattern - - When ``allowed_chats`` is non-empty, it remains a hard gate except for - the narrow ``guest_mode`` bypass: group/supergroup messages that - explicitly @mention this bot. Replies and regex wake words do not bypass - ``allowed_chats``. When ``require_mention`` is enabled, slash commands are not given - special treatment — they must pass the same mention/reply checks - as any other group message. Users can still trigger commands via - the Telegram bot menu (``/command@botname``) or by explicitly - mentioning the bot (``@botname /command``), both of which are - recognised as mentions by :meth:`_message_mentions_bot`. - """ - # Filter out the bot's own messages (returned by getUpdates in some - # environments like groups/supergroups where the bot can see its own - # messages). Without this, outbound messages are counted as incoming - # unread in the Hermes inbox (#52363). - # - # Telegram stamps our CURRENT @username on those own-messages and on - # reply_to_message, so learn the live handle here — before any mention - # gate routes on it. Otherwise a BotFather rename leaves the stale - # handle in place and the exclusive-mention gate reads a message - # addressed to us as one addressed to some other bot. - self._observe_bot_identity_from_message(message) - if self._is_own_message(message): - return False - - if not self._is_group_chat(message): - return True - - thread_id = self._effective_message_thread_id(message) - 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 - - # Check ignored_threads first — 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 guest-mode mention bypass once so _message_mentions_bot - # is not called redundantly in the normal flow below. - guest_mention = self._is_guest_mention(message) - - # allowed_chats check (whitelist). When set, group messages from chats - # outside the whitelist are ignored unless guest_mode permits this - # exact message as an explicit direct mention. DMs are excluded above. - 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 - # When guest_mode is True, _is_guest_mention already called - # _message_mentions_bot above — skip the redundant second call. - 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. @@ -8770,698 +8436,6 @@ async def _ensure_forum_commands(self, message) -> None: 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]: - """Return the message-like payload for normal messages and channel posts. - - Telegram exposes channel broadcasts as ``update.channel_post`` rather - than ``update.message``. MessageHandler filters can still dispatch - those updates, so handlers must use ``effective_message`` to avoid - consuming channel posts without ever building a gateway 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 messages. - - Telegram clients split long messages into multiple updates. Buffer - rapid successive text messages from the same user/chat and aggregate - them into a single MessageEvent before dispatching. - """ - msg = self._effective_update_message(update) - if not msg or not msg.text: - return - # Early user-level auth check: reject unauthorized users before any - # text batching, observe-buffer persistence, event building, or response - # generation. This prevents removed/blocked users from injecting prompts - # into the agent path or the observed transcript context (#40863). - 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) - # Telegram clients split messages above 4096 chars into multiple - # updates. A long command paste (e.g. ``/queue ``) - # arrives as a COMMAND chunk near the limit followed by plain TEXT - # continuation chunk(s). Dispatching the command immediately would - # orphan the continuation, which then lands as a separate message and - # interrupts the running agent. Route near-limit command chunks - # through the same text-batching pipeline so continuations merge in - # before dispatch; short commands (/stop, /approve, ...) keep the - # immediate path and are never delayed. - 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 - - # Build a text message with coordinates and context - 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 key for text message batching. - - Applies the installed topic-recovery hook first so DM-topic batches - coalesce on (and dispatch to) the recovered lane rather than the - raw inbound ``message_thread_id`` Telegram may have attached. - """ - from gateway.session import build_session_key - self._apply_topic_recovery(event) - return 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=event.source.profile, - ) - - def _enqueue_text_event(self, event: MessageEvent) -> None: - """Buffer a text event and reset the flush timer. - - When Telegram splits a long user message into multiple updates, - they arrive within a few hundred milliseconds. This method - concatenates them and waits for a short quiet period before - dispatching the combined message. - """ - if self._should_drop_delayed_delivery(): - logger.debug("[Telegram] Dropping text batch enqueue after disconnect started") - return - - key = self._text_batch_key(event) - existing = self._pending_text_batches.get(key) - chunk_len = len(event.text or "") - if existing is None: - event._last_chunk_len = chunk_len # type: ignore[attr-defined] - self._pending_text_batches[key] = event - else: - # Append text from the follow-up chunk - if event.text: - existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text - existing._last_chunk_len = chunk_len # type: ignore[attr-defined] - # Merge any media that might be attached - if event.media_urls: - existing.media_urls.extend(event.media_urls) - existing.media_types.extend(event.media_types) - - # Cancel any pending flush and restart the timer - prior_task = self._pending_text_batch_tasks.get(key) - if prior_task and not prior_task.done(): - prior_task.cancel() - self._pending_text_batch_tasks[key] = asyncio.create_task( - self._flush_text_batch(key) - ) - - async def _flush_text_batch(self, key: str) -> None: - """Wait for the quiet period then dispatch the aggregated text. - - Uses a longer delay when the latest chunk is near Telegram's 4096-char - split point, since a continuation chunk is almost certain. - """ - current_task = asyncio.current_task() - try: - # Adaptive delay tiers: - # - last chunk ≥ _SPLIT_THRESHOLD: a continuation is almost - # certain → wait the longer split delay. - # - total accumulated text ≤ _TEXT_BATCH_FAST_LEN (~320 cp): - # short message → cap delay at _TEXT_BATCH_FAST_DELAY_S - # so the agent sees the text near-instantly. - # - total ≤ _TEXT_BATCH_SHORT_LEN (~1024 cp): - # medium → cap at _TEXT_BATCH_SHORT_DELAY_S. - # - otherwise: use the configured cap. - # Tiers compose with operator overrides via the env-var-driven - # ``_text_batch_delay_seconds`` (e.g. an operator who sets the - # cap below 0.18s gets that lower number on every tier). - 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(): - logger.debug("[Telegram] Dropping text batch flush after disconnect started") - return - logger.info( - "[Telegram] Flushing text batch %s (%d chars)", - key, len(event.text or ""), - ) - await self.handle_message(event) - 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), - ) - 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() - 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(): - logger.debug("[Telegram] Dropping photo batch flush after disconnect started") - return - logger.info("[Telegram] Flushing photo batch %s with %d image(s)", batch_key, len(event.media_urls)) - await self.handle_message(event) - 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(): - logger.debug("[Telegram] Dropping photo batch enqueue after disconnect started") - 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 _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) - - # Add caption as text - if msg.caption: - event.text = self._clean_bot_trigger_text(msg.caption) - - # Handle stickers: describe via vision tool with caching - if msg.sticker: - await self._handle_sticker(msg, event) - event = self._apply_telegram_group_observe_attribution(event) - await self.handle_message(event) - return - - # Apply observe attribution after caption is set; sticker is handled above - # because _handle_sticker overwrites event.text with its vision description. - event = self._apply_telegram_group_observe_attribution(event) - - # Download photo to local image cache so the vision tool can access it - # even after Telegram's ephemeral file URLs expire (~1 hour). - if msg.photo: - try: - # msg.photo is a list of PhotoSize sorted by size; take the largest - photo = msg.photo[-1] - file_obj = await photo.get_file() - # Download the image bytes directly into memory - image_bytes = await file_obj.download_as_bytearray() - # Determine extension from the file path if available - 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 - # Save to local cache (for vision tool access) - cached_path = cache_image_from_bytes(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) - 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: - batch_key = self._photo_batch_key(event, msg) - self._enqueue_photo_event(batch_key, 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) - - # Download voice/audio messages to cache for STT transcription - if msg.voice: - try: - allowed, note = self._telegram_media_size_allowed(msg.voice, "voice message") - if not allowed: - event.text = self._append_observed_note(event.text, note or "") - logger.info("[Telegram] Skipped oversized user voice (size=%s)", getattr(msg.voice, "file_size", None)) - await self.handle_message(event) - return - file_obj = await msg.voice.get_file() - audio_bytes = await file_obj.download_as_bytearray() - cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".ogg") - event.media_urls = [cached_path] - event.media_types = ["audio/ogg"] - logger.info("[Telegram] Cached user voice at %s", cached_path) - except Exception as e: - logger.warning("[Telegram] Failed to cache voice: %s", _redact_telegram_error_text(e), exc_info=True) - await self._surface_media_cache_failure(msg, event, "voice message", e) - elif msg.audio: - try: - allowed, note = self._telegram_media_size_allowed(msg.audio, "audio file") - if not allowed: - event.text = self._append_observed_note(event.text, note or "") - logger.info("[Telegram] Skipped oversized user audio (size=%s)", getattr(msg.audio, "file_size", None)) - await self.handle_message(event) - return - file_obj = await msg.audio.get_file() - audio_bytes = await file_obj.download_as_bytearray() - cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".mp3") - event.media_urls = [cached_path] - event.media_types = ["audio/mp3"] - logger.info("[Telegram] Cached user audio at %s", cached_path) - except Exception as e: - logger.warning("[Telegram] Failed to cache audio: %s", _redact_telegram_error_text(e), exc_info=True) - await self._surface_media_cache_failure(msg, event, "audio file", e) - - elif msg.video: - try: - allowed, note = self._telegram_media_size_allowed(msg.video, "video file") - if not allowed: - event.text = self._append_observed_note(event.text, note or "") - logger.info("[Telegram] Skipped oversized user video (size=%s)", getattr(msg.video, "file_size", None)) - await self.handle_message(event) - return - file_obj = await msg.video.get_file() - video_bytes = await file_obj.download_as_bytearray() - ext = ".mp4" - 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 = cache_video_from_bytes(bytes(video_bytes), ext=ext) - event.media_urls = [cached_path] - event.media_types = [SUPPORTED_VIDEO_TYPES.get(ext, "video/mp4")] - logger.info("[Telegram] Cached user video at %s", cached_path) - except Exception as e: - logger.warning("[Telegram] Failed to cache video: %s", _redact_telegram_error_text(e), exc_info=True) - await self._surface_media_cache_failure(msg, event, "video file", e) - - # Download document files to cache for agent processing - elif msg.document: - doc = msg.document - try: - # Determine file extension - ext = "" - original_filename = doc.file_name or "" - if original_filename: - _, ext = os.path.splitext(original_filename) - ext = ext.lower() - - # Normalize mime_type for robust comparisons (some clients send - # uppercase like "IMAGE/PNG"). - doc_mime = (doc.mime_type or "").lower() - - # If no extension from filename, reverse-lookup from MIME type - 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, "") - - # Check file size early so image documents cannot bypass the - # document size limit by taking the image path. - if not doc.file_size or doc.file_size > self._max_doc_bytes: - limit_mb = self._max_doc_bytes // (1024 * 1024) - event.text = ( - "The document is too large or its size could not be verified. " - f"Maximum: {limit_mb} MB." - ) - logger.info("[Telegram] Document too large: %s bytes", doc.file_size) - await self.handle_message(event) - return - - # Telegram may deliver screenshots/photos as documents. If the - # payload is actually an image, route it through the image cache - # and batching path instead of rejecting it as a document. - 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 = cache_image_from_bytes(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) - - 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: - batch_key = self._photo_batch_key(event, msg) - self._enqueue_photo_event(batch_key, 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: - # SUPPORTED_IMAGE_DOCUMENT_TYPES has duplicate values (.jpg + .jpeg - # both map to image/jpeg); keep the first ext we encounter. - 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 = cache_video_from_bytes(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 - - # NOTE: image-document handling is performed earlier in this - # function (ext in _TELEGRAM_IMAGE_EXTENSIONS or image/* mime), - # which returns before reaching here. Any subsequent - # ext-in-SUPPORTED_IMAGE_DOCUMENT_TYPES branch would be dead - # code — the extension sets are identical. - - # Download and cache. Any file type is accepted — authorization - # to message the agent is the gate, not the file extension. - # Known types keep their precise MIME; unknown types are tagged - # application/octet-stream so the agent reaches for terminal tools. - 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 - - cached = cache_media_bytes( - 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, - ) - - # For text-readable files, inject content into event.text (capped - # at 100 KB). Gate on a text-like extension/MIME — NOT a blind - # UTF-8 decode, since binary formats (PDF/zip/docx) can have - # decodable ASCII headers. Binary files are surfaced as a cached - # path only (run.py emits a path-pointing context note). - 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: - # Binary file — agent has the cached path and can use - # terminal/read_file against it. No inline injection. - pass - - 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: - """Buffer Telegram media-group items so albums arrive as one logical event. - - Telegram delivers albums as multiple updates with a shared media_group_id. - If we forward each item immediately, the gateway thinks the second image is a - new user message and interrupts the first. We debounce briefly and merge the - attachments into a single MessageEvent. - """ - if self._should_drop_delayed_delivery(): - logger.debug("[Telegram] Dropping media group enqueue after disconnect started") - 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() - try: - await asyncio.sleep(self.MEDIA_GROUP_WAIT_SECONDS) - event = self._media_group_events.pop(media_group_id, None) - if event is not None: - if self._should_drop_delayed_delivery(): - logger.debug("[Telegram] Dropping media group flush after disconnect started") - return - await self.handle_message(event) - except asyncio.CancelledError: - return - 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 Telegram sticker via vision analysis, with caching. - - For static stickers (WEBP), we download, analyze with vision, and cache - the description by file_unique_id. For animated/video stickers, we inject - a placeholder noting the emoji. - """ - 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 "" - - # Animated and video stickers can't be analyzed as static images - if sticker.is_animated or sticker.is_video: - event.text = build_animated_sticker_injection(emoji) - return - - # Check the cache first - 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 - - # Cache miss -- download and analyze - try: - file_obj = await sticker.get_file() - image_bytes = await file_obj.download_as_bytearray() - cached_path = cache_image_from_bytes(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: - # Vision failed -- use emoji as fallback - 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 and load any new thread_ids into cache. @@ -9559,245 +8533,6 @@ def _cache_dm_topic_from_message(self, chat_id: str, thread_id: str, topic_name: 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 _build_message_event( - self, - message: Message, - msg_type: MessageType, - update_id: Optional[int] = None, - ) -> MessageEvent: - """Build a MessageEvent from a Telegram message. - - ``update_id`` is the ``Update.update_id`` from PTB; passing it through - lets ``/restart`` record the triggering offset so the new gateway - process can advance past it (prevents ``/restart`` being re-delivered - when PTB's graceful-shutdown ACK fails). - """ - chat = message.chat - user = message.from_user - - # Determine chat type. Normalize through ``str`` so tests/mocks and - # python-telegram-bot enum values both work (``ChatType.CHANNEL`` is - # string-like, but mocks often provide plain strings). - 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" - - # Resolve routable thread id for DM topics and forum group topics via - # the shared normalizer, so gating and session routing agree on one - # value. Only real topic/forum messages keep a thread id; ordinary - # reply-UI anchors are dropped (they are not durable session threads - # and sends against them hit 'Message thread not found', #3206), while - # forum General-topic messages (message_thread_id=None) normalize to - # the General-topic id so replies route back to General (#22423). - thread_id_str = self._effective_message_thread_id(message) - chat_topic = 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") - - # Also check forum_topic_created service message for topic discovery - 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: - # Group/supergroup forum topic skill binding via config.extra['group_topics']. - # Accept both supported shapes: - # [{"chat_id": "-100...", "topics": [...]}] - # and legacy/operator-edited mapping shape: - # {"-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 - - # Build source - 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, - ) - - # Extract reply context if this message is a reply. - # Prefer Telegram's native partial quote (message.quote, TextQuote) - # so a user replying to a single selected substring of a prior - # multi-section message doesn't get the whole replied-to message - # injected into the agent's context — which can cause the agent - # to act on unrelated actionable-looking text the user didn't - # quote (#22619). Fall back to the full replied-to message text - # / caption when no native quote is present. - reply_to_id = None - reply_to_text = None - if message.reply_to_message: - 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: - reply_to_text = quote_text - else: - reply_to_text = ( - message.reply_to_message.text - or message.reply_to_message.caption - or None - ) - if not reply_to_text: - # Prefer Telegram's native rich-message echo when present; - # keep the local send-time index only as a fallback for - # older/unrecoverable reply payloads. - 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(chat.id), reply_to_id - ) - except Exception: - reply_to_text = None - - # 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: diff --git a/plugins/platforms/telegram/telegram_inbound.py b/plugins/platforms/telegram/telegram_inbound.py new file mode 100644 index 0000000000000..5696283e4cba6 --- /dev/null +++ b/plugins/platforms/telegram/telegram_inbound.py @@ -0,0 +1,1352 @@ +"""Inbound ingest mixin for the Telegram adapter (adapter god-file slice). + +Extracted from ``plugins/platforms/telegram/adapter.py``: inbound message +handlers, text/photo/media-group batching, observed-media caching, and rich +reply flattening for ``MessageEvent`` building. ``TelegramAdapter`` imports +``TelegramIngestMixin`` back and inherits from it (the mixin pattern proven +by the gateway authorization/topic mixins); moved module-level helpers +(``_redact_telegram_error_text``, image extension tables) are re-exported +into ``adapter`` so existing name resolution and tests stay green. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from gateway.platforms.base import ( + MessageEvent, + MessageType, + SUPPORTED_DOCUMENT_TYPES, + SUPPORTED_IMAGE_DOCUMENT_TYPES, + SUPPORTED_VIDEO_TYPES, + _TEXT_INJECT_EXTENSIONS, + cache_audio_from_bytes, + cache_image_from_bytes, + cache_video_from_bytes, +) +from utils import env_float + +if TYPE_CHECKING: + from telegram import Message, Update + from telegram.ext import ContextTypes + +# Keep log records under the adapter's logger name so operator log filters +# and caplog assertions keyed on the adapter keep working after the slice. +logger = logging.getLogger("plugins.platforms.telegram.adapter") + +_TELEGRAM_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"} +_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 _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 "" + + +class TelegramIngestMixin: + """Inbound ingest/event-building methods for TelegramAdapter.""" + + def _init_ingest_state(self) -> None: + """Initialize inbound-ingest batching/grouping state. + + Extracted verbatim from ``TelegramAdapter.__init__`` so the ingest + mixin owns its batch/group state fields. + """ + # Buffer rapid/album photo updates so Telegram image bursts are handled + # as a single MessageEvent instead of self-interrupting multiple 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] = {} + # Buffer rapid text messages so Telegram client-side splits of long + # messages are aggregated into a single MessageEvent. Lower defaults + # (0.3s / 1.0s instead of 0.6s / 2.0s) let short replies stream + # without a noticeable wait — combined with the adaptive fast-path + # in ``_calc_text_batch_delay`` below, ≤320-codepoint replies settle + # in ~180ms. All bounds are conservative for Telegram's + # ~1 edit/s flood envelope. + self._text_batch_delay_seconds = self._env_float_clamped( + "HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS", + 0.3, + min_value=0.08, + max_value=2.0, + ) + self._text_batch_split_delay_seconds = self._env_float_clamped( + "HERMES_TELEGRAM_TEXT_BATCH_SPLIT_DELAY_SECONDS", + 1.0, + min_value=self._text_batch_delay_seconds, + max_value=4.0, + ) + self._pending_text_batches: Dict[str, MessageEvent] = {} + self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {} + + + + 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 + + + async def _cache_observed_media(self, msg: Message, event: MessageEvent) -> None: + """Cache an unmentioned group attachment and annotate the observed text. + + Passive group traffic, so downloads are bounded by the same + ``_max_doc_bytes`` limit as the addressed document path. Oversized or + unsupported attachments are noted in the transcript without downloading. + """ + from gateway.platforms.base import cache_media_bytes + + source, filename, mime, kind = self._observed_media_source(msg) + if source is None: + return + + 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): + limit_mb = max_bytes // (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)", file_size) + return + + 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 = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind) + except Exception as exc: + logger.warning("[Telegram] Failed to cache observed group media: %s", _redact_telegram_error_text(exc), exc_info=True) + return + + if cached is None: + # Only reachable for images that fail validation now — any other + # file type is always cached (authorization is the gate, not the + # extension). + event.text = self._append_observed_note( + event.text, "[Observed Telegram attachment could not be read, not cached.]" + ) + return + + event.media_urls = [cached.path] + event.media_types = [cached.media_type] + if cached.kind == "image": + event.message_type = MessageType.PHOTO + elif cached.kind == "video": + event.message_type = MessageType.VIDEO + elif cached.kind == "audio": + event.message_type = MessageType.AUDIO + 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.""" + from gateway.platforms.base import cache_media_bytes + + reply_msg = getattr(msg, "reply_to_message", None) + if reply_msg is None: + return + source, filename, mime, kind = self._observed_media_source(reply_msg) + if source is None: + return + + 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 + + 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 = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind) + except Exception as exc: + logger.warning("[Telegram] Failed to cache replied-to media: %s", _redact_telegram_error_text(exc), exc_info=True) + return + + if cached is None: + return + + event.media_urls.append(cached.path) + event.media_types.append(cached.media_type) + if len(event.media_urls) == 1: + if cached.kind == "image": + event.message_type = MessageType.PHOTO + elif cached.kind == "video": + event.message_type = MessageType.VIDEO + elif cached.kind == "audio": + event.message_type = MessageType.AUDIO + 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 + + + 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 on BOTH ends instead of swallowing it. + + When download_as_bytearray()/cache_*_from_bytes() raises (typically a + transient httpx.ConnectError to Telegram's CDN), the attachment never + made it into event.media_urls. Without this, the handler falls through + and dispatches an empty turn: the user thinks the file was delivered, + the agent sees nothing, and the only record is a buried log warning. + + This (1) replies to the user in Telegram so they know to retry, and + (2) appends an agent-visible notice to event.text via the existing + observed-note channel so the agent knows an attachment was attempted + and failed — never a silent empty turn. No new event fields (the + structured-event refactor is out of scope per #23045). + """ + named = f" ({display_name})" if display_name else "" + try: + await msg.reply_text( + f"\u26a0\ufe0f Couldn't download your {kind}{named} " + f"({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 " + f"downloaded ({exc.__class__.__name__}); they have been asked to retry.]" + ) + event.text = self._append_observed_note(event.text, agent_note) + + + def _is_own_message(self, message: Message) -> bool: + """Return True when the message was sent by this bot itself. + + In some Telegram environments (groups, supergroups where the bot can + see its own messages), getUpdates returns the bot's own outgoing + messages as updates. These must be filtered out so they are not + counted as incoming unread messages 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 remain unrestricted. Group/supergroup messages are accepted when: + - the chat passes the ``allowed_chats`` whitelist (when set), or + ``guest_mode`` is enabled and the bot is explicitly mentioned + - the chat is explicitly allowlisted in ``free_response_chats`` + - ``require_mention`` is disabled + - the message replies to the bot + - the bot is @mentioned + - the text/caption matches a configured regex wake-word pattern + + When ``allowed_chats`` is non-empty, it remains a hard gate except for + the narrow ``guest_mode`` bypass: group/supergroup messages that + explicitly @mention this bot. Replies and regex wake words do not bypass + ``allowed_chats``. When ``require_mention`` is enabled, slash commands are not given + special treatment — they must pass the same mention/reply checks + as any other group message. Users can still trigger commands via + the Telegram bot menu (``/command@botname``) or by explicitly + mentioning the bot (``@botname /command``), both of which are + recognised as mentions by :meth:`_message_mentions_bot`. + """ + # Filter out the bot's own messages (returned by getUpdates in some + # environments like groups/supergroups where the bot can see its own + # messages). Without this, outbound messages are counted as incoming + # unread in the Hermes inbox (#52363). + # + # Telegram stamps our CURRENT @username on those own-messages and on + # reply_to_message, so learn the live handle here — before any mention + # gate routes on it. Otherwise a BotFather rename leaves the stale + # handle in place and the exclusive-mention gate reads a message + # addressed to us as one addressed to some other bot. + self._observe_bot_identity_from_message(message) + if self._is_own_message(message): + return False + + if not self._is_group_chat(message): + return True + + thread_id = self._effective_message_thread_id(message) + 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 + + # Check ignored_threads first — 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 guest-mode mention bypass once so _message_mentions_bot + # is not called redundantly in the normal flow below. + guest_mention = self._is_guest_mention(message) + + # allowed_chats check (whitelist). When set, group messages from chats + # outside the whitelist are ignored unless guest_mode permits this + # exact message as an explicit direct mention. DMs are excluded above. + 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 + # When guest_mode is True, _is_guest_mention already called + # _message_mentions_bot above — skip the redundant second call. + if not self._telegram_guest_mode() and self._message_mentions_bot(message): + return True + return self._message_matches_mention_patterns(message) + + + def _effective_update_message(self, update: Update) -> Optional[Message]: + """Return the message-like payload for normal messages and channel posts. + + Telegram exposes channel broadcasts as ``update.channel_post`` rather + than ``update.message``. MessageHandler filters can still dispatch + those updates, so handlers must use ``effective_message`` to avoid + consuming channel posts without ever building a gateway 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 messages. + + Telegram clients split long messages into multiple updates. Buffer + rapid successive text messages from the same user/chat and aggregate + them into a single MessageEvent before dispatching. + """ + msg = self._effective_update_message(update) + if not msg or not msg.text: + return + # Early user-level auth check: reject unauthorized users before any + # text batching, observe-buffer persistence, event building, or response + # generation. This prevents removed/blocked users from injecting prompts + # into the agent path or the observed transcript context (#40863). + 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) + # Telegram clients split messages above 4096 chars into multiple + # updates. A long command paste (e.g. ``/queue ``) + # arrives as a COMMAND chunk near the limit followed by plain TEXT + # continuation chunk(s). Dispatching the command immediately would + # orphan the continuation, which then lands as a separate message and + # interrupts the running agent. Route near-limit command chunks + # through the same text-batching pipeline so continuations merge in + # before dispatch; short commands (/stop, /approve, ...) keep the + # immediate path and are never delayed. + 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 + + # Build a text message with coordinates and context + 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 key for text message batching. + + Applies the installed topic-recovery hook first so DM-topic batches + coalesce on (and dispatch to) the recovered lane rather than the + raw inbound ``message_thread_id`` Telegram may have attached. + """ + from gateway.session import build_session_key + self._apply_topic_recovery(event) + return 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=event.source.profile, + ) + + + def _enqueue_text_event(self, event: MessageEvent) -> None: + """Buffer a text event and reset the flush timer. + + When Telegram splits a long user message into multiple updates, + they arrive within a few hundred milliseconds. This method + concatenates them and waits for a short quiet period before + dispatching the combined message. + """ + if self._should_drop_delayed_delivery(): + logger.debug("[Telegram] Dropping text batch enqueue after disconnect started") + return + + key = self._text_batch_key(event) + existing = self._pending_text_batches.get(key) + chunk_len = len(event.text or "") + if existing is None: + event._last_chunk_len = chunk_len # type: ignore[attr-defined] + self._pending_text_batches[key] = event + else: + # Append text from the follow-up chunk + if event.text: + existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text + existing._last_chunk_len = chunk_len # type: ignore[attr-defined] + # Merge any media that might be attached + if event.media_urls: + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + + # Cancel any pending flush and restart the timer + prior_task = self._pending_text_batch_tasks.get(key) + if prior_task and not prior_task.done(): + prior_task.cancel() + self._pending_text_batch_tasks[key] = asyncio.create_task( + self._flush_text_batch(key) + ) + + + async def _flush_text_batch(self, key: str) -> None: + """Wait for the quiet period then dispatch the aggregated text. + + Uses a longer delay when the latest chunk is near Telegram's 4096-char + split point, since a continuation chunk is almost certain. + """ + current_task = asyncio.current_task() + try: + # Adaptive delay tiers: + # - last chunk ≥ _SPLIT_THRESHOLD: a continuation is almost + # certain → wait the longer split delay. + # - total accumulated text ≤ _TEXT_BATCH_FAST_LEN (~320 cp): + # short message → cap delay at _TEXT_BATCH_FAST_DELAY_S + # so the agent sees the text near-instantly. + # - total ≤ _TEXT_BATCH_SHORT_LEN (~1024 cp): + # medium → cap at _TEXT_BATCH_SHORT_DELAY_S. + # - otherwise: use the configured cap. + # Tiers compose with operator overrides via the env-var-driven + # ``_text_batch_delay_seconds`` (e.g. an operator who sets the + # cap below 0.18s gets that lower number on every tier). + 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(): + logger.debug("[Telegram] Dropping text batch flush after disconnect started") + return + logger.info( + "[Telegram] Flushing text batch %s (%d chars)", + key, len(event.text or ""), + ) + await self.handle_message(event) + 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), + ) + 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() + 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(): + logger.debug("[Telegram] Dropping photo batch flush after disconnect started") + return + logger.info("[Telegram] Flushing photo batch %s with %d image(s)", batch_key, len(event.media_urls)) + await self.handle_message(event) + 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(): + logger.debug("[Telegram] Dropping photo batch enqueue after disconnect started") + 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 _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) + + # Add caption as text + if msg.caption: + event.text = self._clean_bot_trigger_text(msg.caption) + + # Handle stickers: describe via vision tool with caching + if msg.sticker: + await self._handle_sticker(msg, event) + event = self._apply_telegram_group_observe_attribution(event) + await self.handle_message(event) + return + + # Apply observe attribution after caption is set; sticker is handled above + # because _handle_sticker overwrites event.text with its vision description. + event = self._apply_telegram_group_observe_attribution(event) + + # Download photo to local image cache so the vision tool can access it + # even after Telegram's ephemeral file URLs expire (~1 hour). + if msg.photo: + try: + # msg.photo is a list of PhotoSize sorted by size; take the largest + photo = msg.photo[-1] + file_obj = await photo.get_file() + # Download the image bytes directly into memory + image_bytes = await file_obj.download_as_bytearray() + # Determine extension from the file path if available + 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 + # Save to local cache (for vision tool access) + cached_path = cache_image_from_bytes(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) + 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: + batch_key = self._photo_batch_key(event, msg) + self._enqueue_photo_event(batch_key, 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) + + # Download voice/audio messages to cache for STT transcription + if msg.voice: + try: + allowed, note = self._telegram_media_size_allowed(msg.voice, "voice message") + if not allowed: + event.text = self._append_observed_note(event.text, note or "") + logger.info("[Telegram] Skipped oversized user voice (size=%s)", getattr(msg.voice, "file_size", None)) + await self.handle_message(event) + return + file_obj = await msg.voice.get_file() + audio_bytes = await file_obj.download_as_bytearray() + cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".ogg") + event.media_urls = [cached_path] + event.media_types = ["audio/ogg"] + logger.info("[Telegram] Cached user voice at %s", cached_path) + except Exception as e: + logger.warning("[Telegram] Failed to cache voice: %s", _redact_telegram_error_text(e), exc_info=True) + await self._surface_media_cache_failure(msg, event, "voice message", e) + elif msg.audio: + try: + allowed, note = self._telegram_media_size_allowed(msg.audio, "audio file") + if not allowed: + event.text = self._append_observed_note(event.text, note or "") + logger.info("[Telegram] Skipped oversized user audio (size=%s)", getattr(msg.audio, "file_size", None)) + await self.handle_message(event) + return + file_obj = await msg.audio.get_file() + audio_bytes = await file_obj.download_as_bytearray() + cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".mp3") + event.media_urls = [cached_path] + event.media_types = ["audio/mp3"] + logger.info("[Telegram] Cached user audio at %s", cached_path) + except Exception as e: + logger.warning("[Telegram] Failed to cache audio: %s", _redact_telegram_error_text(e), exc_info=True) + await self._surface_media_cache_failure(msg, event, "audio file", e) + + elif msg.video: + try: + allowed, note = self._telegram_media_size_allowed(msg.video, "video file") + if not allowed: + event.text = self._append_observed_note(event.text, note or "") + logger.info("[Telegram] Skipped oversized user video (size=%s)", getattr(msg.video, "file_size", None)) + await self.handle_message(event) + return + file_obj = await msg.video.get_file() + video_bytes = await file_obj.download_as_bytearray() + ext = ".mp4" + 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 = cache_video_from_bytes(bytes(video_bytes), ext=ext) + event.media_urls = [cached_path] + event.media_types = [SUPPORTED_VIDEO_TYPES.get(ext, "video/mp4")] + logger.info("[Telegram] Cached user video at %s", cached_path) + except Exception as e: + logger.warning("[Telegram] Failed to cache video: %s", _redact_telegram_error_text(e), exc_info=True) + await self._surface_media_cache_failure(msg, event, "video file", e) + + # Download document files to cache for agent processing + elif msg.document: + doc = msg.document + try: + # Determine file extension + ext = "" + original_filename = doc.file_name or "" + if original_filename: + _, ext = os.path.splitext(original_filename) + ext = ext.lower() + + # Normalize mime_type for robust comparisons (some clients send + # uppercase like "IMAGE/PNG"). + doc_mime = (doc.mime_type or "").lower() + + # If no extension from filename, reverse-lookup from MIME type + 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, "") + + # Check file size early so image documents cannot bypass the + # document size limit by taking the image path. + if not doc.file_size or doc.file_size > self._max_doc_bytes: + limit_mb = self._max_doc_bytes // (1024 * 1024) + event.text = ( + "The document is too large or its size could not be verified. " + f"Maximum: {limit_mb} MB." + ) + logger.info("[Telegram] Document too large: %s bytes", doc.file_size) + await self.handle_message(event) + return + + # Telegram may deliver screenshots/photos as documents. If the + # payload is actually an image, route it through the image cache + # and batching path instead of rejecting it as a document. + 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 = cache_image_from_bytes(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) + + 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: + batch_key = self._photo_batch_key(event, msg) + self._enqueue_photo_event(batch_key, 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: + # SUPPORTED_IMAGE_DOCUMENT_TYPES has duplicate values (.jpg + .jpeg + # both map to image/jpeg); keep the first ext we encounter. + 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 = cache_video_from_bytes(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 + + # NOTE: image-document handling is performed earlier in this + # function (ext in _TELEGRAM_IMAGE_EXTENSIONS or image/* mime), + # which returns before reaching here. Any subsequent + # ext-in-SUPPORTED_IMAGE_DOCUMENT_TYPES branch would be dead + # code — the extension sets are identical. + + # Download and cache. Any file type is accepted — authorization + # to message the agent is the gate, not the file extension. + # Known types keep their precise MIME; unknown types are tagged + # application/octet-stream so the agent reaches for terminal tools. + 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 + + cached = cache_media_bytes( + 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, + ) + + # For text-readable files, inject content into event.text (capped + # at 100 KB). Gate on a text-like extension/MIME — NOT a blind + # UTF-8 decode, since binary formats (PDF/zip/docx) can have + # decodable ASCII headers. Binary files are surfaced as a cached + # path only (run.py emits a path-pointing context note). + 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: + # Binary file — agent has the cached path and can use + # terminal/read_file against it. No inline injection. + pass + + 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: + """Buffer Telegram media-group items so albums arrive as one logical event. + + Telegram delivers albums as multiple updates with a shared media_group_id. + If we forward each item immediately, the gateway thinks the second image is a + new user message and interrupts the first. We debounce briefly and merge the + attachments into a single MessageEvent. + """ + if self._should_drop_delayed_delivery(): + logger.debug("[Telegram] Dropping media group enqueue after disconnect started") + 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() + try: + await asyncio.sleep(self.MEDIA_GROUP_WAIT_SECONDS) + event = self._media_group_events.pop(media_group_id, None) + if event is not None: + if self._should_drop_delayed_delivery(): + logger.debug("[Telegram] Dropping media group flush after disconnect started") + return + await self.handle_message(event) + except asyncio.CancelledError: + return + 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 Telegram sticker via vision analysis, with caching. + + For static stickers (WEBP), we download, analyze with vision, and cache + the description by file_unique_id. For animated/video stickers, we inject + a placeholder noting the emoji. + """ + 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 "" + + # Animated and video stickers can't be analyzed as static images + if sticker.is_animated or sticker.is_video: + event.text = build_animated_sticker_injection(emoji) + return + + # Check the cache first + 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 + + # Cache miss -- download and analyze + try: + file_obj = await sticker.get_file() + image_bytes = await file_obj.download_as_bytearray() + cached_path = cache_image_from_bytes(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: + # Vision failed -- use emoji as fallback + 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, + ) + + + @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 _build_message_event( + self, + message: Message, + msg_type: MessageType, + update_id: Optional[int] = None, + ) -> MessageEvent: + """Build a MessageEvent from a Telegram message. + + ``update_id`` is the ``Update.update_id`` from PTB; passing it through + lets ``/restart`` record the triggering offset so the new gateway + process can advance past it (prevents ``/restart`` being re-delivered + when PTB's graceful-shutdown ACK fails). + """ + chat = message.chat + user = message.from_user + + # Determine chat type. Normalize through ``str`` so tests/mocks and + # python-telegram-bot enum values both work (``ChatType.CHANNEL`` is + # string-like, but mocks often provide plain strings). + 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" + + # Resolve routable thread id for DM topics and forum group topics via + # the shared normalizer, so gating and session routing agree on one + # value. Only real topic/forum messages keep a thread id; ordinary + # reply-UI anchors are dropped (they are not durable session threads + # and sends against them hit 'Message thread not found', #3206), while + # forum General-topic messages (message_thread_id=None) normalize to + # the General-topic id so replies route back to General (#22423). + thread_id_str = self._effective_message_thread_id(message) + chat_topic = 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") + + # Also check forum_topic_created service message for topic discovery + 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: + # Group/supergroup forum topic skill binding via config.extra['group_topics']. + # Accept both supported shapes: + # [{"chat_id": "-100...", "topics": [...]}] + # and legacy/operator-edited mapping shape: + # {"-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 + + # Build source + 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, + ) + + # Extract reply context if this message is a reply. + # Prefer Telegram's native partial quote (message.quote, TextQuote) + # so a user replying to a single selected substring of a prior + # multi-section message doesn't get the whole replied-to message + # injected into the agent's context — which can cause the agent + # to act on unrelated actionable-looking text the user didn't + # quote (#22619). Fall back to the full replied-to message text + # / caption when no native quote is present. + reply_to_id = None + reply_to_text = None + if message.reply_to_message: + 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: + reply_to_text = quote_text + else: + reply_to_text = ( + message.reply_to_message.text + or message.reply_to_message.caption + or None + ) + if not reply_to_text: + # Prefer Telegram's native rich-message echo when present; + # keep the local send-time index only as a fallback for + # older/unrecoverable reply payloads. + 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(chat.id), reply_to_id + ) + except Exception: + reply_to_text = None + + # 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, + ) From cd4c3650ad14da1889489730700e8ed16c012ee1 Mon Sep 17 00:00:00 2001 From: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:12:05 -0500 Subject: [PATCH 02/10] refactor(telegram): extract outbound text delivery into TelegramTextDeliveryMixin (adapter god-file slice) Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> # Conflicts: # plugins/platforms/telegram/adapter.py --- plugins/platforms/telegram/adapter.py | 1004 +--------------- .../platforms/telegram/telegram_messaging.py | 1043 +++++++++++++++++ 2 files changed, 1047 insertions(+), 1000 deletions(-) create mode 100644 plugins/platforms/telegram/telegram_messaging.py diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 4ac952040b31e..0b65168b6f816 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -292,6 +292,9 @@ class _MockContextTypes: discover_fallback_ips, parse_fallback_ip_env, ) +from plugins.platforms.telegram.telegram_messaging import ( + TelegramTextDeliveryMixin, +) from utils import atomic_replace, env_float, env_int from plugins.platforms.telegram.telegram_inbound import ( @@ -593,7 +596,7 @@ class _PollingLifecycleAbort(RuntimeError): """Internal control flow for polling startup fenced by teardown.""" -class TelegramAdapter(TelegramIngestMixin, BasePlatformAdapter): +class TelegramAdapter(TelegramIngestMixin, TelegramTextDeliveryMixin, BasePlatformAdapter): """ Telegram bot adapter. @@ -861,21 +864,6 @@ def _should_drop_delayed_delivery(self) -> bool: """ return bool(getattr(self, "_drop_delayed_deliveries", False)) - def _notification_kwargs( - self, metadata: Optional[Dict[str, Any]] - ) -> Dict[str, Any]: - """Return disable_notification kwargs when the adapter is in silent mode. - - In "important" mode, all message sends are silently delivered - (disable_notification=True) unless the caller explicitly requests a - notification by setting ``metadata["notify"] = True``. - """ - 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, @@ -1516,13 +1504,6 @@ def _coerce_float_extra( 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) # @@ -4362,983 +4343,6 @@ async def disconnect(self) -> 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: - """Determine if this message chunk should thread to the original message. - - Args: - reply_to: The original message ID to reply to - chunk_index: Index of this chunk (0 = first chunk) - - Returns: - True if this chunk should be threaded to the original message - """ - 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: - return SendResult(success=False, error="Not connected") - - # 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: send the raw agent markdown via - # sendRichMessage so tables/task lists/etc. render natively. Falls - # through to the legacy MarkdownV2 path 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: - if rich_result.success: - # Re-trigger typing like the legacy success path does, - # but ONLY for intermediate sends. On the final reply - # (metadata["notify"]) the gateway has already torn down - # the typing refresh loop; re-arming Telegram's ~5s timer - # here would leave the "...typing" bubble lingering after - # the answer (no Bot API call cancels it). See #48678. - if 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 - - # Format and split message if needed - 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 and fall back to plain text. - 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 - 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) - # reply_to_mode="off" on the existing telegram_dm_topic_reply_fallback path - # is an explicit user opt-in to "message_thread_id alone is enough" (PR #23994 - # / commit 21a15b671). Honor it — don't fail loud just because the anchor was - # suppressed by config. The new fail-loud contract only applies when the caller - # didn't ask for the anchor to be dropped. - 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, i) - reply_to_id = int(reply_to_source) if should_thread and reply_to_source else None - 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 - try: - msg = await self._bot.send_message( - chat_id=normalize_telegram_chat_id(chat_id), - text=chunk, - parse_mode=ParseMode.MARKDOWN_V2, - reply_to_message_id=reply_to_id, - **thread_kwargs, - **self._link_preview_kwargs(), - **self._notification_kwargs(metadata), - ) - except Exception as md_error: - # Markdown parsing failed, try plain text - 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) - plain_chunk = _strip_mdv2(chunk) - msg = await self._bot.send_message( - chat_id=normalize_telegram_chat_id(chat_id), - text=plain_chunk, - parse_mode=None, - reply_to_message_id=reply_to_id, - **thread_kwargs, - **self._link_preview_kwargs(), - **self._notification_kwargs(metadata), - ) - else: - raise - break # success - except _NetErr as send_err: - # BadRequest is a subclass of NetworkError in - # python-telegram-bot but represents permanent errors - # (not transient network issues). Detect and 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 has been observed to return a - # one-off "thread not found" that recovers on - # an immediate retry (transient flake — see - # test_send_retries_transient_thread_not_found_before_fallback). - # Try the same thread_id once without sleeping - # 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: the thread is genuinely gone. - # Retry without ``message_thread_id`` so the - # message still reaches the chat, and prune - # the stale binding so future inbound - # messages aren't redirected back to it - # (#31501). - 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, - ) - 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, - ) - # Original message was 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 is also a subclass of NetworkError. A - # generic timeout may have reached Telegram, so don't - # retry; a wrapped ConnectTimeout means no connection - # was established, so retrying is safe. A pool timeout - # (httpx pool exhausted) is explicitly "not sent to - # Telegram" -- retrying through the loop is safe and - # prevents silent drops when the pool frees up. - 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(): - if _send_attempt < 2: - wait = float(retry_after) if retry_after is not None else 1.0 - safe_send_error = _redact_telegram_error_text(send_err) - 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 indicator after sending a message. - # Telegram clears the typing state when a new message is delivered, - # so without this the "...typing" bubble disappears mid-response - # (especially noticeable when the agent sends intermediate progress - # messages like "Checking:" before running tools). - # Skip this on the FINAL reply (metadata["notify"]): the gateway has - # already cancelled the typing refresh loop by the time the final - # send returns, so re-arming Telegram's ~5s timer here would leave - # the indicator lingering after the answer with nothing to cancel - # it (Telegram exposes no stop-typing API). See #48678. - 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) - # Message too long — content exceeded 4096 chars. Return failure so - # 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 as non-retryable so _send_with_retry() doesn't re-send. - # Exceptions: a wrapped ConnectTimeout (no connection established) - # and an httpx pool timeout (request explicitly not sent) -- both - # are safe to re-send and must not be silently dropped. - _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. - - Issue #30045: progress/status callbacks (context-pressure, lifecycle, - compression, etc.) used to append a fresh bubble on every call. With - this method, the first call sends and the message id is remembered; - subsequent calls with the same (chat_id, status_key) edit that same - message in place. If the edit fails (message deleted, too old, etc.) - we drop the cached id and send fresh. - """ - 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 - # Edit failed — clear the cached id and fall through to a fresh send. - 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_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 single-message text at 4096 UTF-16 codeunits. Streaming - replies that grow past this limit must NOT be silently truncated and - must NOT return failure (the consumer would re-send and create a - duplicate). Instead this method split-and-delivers: edit the - existing message with the first chunk and send the rest as - continuation messages, returning the final chunk's id so subsequent - edits target the most recent visible message. - """ - if not self._bot: - return SendResult(success=False, error="Not connected") - - # Rich finalize (Bot API 10.1): when the completed content has - # constructs the legacy MarkdownV2 edit degrades (tables → bullet - # lists, task lists,
, block math) and rich is available, - # edit the preview IN PLACE via editMessageText's rich_message param. - # No fresh send + delete → no duplicate preview (the problem #46206 - # reverted the fresh-final path for). Attempted before the 4,096 - # overflow pre-flight because the rich text cap is 32,768 — a rich - # table that exceeds the MarkdownV2 limit must not be split into legacy - # chunks. Falls back to the legacy edit path (overflow split included) - # on capability/permanent rejection. - if finalize and self._rich_eligible(content): - rich_result = await self._try_edit_rich( - chat_id, message_id, content, metadata=metadata, - ) - if rich_result is not None: - return rich_result - - # Pre-flight: if content already exceeds the limit, split-and-deliver - # without round-tripping a doomed edit. During streaming - # (finalize=False) we truncate instead of splitting — splitting creates - # continuation messages whose IDs become the new edit target, and on - # the next token chunk the full accumulated text is re-edited into the - # continuation, triggering another split → infinite duplication loop - # (#48648). The full content is delivered when finalize=True. - _preview_key = (str(chat_id), str(message_id)) - _saturated_preview = False - if finalize: - # Any saturation state for this message is finished with — 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 it is a visual no-op that - # still burns flood budget (Telegram counts the request and answers - # "message is not modified"). ~1 edit/0.8s for the rest of a long - # stream trips flood control (200s+ penalties) and hangs the final - # delivery. Skip silently until finalize. - 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 (segment break / new message - # id) — 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._bot.edit_message_text( - chat_id=normalize_telegram_chat_id(chat_id), - message_id=int(message_id), - text=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._bot.edit_message_text( - chat_id=normalize_telegram_chat_id(chat_id), - message_id=int(message_id), - text=formatted, - parse_mode=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) - # Fallback: strip MarkdownV2 escapes and retry as clean plain text - 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._bot.edit_message_text( - chat_id=normalize_telegram_chat_id(chat_id), - message_id=int(message_id), - text=_plain, - ) - return SendResult(success=True, message_id=message_id) - except Exception as e: - err_str = str(e).lower() - # "Message is not modified" — content identical, treat as success - if "not modified" in err_str: - return SendResult(success=True, message_id=message_id) - # Reactive split-and-deliver: parse_mode formatting can inflate - # the payload past the limit even when the raw text was under - # (e.g. MarkdownV2 escapes). Same fix as the pre-flight path. - 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 (#48648). - 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._bot.edit_message_text( - chat_id=normalize_telegram_chat_id(chat_id), - message_id=int(message_id), - text=truncated, - ) - self._last_overflow_preview[_preview_key] = truncated - return SendResult(success=True, message_id=message_id) - # Flood control / RetryAfter — short waits are retried inline, - # long waits return a failure immediately so streaming can fall back - # to a normal final send instead of leaving a truncated 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 > 5.0: - return SendResult( - success=False, - error=f"flood_control:{wait}", - retry_after=float(wait), - ) - await asyncio.sleep(wait) - try: - await self._bot.edit_message_text( - chat_id=normalize_telegram_chat_id(chat_id), - message_id=int(message_id), - text=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 (ConnectError, timeouts, server - # disconnects) should not permanently disable progress-message - # editing. Mark the result retryable so the caller knows it - # can keep trying on the 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: - """Return a one-message preview for oversized streaming edits. - - Streaming edits must keep targeting the original message. Splitting a - mid-stream preview creates continuation messages and moves the active - message id, so the next accumulated-token edit repeats the overflow - cycle (#48648). Final edits still use ``_edit_overflow_split`` to - deliver the complete response. - """ - return self.truncate_message( - content, - self.MAX_MESSAGE_LENGTH, - len_fn=utf16_len, - )[0] - - async def _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 the original ``message_id`` with chunk 1 (with the platform's - usual ``(1/N)`` suffix preserved), then send the remaining chunks as - new messages threaded as replies to the previous chunk so the user - sees them grouped. Returns ``SendResult(success=True, - message_id=, continuation_message_ids=(...))`` so the - stream consumer can keep editing the most recent visible message - and the gateway has full visibility into every message id we put on - screen. - - Falls back to ``SendResult(success=False)`` only if even the first- - chunk edit fails — that's 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: shouldn't happen given the caller's pre-flight, but - # if truncate_message returned a single chunk just edit normally. - chunks = [content] - - # Step 1 — edit the existing message with the first chunk. - first_chunk = chunks[0] - try: - if finalize: - # Use format_message + parse_mode for the final chunk; - # mirror edit_message's main happy-path. - formatted = _separate_chunk_indicator_from_fence( - self.format_message(first_chunk) - ) - try: - await self._bot.edit_message_text( - chat_id=normalize_telegram_chat_id(chat_id), - message_id=int(message_id), - text=formatted, - parse_mode=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._bot.edit_message_text( - chat_id=normalize_telegram_chat_id(chat_id), - message_id=int(message_id), - text=_strip_mdv2(first_chunk), - ) - else: - await self._bot.edit_message_text( - chat_id=normalize_telegram_chat_id(chat_id), - message_id=int(message_id), - text=first_chunk, - ) - except Exception as e: - err_str = str(e).lower() - if "not modified" in err_str: - # First chunk identical to current text — fall through to - # 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 each remaining chunk as a continuation message, - # threaded as a reply to the previous so the user sees them as a - # contiguous block. We call self._bot.send_message directly so the - # continuation skips ``self.send``'s own pre-chunking pass (chunks - # are already correctly sized). Best-effort MarkdownV2 with plain - # fallback, mirroring 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: - # Plain attempt: on finalize the MarkdownV2 attempt - # failed, so degrade to clean stripped text, never - # the raw chunk (raw ** / ``` markers 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 try again. Private DM - # topic fallback needs the anchor and topic id together; - # forum topics can still safely keep message_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: - # try plain text on next loop iteration - continue - 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: - # Continuation failed — the user has chunk 1 + however many - # continuations succeeded, but NOT the full response. 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 Telegram topic clipped after the last - # delivered chunk. - 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 previously sent Telegram message. - - Used by the stream consumer's fresh-final cleanup path (ported - from openclaw/openclaw#72038) to remove long-lived preview - messages after sending the completed reply as a fresh message. - Telegram's Bot API ``deleteMessage`` works for bot-posted - messages in the last 48 hours. Failures are non-fatal — the - caller leaves the preview in place and logs at debug level. - """ - if not self._bot: - return False - try: - await self._bot.delete_message( - chat_id=normalize_telegram_chat_id(chat_id), - message_id=int(message_id), - ) - return True - except Exception as e: - logger.debug( - "[%s] Failed to delete Telegram message %s: %s", - self.name, message_id, _redact_telegram_error_text(e), - ) - return False - - def supports_draft_streaming( - self, - chat_type: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> bool: - """Telegram supports sendMessageDraft for private chats only. - - Bot API 9.5 (March 2026) opened ``sendMessageDraft`` to all bots - unconditionally for private (DM) chats. Groups, supergroups, and - channels still rely on the edit-based path. - - We additionally require ``self._bot`` to expose ``send_message_draft`` - (added to python-telegram-bot in 22.6); older PTB installs gracefully - fall back to the edit path even on DMs. - """ - 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) with the raw markdown when - rich messages are enabled and supported, otherwise the plain-text - ``sendMessageDraft``. The Bot API animates the preview when the same - ``draft_id`` is reused across consecutive calls in the same chat. When - the response finishes, the caller sends the final text via the normal - ``send`` path; the draft preview clears naturally on the client - (Telegram has no Bot API to "promote" a draft to a real message — the - final ``sendMessage``/``sendRichMessage`` is what the user receives in - their history). - """ - if not self._bot: - return SendResult(success=False, error="not_connected") - - # Rich draft fast-path (Bot API 10.1 sendRichMessageDraft): render the - # streaming preview with the same raw markdown the final - # sendRichMessage will persist, so the animated draft matches the final - # message. Any failure degrades to the legacy plain-text draft below. - if self._should_attempt_rich_draft(content): - if 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") - - # Trim to the same UTF-16 budget the platform enforces on regular - # sends. Drafts have the same length contract as messages. - text = content if len(content) <= self.MAX_MESSAGE_LENGTH else \ - self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)[0] - - thread_id = self._metadata_thread_id(metadata) - - # Apply the same MarkdownV2 conversion the regular ``send`` path uses - # so the animated draft preview renders with identical formatting to - # the final message. Without this, the draft streams as raw text and - # the final ``sendMessage`` (which DOES use MarkdownV2) snaps into - # formatted output, producing a jarring visual shift at the end of the - # response. We try MarkdownV2 first and fall back to plain text if a - # malformed escape would be rejected — mirroring the (True, False) - # retry the streaming send loop uses — so a single bad token never - # kills draft streaming for the whole response. - for use_markdown in (True, False): - kwargs: Dict[str, Any] = { - "chat_id": normalize_telegram_chat_id(chat_id), - "draft_id": int(draft_id), - "text": self.format_message(text) if use_markdown else text, - } - if use_markdown: - kwargs["parse_mode"] = ParseMode.MARKDOWN_V2 - if thread_id is not None: - kwargs["message_thread_id"] = thread_id - - try: - ok = await self._bot.send_message_draft(**kwargs) - if ok: - # Drafts have no message_id; we report success without one - # so the caller knows the animation frame landed. - return SendResult(success=True, message_id=None) - return SendResult(success=False, error="draft_rejected") - except Exception as e: - # A MarkdownV2 parse failure (BadRequest "can't parse entities") - # is recoverable: retry once as plain text. Any other failure - # (chat doesn't allow drafts, transient hiccup) — or a failure - # on the plain-text attempt — propagates to the caller, which - # treats it as "fall back to edit-based 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 Telegram message, retrying once without message_thread_id - if Telegram returns 'Message thread not found'. - - Used for control-style sends (approval prompts, model picker, - update prompts) that can carry a stale thread_id from a DM - reply chain. The streaming send loop has its own equivalent - (PR #3390) at the body of ``send``; this helper applies the - same retry pattern to the non-streaming control paths. - """ - if not self._bot: - raise RuntimeError("Not connected") - - message_thread_id = kwargs.get("message_thread_id") - try: - return await self._bot.send_message(**kwargs) - except Exception as send_err: - if ( - message_thread_id is not None - and self._is_bad_request_error(send_err) - and self._is_thread_not_found_error(send_err) - ): - logger.warning( - "[%s] Thread %s not found for control message, retrying without message_thread_id", - self.name, - message_thread_id, - ) - # Same prune as the streaming send path — the - # control-message retry tells us the topic is gone, - # so the binding row in state.db must go too - # (#31501). - 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_update_prompt( self, chat_id: str, prompt: str, default: str = "", session_key: str = "", diff --git a/plugins/platforms/telegram/telegram_messaging.py b/plugins/platforms/telegram/telegram_messaging.py new file mode 100644 index 0000000000000..342d4a89104c0 --- /dev/null +++ b/plugins/platforms/telegram/telegram_messaging.py @@ -0,0 +1,1043 @@ +"""Outbound text delivery for the Telegram adapter. + +The outbound text-delivery cluster (send / edit / delete / draft and their +helpers) extracted from ``plugins/platforms/telegram/adapter.py`` as part of +the adapter god-file decomposition (campaign lane: telegram adapter sharding). +``TelegramTextDeliveryMixin`` is mixed into ``TelegramAdapter`` first in the +MRO. Adapter-local helpers referenced by these methods (``_strip_mdv2``, +``_separate_chunk_indicator_from_fence``, ``_redact_telegram_error_text``, and +the runtime-rebound ``ParseMode`` / ``LinkPreviewOptions`` module globals) +stay in the adapter and are imported lazily inside the methods so this module +never imports the adapter at module level (no import cycle). +""" + +from __future__ import annotations + +import asyncio +import logging +import re +from typing import Any, Dict, Optional + +from gateway.platforms.base import SendResult, classify_send_error, utf16_len +from plugins.platforms.telegram.telegram_ids import normalize_telegram_chat_id + +# Same logger object as the adapter module: log records keep identical +# provenance (name = plugins.platforms.telegram.adapter). +logger = logging.getLogger("plugins.platforms.telegram.adapter") + + +class TelegramTextDeliveryMixin: + """Outbound text delivery: send, edit, delete, draft streaming. + + ``TelegramAdapter`` inherits this mixin first, so these methods keep + shadowing the ``BasePlatformAdapter`` defaults exactly as the original + adapter-local definitions did. Instance state these methods use + (``_last_overflow_preview``, ``_disable_link_previews``, + ``_notifications_mode``, ``_reply_to_mode``) is initialized in the + adapter's ``__init__``. + """ + + def _notification_kwargs( + self, metadata: Optional[Dict[str, Any]] + ) -> Dict[str, Any]: + """Return disable_notification kwargs when the adapter is in silent mode. + + In "important" mode, all message sends are silently delivered + (disable_notification=True) unless the caller explicitly requests a + notification by setting ``metadata["notify"] = True``. + """ + if getattr(self, "_notifications_mode", "important") != "important": + return {} + if (metadata or {}).get("notify"): + return {} + return {"disable_notification": True} + + def _link_preview_kwargs(self) -> Dict[str, Any]: + from plugins.platforms.telegram.adapter import LinkPreviewOptions + 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} + + def _should_thread_reply(self, reply_to: Optional[str], chunk_index: int) -> bool: + """Determine if this message chunk should thread to the original message. + + Args: + reply_to: The original message ID to reply to + chunk_index: Index of this chunk (0 = first chunk) + + Returns: + True if this chunk should be threaded to the original message + """ + 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.""" + from plugins.platforms.telegram.adapter import ParseMode, _redact_telegram_error_text, _separate_chunk_indicator_from_fence, _strip_mdv2 + if not self._bot: + return SendResult(success=False, error="Not connected") + + # 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: send the raw agent markdown via + # sendRichMessage so tables/task lists/etc. render natively. Falls + # through to the legacy MarkdownV2 path 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: + if rich_result.success: + # Re-trigger typing like the legacy success path does, + # but ONLY for intermediate sends. On the final reply + # (metadata["notify"]) the gateway has already torn down + # the typing refresh loop; re-arming Telegram's ~5s timer + # here would leave the "...typing" bubble lingering after + # the answer (no Bot API call cancels it). See #48678. + if 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 + + # Format and split message if needed + 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 and fall back to plain text. + 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 + 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) + # reply_to_mode="off" on the existing telegram_dm_topic_reply_fallback path + # is an explicit user opt-in to "message_thread_id alone is enough" (PR #23994 + # / commit 21a15b671). Honor it — don't fail loud just because the anchor was + # suppressed by config. The new fail-loud contract only applies when the caller + # didn't ask for the anchor to be dropped. + 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, i) + reply_to_id = int(reply_to_source) if should_thread and reply_to_source else None + 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 + try: + msg = await self._bot.send_message( + chat_id=normalize_telegram_chat_id(chat_id), + text=chunk, + parse_mode=ParseMode.MARKDOWN_V2, + reply_to_message_id=reply_to_id, + **thread_kwargs, + **self._link_preview_kwargs(), + **self._notification_kwargs(metadata), + ) + except Exception as md_error: + # Markdown parsing failed, try plain text + 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) + plain_chunk = _strip_mdv2(chunk) + msg = await self._bot.send_message( + chat_id=normalize_telegram_chat_id(chat_id), + text=plain_chunk, + parse_mode=None, + reply_to_message_id=reply_to_id, + **thread_kwargs, + **self._link_preview_kwargs(), + **self._notification_kwargs(metadata), + ) + else: + raise + break # success + except _NetErr as send_err: + # BadRequest is a subclass of NetworkError in + # python-telegram-bot but represents permanent errors + # (not transient network issues). Detect and 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 has been observed to return a + # one-off "thread not found" that recovers on + # an immediate retry (transient flake — see + # test_send_retries_transient_thread_not_found_before_fallback). + # Try the same thread_id once without sleeping + # 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: the thread is genuinely gone. + # Retry without ``message_thread_id`` so the + # message still reaches the chat, and prune + # the stale binding so future inbound + # messages aren't redirected back to it + # (#31501). + 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, + ) + 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, + ) + # Original message was 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 is also a subclass of NetworkError. A + # generic timeout may have reached Telegram, so don't + # retry; a wrapped ConnectTimeout means no connection + # was established, so retrying is safe. A pool timeout + # (httpx pool exhausted) is explicitly "not sent to + # Telegram" -- retrying through the loop is safe and + # prevents silent drops when the pool frees up. + 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(): + if _send_attempt < 2: + wait = float(retry_after) if retry_after is not None else 1.0 + safe_send_error = _redact_telegram_error_text(send_err) + 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 indicator after sending a message. + # Telegram clears the typing state when a new message is delivered, + # so without this the "...typing" bubble disappears mid-response + # (especially noticeable when the agent sends intermediate progress + # messages like "Checking:" before running tools). + # Skip this on the FINAL reply (metadata["notify"]): the gateway has + # already cancelled the typing refresh loop by the time the final + # send returns, so re-arming Telegram's ~5s timer here would leave + # the indicator lingering after the answer with nothing to cancel + # it (Telegram exposes no stop-typing API). See #48678. + 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) + # Message too long — content exceeded 4096 chars. Return failure so + # 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 as non-retryable so _send_with_retry() doesn't re-send. + # Exceptions: a wrapped ConnectTimeout (no connection established) + # and an httpx pool timeout (request explicitly not sent) -- both + # are safe to re-send and must not be silently dropped. + _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. + + Issue #30045: progress/status callbacks (context-pressure, lifecycle, + compression, etc.) used to append a fresh bubble on every call. With + this method, the first call sends and the message id is remembered; + subsequent calls with the same (chat_id, status_key) edit that same + message in place. If the edit fails (message deleted, too old, etc.) + we drop the cached id and send fresh. + """ + 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 + # Edit failed — clear the cached id and fall through to a fresh send. + 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_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 single-message text at 4096 UTF-16 codeunits. Streaming + replies that grow past this limit must NOT be silently truncated and + must NOT return failure (the consumer would re-send and create a + duplicate). Instead this method split-and-delivers: edit the + existing message with the first chunk and send the rest as + continuation messages, returning the final chunk's id so subsequent + edits target the most recent visible message. + """ + from plugins.platforms.telegram.adapter import ParseMode, _redact_telegram_error_text, _strip_mdv2 + if not self._bot: + return SendResult(success=False, error="Not connected") + + # Rich finalize (Bot API 10.1): when the completed content has + # constructs the legacy MarkdownV2 edit degrades (tables → bullet + # lists, task lists,
, block math) and rich is available, + # edit the preview IN PLACE via editMessageText's rich_message param. + # No fresh send + delete → no duplicate preview (the problem #46206 + # reverted the fresh-final path for). Attempted before the 4,096 + # overflow pre-flight because the rich text cap is 32,768 — a rich + # table that exceeds the MarkdownV2 limit must not be split into legacy + # chunks. Falls back to the legacy edit path (overflow split included) + # on capability/permanent rejection. + if finalize and self._rich_eligible(content): + rich_result = await self._try_edit_rich( + chat_id, message_id, content, metadata=metadata, + ) + if rich_result is not None: + return rich_result + + # Pre-flight: if content already exceeds the limit, split-and-deliver + # without round-tripping a doomed edit. During streaming + # (finalize=False) we truncate instead of splitting — splitting creates + # continuation messages whose IDs become the new edit target, and on + # the next token chunk the full accumulated text is re-edited into the + # continuation, triggering another split → infinite duplication loop + # (#48648). The full content is delivered when finalize=True. + _preview_key = (str(chat_id), str(message_id)) + _saturated_preview = False + if finalize: + # Any saturation state for this message is finished with — 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 it is a visual no-op that + # still burns flood budget (Telegram counts the request and answers + # "message is not modified"). ~1 edit/0.8s for the rest of a long + # stream trips flood control (200s+ penalties) and hangs the final + # delivery. Skip silently until finalize. + 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 (segment break / new message + # id) — 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._bot.edit_message_text( + chat_id=normalize_telegram_chat_id(chat_id), + message_id=int(message_id), + text=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._bot.edit_message_text( + chat_id=normalize_telegram_chat_id(chat_id), + message_id=int(message_id), + text=formatted, + parse_mode=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) + # Fallback: strip MarkdownV2 escapes and retry as clean plain text + 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._bot.edit_message_text( + chat_id=normalize_telegram_chat_id(chat_id), + message_id=int(message_id), + text=_plain, + ) + return SendResult(success=True, message_id=message_id) + except Exception as e: + err_str = str(e).lower() + # "Message is not modified" — content identical, treat as success + if "not modified" in err_str: + return SendResult(success=True, message_id=message_id) + # Reactive split-and-deliver: parse_mode formatting can inflate + # the payload past the limit even when the raw text was under + # (e.g. MarkdownV2 escapes). Same fix as the pre-flight path. + 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 (#48648). + 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._bot.edit_message_text( + chat_id=normalize_telegram_chat_id(chat_id), + message_id=int(message_id), + text=truncated, + ) + self._last_overflow_preview[_preview_key] = truncated + return SendResult(success=True, message_id=message_id) + # Flood control / RetryAfter — short waits are retried inline, + # long waits return a failure immediately so streaming can fall back + # to a normal final send instead of leaving a truncated 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 > 5.0: + return SendResult( + success=False, + error=f"flood_control:{wait}", + retry_after=float(wait), + ) + await asyncio.sleep(wait) + try: + await self._bot.edit_message_text( + chat_id=normalize_telegram_chat_id(chat_id), + message_id=int(message_id), + text=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 (ConnectError, timeouts, server + # disconnects) should not permanently disable progress-message + # editing. Mark the result retryable so the caller knows it + # can keep trying on the 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: + """Return a one-message preview for oversized streaming edits. + + Streaming edits must keep targeting the original message. Splitting a + mid-stream preview creates continuation messages and moves the active + message id, so the next accumulated-token edit repeats the overflow + cycle (#48648). Final edits still use ``_edit_overflow_split`` to + deliver the complete response. + """ + return self.truncate_message( + content, + self.MAX_MESSAGE_LENGTH, + len_fn=utf16_len, + )[0] + + async def _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 the original ``message_id`` with chunk 1 (with the platform's + usual ``(1/N)`` suffix preserved), then send the remaining chunks as + new messages threaded as replies to the previous chunk so the user + sees them grouped. Returns ``SendResult(success=True, + message_id=, continuation_message_ids=(...))`` so the + stream consumer can keep editing the most recent visible message + and the gateway has full visibility into every message id we put on + screen. + + Falls back to ``SendResult(success=False)`` only if even the first- + chunk edit fails — that's a real adapter problem, not an overflow. + """ + from plugins.platforms.telegram.adapter import ParseMode, _redact_telegram_error_text, _separate_chunk_indicator_from_fence, _strip_mdv2 + chunks = self.truncate_message( + content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len, + ) + if len(chunks) <= 1: + # Defensive: shouldn't happen given the caller's pre-flight, but + # if truncate_message returned a single chunk just edit normally. + chunks = [content] + + # Step 1 — edit the existing message with the first chunk. + first_chunk = chunks[0] + try: + if finalize: + # Use format_message + parse_mode for the final chunk; + # mirror edit_message's main happy-path. + formatted = _separate_chunk_indicator_from_fence( + self.format_message(first_chunk) + ) + try: + await self._bot.edit_message_text( + chat_id=normalize_telegram_chat_id(chat_id), + message_id=int(message_id), + text=formatted, + parse_mode=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._bot.edit_message_text( + chat_id=normalize_telegram_chat_id(chat_id), + message_id=int(message_id), + text=_strip_mdv2(first_chunk), + ) + else: + await self._bot.edit_message_text( + chat_id=normalize_telegram_chat_id(chat_id), + message_id=int(message_id), + text=first_chunk, + ) + except Exception as e: + err_str = str(e).lower() + if "not modified" in err_str: + # First chunk identical to current text — fall through to + # 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 each remaining chunk as a continuation message, + # threaded as a reply to the previous so the user sees them as a + # contiguous block. We call self._bot.send_message directly so the + # continuation skips ``self.send``'s own pre-chunking pass (chunks + # are already correctly sized). Best-effort MarkdownV2 with plain + # fallback, mirroring 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: + # Plain attempt: on finalize the MarkdownV2 attempt + # failed, so degrade to clean stripped text, never + # the raw chunk (raw ** / ``` markers 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 try again. Private DM + # topic fallback needs the anchor and topic id together; + # forum topics can still safely keep message_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: + # try plain text on next loop iteration + continue + 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: + # Continuation failed — the user has chunk 1 + however many + # continuations succeeded, but NOT the full response. 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 Telegram topic clipped after the last + # delivered chunk. + 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 previously sent Telegram message. + + Used by the stream consumer's fresh-final cleanup path (ported + from openclaw/openclaw#72038) to remove long-lived preview + messages after sending the completed reply as a fresh message. + Telegram's Bot API ``deleteMessage`` works for bot-posted + messages in the last 48 hours. Failures are non-fatal — the + caller leaves the preview in place and logs at debug level. + """ + from plugins.platforms.telegram.adapter import _redact_telegram_error_text + 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 (March 2026) opened ``sendMessageDraft`` to all bots + unconditionally for private (DM) chats. Groups, supergroups, and + channels still rely on the edit-based path. + + We additionally require ``self._bot`` to expose ``send_message_draft`` + (added to python-telegram-bot in 22.6); older PTB installs gracefully + fall back to the edit path even on DMs. + """ + 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) with the raw markdown when + rich messages are enabled and supported, otherwise the plain-text + ``sendMessageDraft``. The Bot API animates the preview when the same + ``draft_id`` is reused across consecutive calls in the same chat. When + the response finishes, the caller sends the final text via the normal + ``send`` path; the draft preview clears naturally on the client + (Telegram has no Bot API to "promote" a draft to a real message — the + final ``sendMessage``/``sendRichMessage`` is what the user receives in + their history). + """ + from plugins.platforms.telegram.adapter import ParseMode, _redact_telegram_error_text + if not self._bot: + return SendResult(success=False, error="not_connected") + + # Rich draft fast-path (Bot API 10.1 sendRichMessageDraft): render the + # streaming preview with the same raw markdown the final + # sendRichMessage will persist, so the animated draft matches the final + # message. Any failure degrades to the legacy plain-text draft below. + if self._should_attempt_rich_draft(content): + if 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") + + # Trim to the same UTF-16 budget the platform enforces on regular + # sends. Drafts have the same length contract as messages. + text = content if len(content) <= self.MAX_MESSAGE_LENGTH else \ + self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)[0] + + thread_id = self._metadata_thread_id(metadata) + + # Apply the same MarkdownV2 conversion the regular ``send`` path uses + # so the animated draft preview renders with identical formatting to + # the final message. Without this, the draft streams as raw text and + # the final ``sendMessage`` (which DOES use MarkdownV2) snaps into + # formatted output, producing a jarring visual shift at the end of the + # response. We try MarkdownV2 first and fall back to plain text if a + # malformed escape would be rejected — mirroring the (True, False) + # retry the streaming send loop uses — so a single bad token never + # kills draft streaming for the whole response. + for use_markdown in (True, False): + kwargs: Dict[str, Any] = { + "chat_id": normalize_telegram_chat_id(chat_id), + "draft_id": int(draft_id), + "text": self.format_message(text) if use_markdown else text, + } + if use_markdown: + kwargs["parse_mode"] = ParseMode.MARKDOWN_V2 + if thread_id is not None: + kwargs["message_thread_id"] = thread_id + + try: + ok = await self._bot.send_message_draft(**kwargs) + if ok: + # Drafts have no message_id; we report success without one + # so the caller knows the animation frame landed. + return SendResult(success=True, message_id=None) + return SendResult(success=False, error="draft_rejected") + except Exception as e: + # A MarkdownV2 parse failure (BadRequest "can't parse entities") + # is recoverable: retry once as plain text. Any other failure + # (chat doesn't allow drafts, transient hiccup) — or a failure + # on the plain-text attempt — propagates to the caller, which + # treats it as "fall back to edit-based 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 Telegram message, retrying once without message_thread_id + if Telegram returns 'Message thread not found'. + + Used for control-style sends (approval prompts, model picker, + update prompts) that can carry a stale thread_id from a DM + reply chain. The streaming send loop has its own equivalent + (PR #3390) at the body of ``send``; this helper applies the + same retry pattern to the non-streaming control paths. + """ + if not self._bot: + raise RuntimeError("Not connected") + + message_thread_id = kwargs.get("message_thread_id") + try: + return await self._bot.send_message(**kwargs) + except Exception as send_err: + if ( + message_thread_id is not None + and self._is_bad_request_error(send_err) + and self._is_thread_not_found_error(send_err) + ): + logger.warning( + "[%s] Thread %s not found for control message, retrying without message_thread_id", + self.name, + message_thread_id, + ) + # Same prune as the streaming send path — the + # control-message retry tells us the topic is gone, + # so the binding row in state.db must go too + # (#31501). + 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 From 7756b0cd7db987df63d7b7bbe6ca64d1fec49b37 Mon Sep 17 00:00:00 2001 From: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:18:14 -0500 Subject: [PATCH 03/10] refactor(telegram): extract rich message delivery into TelegramRichMixin (adapter god-file slice) Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> # Conflicts: # plugins/platforms/telegram/adapter.py --- plugins/platforms/telegram/adapter.py | 574 +----------------- plugins/platforms/telegram/telegram_rich.py | 635 ++++++++++++++++++++ 2 files changed, 637 insertions(+), 572 deletions(-) create mode 100644 plugins/platforms/telegram/telegram_rich.py diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 0b65168b6f816..bd828a9316502 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -10,7 +10,6 @@ import asyncio import dataclasses import faulthandler -import inspect import json import logging import os @@ -295,6 +294,7 @@ class _MockContextTypes: from plugins.platforms.telegram.telegram_messaging import ( TelegramTextDeliveryMixin, ) +from plugins.platforms.telegram.telegram_rich import TelegramRichMixin from utils import atomic_replace, env_float, env_int from plugins.platforms.telegram.telegram_inbound import ( @@ -488,61 +488,11 @@ def _separate_chunk_indicator_from_fence(text: str) -> str: # the full conversion (detection + rendering); Telegram just calls it. 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 -# --------------------------------------------------------------------------- - -# Matches a protected region whose internal newlines must stay bare in the -# rich-message path: a fenced code block (```...```) OR a GFM pipe-table block -# (a header row, a delimiter row of dashes/pipes, then any pipe data rows). -# Telegram renders both natively, so injecting Markdown hard breaks inside them -# would corrupt the code block / table. -_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 for the rich-message path. - - Standard Markdown treats a lone ``\\n`` as whitespace (soft break), so - Bot API 10.1 ``sendRichMessage`` collapses multi-line content — e.g. - slash-command lists joined with ``"\\n".join(lines)`` — into a single - paragraph. Adding two trailing spaces before each single newline - forces a hard line break (``
``) in the rendered output. - - Paragraph breaks (``\\n\\n``), fenced code blocks, and GFM pipe-table - blocks are left untouched: tables render natively in the rich path and a - hard break injected into a row separator would corrupt the table. - """ - if not text or '\n' not in text: - return text - - out: list[str] = [] - # Split off protected regions (fenced code OR table blocks) and only inject - # hard breaks in the prose between them. Boundary newlines are handled by - # the original single-\n regex, which sees each prose run as a whole string. - pos = 0 - for m in _RICH_PROTECTED_REGION_RE.finditer(text): - prose = text[pos:m.start()] - out.append(re.sub(r'(? bool: - """Cheap pre-check for the one hard rich limit we can count locally. - - Only the 32,768 UTF-8 character text cap is enforced here. Other Bot API - rich limits (500 blocks, 16 nesting levels, 20 table columns, ...) are - not pre-counted; if exceeded Telegram returns a BadRequest, which - :meth:`_is_rich_fallback_error` classifies as permanent so the send - degrades to the legacy chunking path. - """ - return len(content) <= self.RICH_MESSAGE_MAX_CHARS - - def _bot_supports_rich(self) -> bool: - """True when the bound bot can issue raw ``sendRichMessage`` calls. - - Gates on ``do_api_request`` being an *async* callable. The real - ``telegram.Bot.do_api_request`` is a coroutine function; test doubles - that opt into rich set it to an ``AsyncMock`` (also a coroutine - function). Plain ``MagicMock`` bots expose a *sync* auto-child and - ``SimpleNamespace`` bots lack the attribute entirely — both resolve to - ``False`` here, so the legacy path is used unchanged. - """ - return inspect.iscoroutinefunction(getattr(self._bot, "do_api_request", None)) - - _RICH_DETAILS_RE = re.compile(r"]*>.*?
", re.IGNORECASE | re.DOTALL) - _RICH_MATH_IN_DETAILS_RE = re.compile( - r"(\$\$.*?\$\$|" - r"\\\[.*?\\\]|" - 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: - """Return True for rich-message details+math content that crashes TDesktop. - - Telegram Desktop 6.9.1 can crash while rendering Bot API 10.1 rich - messages containing math inside a collapsible details block - (telegramdesktop/tdesktop#30808). The Bot API accepts the payload, so - Hermes must skip rich delivery up front and use the legacy MarkdownV2 - path until affected Desktop clients age out. - """ - 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: - """Return True for CJK content that current TDesktop rich drafts garble. - - Telegram Mac/Desktop Bot API 10.1 rich-message rendering currently - leaves overlapping draft/overlay glyph artifacts for CJK text (#47653). - The legacy MarkdownV2 path renders the same text cleanly, so skip rich - delivery up front until affected clients age out. - """ - return bool(content and self._RICH_CJK_RE.search(content)) - - def _needs_rich_rendering(self, content: str) -> bool: - """Return True for markdown constructs that the legacy path degrades. - - Keep ordinary replies on the pre-rich MarkdownV2 path so Telegram - clients render a consistent font weight/spacing. The rich endpoint is - reserved for constructs where raw markdown materially improves output: - pipe tables (MarkdownV2 has no table syntax and rewrites them into - bullet lists), GFM task lists, collapsible ``
`` blocks, and - block math. Adapted from #45995 (@YonganZhang). - """ - if not content: - return False - if any(_TABLE_SEPARATOR_RE.match(line) for line in content.splitlines()): - return True - if re.search(r"(?m)^\s*[-*]\s+\[[ xX]\]\s+", content): - return True - if re.search(r"(?m)^|^", content): - return True - if "$$" in content: - return True - return False - - 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: - """Capability/content eligibility for rich, ignoring ``expect_edits``. - - Shared core of :meth:`_should_attempt_rich` minus the per-call - ``expect_edits`` metadata gate. The rich EDIT-finalize path - (:meth:`_try_edit_rich`) needs this: a streamed preview is sent with - ``expect_edits=True`` to stay on the editable path mid-stream, but the - FINAL edit should still upgrade to rich when the content warrants it. - """ - 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. - - Disabled for Telegram. The fresh-final path briefly shows two copies of - the final answer, then deletes the streaming preview after the rich send - succeeds — it looks like duplicate delivery at the end of every streamed - turn (the reason #46206 reverted it). Rich finalize is instead handled - by editing the existing preview in place via Bot API 10.1's - ``editMessageText`` ``rich_message`` parameter (see - :meth:`_try_edit_rich`), so no fresh re-send / delete is needed. - """ - return False - - def streaming_overflow_limit(self) -> Optional[int]: - """Allow the stream consumer to accumulate up to the rich-message cap - before splitting, so a reply that fits one ``sendRichMessage`` / - ``sendRichMessageDraft`` isn't fragmented at the 4,096 MarkdownV2 limit. - - Gated on the same rich capability as the send path (minus the - content-length check — raising that cap is the whole point): rich not - latched off and the bot exposes an async ``do_api_request``. Returns - ``None`` (→ legacy 4,096 limit) when rich isn't available, so non-rich - streams split exactly as before. - """ - 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`` object from RAW markdown. - - Never pass ``format_message(content)`` here — that converts to - MarkdownV2 and would escape/destroy rich syntax like table pipes. - - Single newlines are normalized to Markdown hard breaks so that - multi-line content (slash-command lists, etc.) renders correctly - in the rich-message path. See ``_rich_normalize_linebreaks``. - """ - 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). - - These latch rich off for the rest of the adapter's life — retrying is - pointless and would cost a failed roundtrip on every send. Per-message - rejections (BadRequest from a parser/limit issue) are NOT capability - errors: the next message may be fine. - """ - 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: only clearly-permanent failures (BadRequest, - capability errors, unknown/unsupported endpoint) qualify. Everything - else is treated as transient — the rich request may have reached - Telegram, so we must NOT legacy-resend and risk 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 _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`` to signal "skip - rich, let the legacy path handle it" — used for the DM-topic fail-loud - case so the legacy path stays the single source of the refuse result. - """ - 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, 0) - reply_to_id = int(reply_to_source) if should_thread and reply_to_source else None - 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 private_dm_topic_send and reply_to_id is None and not dm_topic_reply_to_off: - # Refusing to send outside the requested DM topic — defer to the - # legacy path, which returns the canonical fail-loud SendResult. - # Exception: synthetic/resumed topic sends that route via - # ``direct_messages_topic_id`` do not need a reply anchor. - if not thread_kwargs.get("direct_messages_topic_id"): - return None - return reply_to_id, thread_kwargs - - 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 :class:`SendResult` (success, or a transient failure that the - caller must NOT legacy-resend), or ``None`` to signal "fall back to the - legacy MarkdownV2 path" (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: when direct_messages_topic_id is - # present _thread_kwargs_for_send pairs it with message_thread_id=None, - # which must not be sent as a stray field on 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: - # Spec: sendRichMessage takes reply_parameters (ReplyParameters - # object), NOT the legacy reply_to_message_id scalar. Unknown - # params are silently ignored by the Bot API, so the scalar would - # quietly drop the reply anchor instead of erroring. - payload["reply_parameters"] = {"message_id": reply_to_id} - - try: - # Take the raw Bot API result (dict under real PTB). Passing - # return_type=Message would make PTB deserialize a Bot API 10.1 - # response shape it does not fully model yet; a post-delivery parse - # error must not be mistaken for a sendable 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 (old PTB/server) — latch rich off so - # every later send doesn't pay a doomed extra roundtrip. - self._rich_send_disabled = True - logger.debug( - "[%s] sendRichMessage rejected (%s) — falling back to MarkdownV2", - self.name, _redact_telegram_error_text(exc), - ) - return None - # Transient / network / unknown: the request may have reached - # Telegram. Do NOT legacy-resend (duplicate risk); surface a - # failure with retry semantics mirroring the legacy send() except. - 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) - # Extract server-requested retry_after for flood control so the - # base retry layer honors Telegram's backoff instead of its own - # short exponential schedule. - _retry_after = getattr(exc, "retry_after", None) - if _retry_after is None: - import re as _re - _m = _re.search(r"retry\s+(?:in\s+)?(\d+)", err_str, _re.IGNORECASE) - if _m: - _retry_after = float(_m.group(1)) - safe_error = _redact_telegram_error_text(exc) - logger.warning( - "[%s] sendRichMessage transient failure (no legacy resend): %s", - self.name, safe_error, - ) - return SendResult( - success=False, - error=safe_error, - retryable=(is_connect_timeout or not is_timeout), - 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: - # Telegram won't echo rich content in reply_to_message, so remember - # what we sent — replies to this message resolve via this index. - try: - from gateway import rich_sent_store - rich_sent_store.record(str(chat_id), str(message_id), content) - except Exception: - pass - 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 an existing message in place as a rich message (Bot API 10.1). - - Uses ``editMessageText`` with the ``rich_message`` parameter so a - streamed preview can finalize as rich (tables/task lists/details/math) - WITHOUT a fresh send + delete — no duplicate preview. Mirrors - :meth:`_try_send_rich`'s error contract: - - - success → ``SendResult(success=True, message_id=...)`` - - permanent / capability error → ``None`` (caller falls back to the - legacy MarkdownV2 edit; capability errors latch rich off) - - transient / unknown → ``SendResult(success=False)`` with retry - semantics (the message may already be edited; do NOT 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), - } - thread_id = self._metadata_thread_id(metadata) - thread_kwargs = self._thread_kwargs_for_send( - chat_id, - thread_id, - metadata, - reply_to_message_id=None, - reply_to_mode=self._reply_to_mode, - ) - payload.update({k: v for k, v in thread_kwargs.items() if v is not None}) - if getattr(self, "_disable_link_previews", False): - payload["link_preview_options"] = {"is_disabled": True} - try: - # Raw Bot API result; do not request return_type=Message (PTB does - # not fully model the 10.1 response shape yet — a post-edit parse - # error must not be mistaken for a failed edit). - 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" — content identical to the current - # rich message; treat as a successful no-op so the caller does - # not fall through to a 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) - 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] rich editMessageText transient failure (no legacy resend): %s", - self.name, safe_error, - ) - return SendResult( - success=False, - error=safe_error, - retryable=(is_connect_timeout or not is_timeout), - ) - # Telegram won't echo rich content for messages that predate the bot's - # first rich send, so mirror the fresh-send index here too: a streamed - # final finalized via editMessageText is otherwise never recorded, and - # replies to it would have no native echo to recover from. - try: - from gateway import rich_sent_store - rich_sent_store.record(str(chat_id), str(message_id), content) - except Exception: - pass - 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 and overwritten by the next frame / the - final ``sendRichMessage``, so a duplicate or lost rich draft is - harmless — any failure simply returns False and the caller renders the - legacy plain-text draft. A permanent/capability failure additionally - latches ``_rich_draft_disabled`` so later frames skip the rich attempt. - """ - payload: Dict[str, Any] = { - "chat_id": normalize_telegram_chat_id(chat_id), - "draft_id": int(draft_id), - "rich_message": self._rich_message_payload(content), - } - thread_id = self._metadata_thread_id(metadata) - if thread_id is not None: - payload["message_thread_id"] = int(thread_id) - 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 connection pool used for getUpdates polling. diff --git a/plugins/platforms/telegram/telegram_rich.py b/plugins/platforms/telegram/telegram_rich.py new file mode 100644 index 0000000000000..3104f59916d73 --- /dev/null +++ b/plugins/platforms/telegram/telegram_rich.py @@ -0,0 +1,635 @@ +"""Rich-message delivery methods for ``TelegramAdapter`` (Bot API 10.1/10.2). + +Extracted from ``plugins/platforms/telegram/adapter.py`` as part of the god-file +decomposition campaign (telegram adapter shard). This mixin holds the rich-message +delivery cluster: capability/content eligibility gates, TDesktop client-workaround +detectors, the raw ``sendRichMessage`` / ``editMessageText rich_message`` / +``sendRichMessageDraft`` senders, and the streaming protocol overrides. + +Behavior-neutral: every method is lifted verbatim from ``TelegramAdapter``. +``self.*`` calls resolve unchanged via the MRO (thread routing, notification +kwargs, error classifiers stay on the adapter). Neutral dependencies import at +module top; the one adapter-local helper (``_redact_telegram_error_text``) is +imported lazily inside the methods that use it so this module never imports the +adapter at import time -> no import cycle. The module-level ``logger`` keeps the +adapter's exact logger name (``"plugins.platforms.telegram.adapter"``) so log +records are unchanged. + +Tri-state contract (preserved): ``_try_send_rich`` / ``_try_edit_rich`` return +``None`` to signal "fall back to the legacy MarkdownV2 path"; a ``SendResult`` +with ``success=False`` means transient (do NOT legacy-resend). The capability +latches (``_rich_send_disabled`` / ``_rich_draft_disabled``) are initialized in +``TelegramAdapter.__init__`` and read/written only by this cluster. +""" + +from __future__ import annotations + +import inspect +import logging +import re +from typing import Any, Dict, Optional + +from gateway.platforms.base import SendResult +from gateway.platforms.helpers import TABLE_SEPARATOR_RE as _TABLE_SEPARATOR_RE +from plugins.platforms.telegram.telegram_ids import normalize_telegram_chat_id + +logger = logging.getLogger("plugins.platforms.telegram.adapter") + +# --------------------------------------------------------------------------- +# Rich-message newline normalization +# --------------------------------------------------------------------------- + +# Matches a protected region whose internal newlines must stay bare in the +# rich-message path: a fenced code block (```...```) OR a GFM pipe-table block +# (a header row, a delimiter row of dashes/pipes, then any pipe data rows). +# Telegram renders both natively, so injecting Markdown hard breaks inside them +# would corrupt the code block / table. +_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 for the rich-message path. + + Standard Markdown treats a lone ``\\n`` as whitespace (soft break), so + Bot API 10.1 ``sendRichMessage`` collapses multi-line content — e.g. + slash-command lists joined with ``"\\n".join(lines)`` — into a single + paragraph. Adding two trailing spaces before each single newline + forces a hard line break (``
``) in the rendered output. + + Paragraph breaks (``\\n\\n``), fenced code blocks, and GFM pipe-table + blocks are left untouched: tables render natively in the rich path and a + hard break injected into a row separator would corrupt the table. + """ + if not text or '\n' not in text: + return text + + out: list[str] = [] + # Split off protected regions (fenced code OR table blocks) and only inject + # hard breaks in the prose between them. Boundary newlines are handled by + # the original single-\n regex, which sees each prose run as a whole string. + pos = 0 + for m in _RICH_PROTECTED_REGION_RE.finditer(text): + prose = text[pos:m.start()] + out.append(re.sub(r'(? bool: + """Cheap pre-check for the one hard rich limit we can count locally. + + Only the 32,768 UTF-8 character text cap is enforced here. Other Bot API + rich limits (500 blocks, 16 nesting levels, 20 table columns, ...) are + not pre-counted; if exceeded Telegram returns a BadRequest, which + :meth:`_is_rich_fallback_error` classifies as permanent so the send + degrades to the legacy chunking path. + """ + return len(content) <= self.RICH_MESSAGE_MAX_CHARS + + + def _bot_supports_rich(self) -> bool: + """True when the bound bot can issue raw ``sendRichMessage`` calls. + + Gates on ``do_api_request`` being an *async* callable. The real + ``telegram.Bot.do_api_request`` is a coroutine function; test doubles + that opt into rich set it to an ``AsyncMock`` (also a coroutine + function). Plain ``MagicMock`` bots expose a *sync* auto-child and + ``SimpleNamespace`` bots lack the attribute entirely — both resolve to + ``False`` here, so the legacy path is used unchanged. + """ + return inspect.iscoroutinefunction(getattr(self._bot, "do_api_request", None)) + + + _RICH_DETAILS_RE = re.compile(r"]*>.*?
", re.IGNORECASE | re.DOTALL) + _RICH_MATH_IN_DETAILS_RE = re.compile( + r"(\$\$.*?\$\$|" + r"\\\[.*?\\\]|" + 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: + """Return True for rich-message details+math content that crashes TDesktop. + + Telegram Desktop 6.9.1 can crash while rendering Bot API 10.1 rich + messages containing math inside a collapsible details block + (telegramdesktop/tdesktop#30808). The Bot API accepts the payload, so + Hermes must skip rich delivery up front and use the legacy MarkdownV2 + path until affected Desktop clients age out. + """ + 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: + """Return True for CJK content that current TDesktop rich drafts garble. + + Telegram Mac/Desktop Bot API 10.1 rich-message rendering currently + leaves overlapping draft/overlay glyph artifacts for CJK text (#47653). + The legacy MarkdownV2 path renders the same text cleanly, so skip rich + delivery up front until affected clients age out. + """ + return bool(content and self._RICH_CJK_RE.search(content)) + + + def _needs_rich_rendering(self, content: str) -> bool: + """Return True for markdown constructs that the legacy path degrades. + + Keep ordinary replies on the pre-rich MarkdownV2 path so Telegram + clients render a consistent font weight/spacing. The rich endpoint is + reserved for constructs where raw markdown materially improves output: + pipe tables (MarkdownV2 has no table syntax and rewrites them into + bullet lists), GFM task lists, collapsible ``
`` blocks, and + block math. Adapted from #45995 (@YonganZhang). + """ + if not content: + return False + if any(_TABLE_SEPARATOR_RE.match(line) for line in content.splitlines()): + return True + if re.search(r"(?m)^\s*[-*]\s+\[[ xX]\]\s+", content): + return True + if re.search(r"(?m)^|^", content): + return True + if "$$" in content: + return True + return False + + + 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: + """Capability/content eligibility for rich, ignoring ``expect_edits``. + + Shared core of :meth:`_should_attempt_rich` minus the per-call + ``expect_edits`` metadata gate. The rich EDIT-finalize path + (:meth:`_try_edit_rich`) needs this: a streamed preview is sent with + ``expect_edits=True`` to stay on the editable path mid-stream, but the + FINAL edit should still upgrade to rich when the content warrants it. + """ + 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. + + Disabled for Telegram. The fresh-final path briefly shows two copies of + the final answer, then deletes the streaming preview after the rich send + succeeds — it looks like duplicate delivery at the end of every streamed + turn (the reason #46206 reverted it). Rich finalize is instead handled + by editing the existing preview in place via Bot API 10.1's + ``editMessageText`` ``rich_message`` parameter (see + :meth:`_try_edit_rich`), so no fresh re-send / delete is needed. + """ + return False + + + def streaming_overflow_limit(self) -> Optional[int]: + """Allow the stream consumer to accumulate up to the rich-message cap + before splitting, so a reply that fits one ``sendRichMessage`` / + ``sendRichMessageDraft`` isn't fragmented at the 4,096 MarkdownV2 limit. + + Gated on the same rich capability as the send path (minus the + content-length check — raising that cap is the whole point): rich not + latched off and the bot exposes an async ``do_api_request``. Returns + ``None`` (→ legacy 4,096 limit) when rich isn't available, so non-rich + streams split exactly as before. + """ + 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`` object from RAW markdown. + + Never pass ``format_message(content)`` here — that converts to + MarkdownV2 and would escape/destroy rich syntax like table pipes. + + Single newlines are normalized to Markdown hard breaks so that + multi-line content (slash-command lists, etc.) renders correctly + in the rich-message path. See ``_rich_normalize_linebreaks``. + """ + 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). + + These latch rich off for the rest of the adapter's life — retrying is + pointless and would cost a failed roundtrip on every send. Per-message + rejections (BadRequest from a parser/limit issue) are NOT capability + errors: the next message may be fine. + """ + 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: only clearly-permanent failures (BadRequest, + capability errors, unknown/unsupported endpoint) qualify. Everything + else is treated as transient — the rich request may have reached + Telegram, so we must NOT legacy-resend and risk 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 _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`` to signal "skip + rich, let the legacy path handle it" — used for the DM-topic fail-loud + case so the legacy path stays the single source of the refuse result. + """ + 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, 0) + reply_to_id = int(reply_to_source) if should_thread and reply_to_source else None + 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 private_dm_topic_send and reply_to_id is None and not dm_topic_reply_to_off: + # Refusing to send outside the requested DM topic — defer to the + # legacy path, which returns the canonical fail-loud SendResult. + # Exception: synthetic/resumed topic sends that route via + # ``direct_messages_topic_id`` do not need a reply anchor. + if not thread_kwargs.get("direct_messages_topic_id"): + return None + return reply_to_id, thread_kwargs + + + 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 :class:`SendResult` (success, or a transient failure that the + caller must NOT legacy-resend), or ``None`` to signal "fall back to the + legacy MarkdownV2 path" (permanent/capability error or DM-topic skip). + """ + from plugins.platforms.telegram.adapter import _redact_telegram_error_text + + 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: when direct_messages_topic_id is + # present _thread_kwargs_for_send pairs it with message_thread_id=None, + # which must not be sent as a stray field on 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: + # Spec: sendRichMessage takes reply_parameters (ReplyParameters + # object), NOT the legacy reply_to_message_id scalar. Unknown + # params are silently ignored by the Bot API, so the scalar would + # quietly drop the reply anchor instead of erroring. + payload["reply_parameters"] = {"message_id": reply_to_id} + + try: + # Take the raw Bot API result (dict under real PTB). Passing + # return_type=Message would make PTB deserialize a Bot API 10.1 + # response shape it does not fully model yet; a post-delivery parse + # error must not be mistaken for a sendable 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 (old PTB/server) — latch rich off so + # every later send doesn't pay a doomed extra roundtrip. + self._rich_send_disabled = True + logger.debug( + "[%s] sendRichMessage rejected (%s) — falling back to MarkdownV2", + self.name, _redact_telegram_error_text(exc), + ) + return None + # Transient / network / unknown: the request may have reached + # Telegram. Do NOT legacy-resend (duplicate risk); surface a + # failure with retry semantics mirroring the legacy send() except. + 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) + # Extract server-requested retry_after for flood control so the + # base retry layer honors Telegram's backoff instead of its own + # short exponential schedule. + _retry_after = getattr(exc, "retry_after", None) + if _retry_after is None: + import re as _re + _m = _re.search(r"retry\s+(?:in\s+)?(\d+)", err_str, _re.IGNORECASE) + if _m: + _retry_after = float(_m.group(1)) + safe_error = _redact_telegram_error_text(exc) + logger.warning( + "[%s] sendRichMessage transient failure (no legacy resend): %s", + self.name, safe_error, + ) + return SendResult( + success=False, + error=safe_error, + retryable=(is_connect_timeout or not is_timeout), + 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: + # Telegram won't echo rich content in reply_to_message, so remember + # what we sent — replies to this message resolve via this index. + try: + from gateway import rich_sent_store + rich_sent_store.record(str(chat_id), str(message_id), content) + except Exception: + pass + 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 an existing message in place as a rich message (Bot API 10.1). + + Uses ``editMessageText`` with the ``rich_message`` parameter so a + streamed preview can finalize as rich (tables/task lists/details/math) + WITHOUT a fresh send + delete — no duplicate preview. Mirrors + :meth:`_try_send_rich`'s error contract: + + - success → ``SendResult(success=True, message_id=...)`` + - permanent / capability error → ``None`` (caller falls back to the + legacy MarkdownV2 edit; capability errors latch rich off) + - transient / unknown → ``SendResult(success=False)`` with retry + semantics (the message may already be edited; do NOT legacy-resend) + """ + from plugins.platforms.telegram.adapter import _redact_telegram_error_text + + payload: Dict[str, Any] = { + "chat_id": normalize_telegram_chat_id(chat_id), + "message_id": int(message_id), + "rich_message": self._rich_message_payload(content), + } + thread_id = self._metadata_thread_id(metadata) + thread_kwargs = self._thread_kwargs_for_send( + chat_id, + thread_id, + metadata, + reply_to_message_id=None, + reply_to_mode=self._reply_to_mode, + ) + payload.update({k: v for k, v in thread_kwargs.items() if v is not None}) + if getattr(self, "_disable_link_previews", False): + payload["link_preview_options"] = {"is_disabled": True} + try: + # Raw Bot API result; do not request return_type=Message (PTB does + # not fully model the 10.1 response shape yet — a post-edit parse + # error must not be mistaken for a failed edit). + 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" — content identical to the current + # rich message; treat as a successful no-op so the caller does + # not fall through to a 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) + 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] rich editMessageText transient failure (no legacy resend): %s", + self.name, safe_error, + ) + return SendResult( + success=False, + error=safe_error, + retryable=(is_connect_timeout or not is_timeout), + ) + # Telegram won't echo rich content for messages that predate the bot's + # first rich send, so mirror the fresh-send index here too: a streamed + # final finalized via editMessageText is otherwise never recorded, and + # replies to it would have no native echo to recover from. + try: + from gateway import rich_sent_store + rich_sent_store.record(str(chat_id), str(message_id), content) + except Exception: + pass + 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 and overwritten by the next frame / the + final ``sendRichMessage``, so a duplicate or lost rich draft is + harmless — any failure simply returns False and the caller renders the + legacy plain-text draft. A permanent/capability failure additionally + latches ``_rich_draft_disabled`` so later frames skip the rich attempt. + """ + from plugins.platforms.telegram.adapter import _redact_telegram_error_text + + payload: Dict[str, Any] = { + "chat_id": normalize_telegram_chat_id(chat_id), + "draft_id": int(draft_id), + "rich_message": self._rich_message_payload(content), + } + thread_id = self._metadata_thread_id(metadata) + if thread_id is not None: + payload["message_thread_id"] = int(thread_id) + 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 From a60279624371e6e4c87f2f83ba0e3c1f062fc28b Mon Sep 17 00:00:00 2001 From: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:28:30 -0500 Subject: [PATCH 04/10] refactor(telegram): extract polling/updates transport into TelegramPollingMixin (adapter god-file slice) Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> # Conflicts: # plugins/platforms/telegram/adapter.py --- plugins/platforms/telegram/adapter.py | 1251 +--------------- .../platforms/telegram/telegram_polling.py | 1310 +++++++++++++++++ 2 files changed, 1315 insertions(+), 1246 deletions(-) create mode 100644 plugins/platforms/telegram/telegram_polling.py diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index bd828a9316502..1a14647612f51 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -295,6 +295,7 @@ class _MockContextTypes: TelegramTextDeliveryMixin, ) from plugins.platforms.telegram.telegram_rich import TelegramRichMixin +from plugins.platforms.telegram.telegram_polling import TelegramPollingMixin from utils import atomic_replace, env_float, env_int from plugins.platforms.telegram.telegram_inbound import ( @@ -546,7 +547,7 @@ class _PollingLifecycleAbort(RuntimeError): """Internal control flow for polling startup fenced by teardown.""" -class TelegramAdapter(TelegramIngestMixin, TelegramTextDeliveryMixin, TelegramRichMixin, BasePlatformAdapter): +class TelegramAdapter(TelegramPollingMixin, TelegramIngestMixin, TelegramTextDeliveryMixin, TelegramRichMixin, BasePlatformAdapter): """ Telegram bot adapter. @@ -630,7 +631,7 @@ 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._init_polling_state() 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) @@ -668,16 +669,10 @@ def __init__(self, config: PlatformConfig): # Inbound ingest batching/grouping state lives on TelegramIngestMixin. self._init_ingest_state() self._drop_delayed_deliveries = False - 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 # Live @username, refreshed whenever Telegram tells us what it is. # PTB caches getMe() in Bot._bot_user at initialize() and only rewrites @@ -1449,1164 +1444,6 @@ def _coerce_float_extra( return parsed - async def _drain_polling_connections(self) -> None: - """Reset the httpx connection pool used for getUpdates polling. - - Network errors (especially through proxies like sing-box) can leave - httpx connections in a half-closed state that still occupy pool slots. - After enough reconnect cycles the pool fills up entirely, causing - ``Pool timeout: All connections in the connection pool are occupied.`` - - We reset ONLY ``_request[0]`` (the getUpdates request) — the general - request (``_request[1]``) is left untouched so concurrent - ``send_message`` / ``edit_message`` calls are never interrupted. - - Implementation note: accesses ``Bot._request[0]`` which is the - get-updates ``BaseRequest`` in the PTB 22.x internal tuple - ``(get_updates_request, general_request)``. There is no public - accessor for the polling request; review if upgrading to PTB 23+. - """ - if not (self._app and self._app.bot): - return - try: - # PTB 22.x: _request is a (get_updates, general) tuple; - # no public accessor exists for the polling request. - polling_req = self._app.bot._request[0] # noqa: SLF001 - except Exception: - return - try: - # Bounded: a wedged CLOSE-WAIT socket can make this close hang - # forever and freeze the reconnect ladder (#66377). - await asyncio.wait_for(polling_req.shutdown(), timeout=_DRAIN_TIMEOUT) - except Exception: - logger.debug( - "[%s] Polling request shutdown failed/timed out (non-fatal)", - self.name, exc_info=True, - ) - try: - await asyncio.wait_for(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, - ) - - def _begin_polling_generation(self) -> tuple[int, asyncio.Event]: - """Start accepting progress for a new getUpdates polling generation.""" - if getattr(self, "_polling_teardown_started", False): - 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 - 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 getattr(self, "_polling_teardown_started", False): - return - if not self._polling_progress_accepting: - return - if generation != self._polling_generation: - return - self._polling_progress_event.set() - 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 - 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. Kept as its own method so the observation - logic is shared and independently testable. - """ - status_code, payload = result - if generation is None or not (200 <= status_code < 300): - return - try: - # Use the request's own parser so health observation agrees - # exactly with PTB's authoritative response handling (e.g. - # UTF-8 replacement decoding and 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's request classes (``BaseRequest`` / ``HTTPXRequest``) use - ``__slots__``. On Python 3.13 their instances no longer carry a - ``__dict__`` (the ``AbstractAsyncContextManager`` MRO stopped yielding - one), so ``request.do_request = wrapper`` raises - ``AttributeError: 'HTTPXRequest' object attribute 'do_request' is - read-only`` and the whole Telegram connect fails (#64482). It only - appeared to work on Python 3.12, where those instances still had a - ``__dict__``. - - Instead of monkey-patching the instance, re-tag it to a thin subclass - that overrides ``do_request``. This is portable across Python versions - and works for both the real request and the test doubles. The subclass - declares ``__slots__ = ()`` so its instance layout stays identical to - the base, which is what makes the ``__class__`` swap legal on a slotted - instance. - """ - 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 the ``(generation, progress_event)`` pair created for this - polling generation so callers that must gate on readiness (strict - cold start, #67498) can bind to exactly this generation instead of - re-reading ``self._polling_progress_event`` — which a concurrent - recovery task may have replaced with a newer generation's event. - """ - if getattr(self, "_polling_teardown_started", False): - 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 getattr(self, "_polling_teardown_started", False): - 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 for cancellation to escape - # httpcore/AnyIO shielded scopes (#58236/#67498). Reuse the - # proven wall-deadline helper and abandon the partial updater; - # caller recovery will dispose/rebuild the whole adapter. - 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 getattr(self, "_polling_teardown_started", False): - 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 getattr(self, "_polling_teardown_started", False): - 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 Bot API request pool after a confirmed send pool timeout. - - ``send_message`` uses PTB's general request pool (``_request[1]``). - When httpx reports that this pool is exhausted, PTB says the request - was not sent, so it is safe to reset the wedged pool before retrying. - """ - bot = getattr(getattr(self, "_app", None), "bot", None) - if bot is None: - bot = getattr(self, "_bot", None) - if bot is None: - return - try: - # PTB 22.x: _request is (get_updates_request, general_request). - general_req = bot._request[1] # noqa: SLF001 - except Exception: - return - async with self._get_general_request_drain_lock(): - try: - await general_req.shutdown() - except Exception: - logger.debug( - "[%s] General request shutdown failed after pool timeout (non-fatal)", - self.name, exc_info=True, - ) - try: - await general_req.initialize() - logger.warning( - "[%s] General request pool drained after Telegram pool timeout", - self.name, - ) - except Exception: - logger.debug( - "[%s] General request re-initialize failed after pool timeout (non-fatal)", - self.name, exc_info=True, - ) - - def _schedule_polling_recovery(self, error: Exception, *, reason: str) -> None: - """Schedule polling recovery without failing gateway startup. - - A Telegram bootstrap failure (deleteWebhook / initial start_polling) - caused by a transient network error should degrade only the Telegram - adapter: the gateway process stays alive and the existing reconnect - ladder (``_handle_polling_network_error``) recovers in the background. - """ - if getattr(self, "_polling_teardown_started", False): - 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 - logger.warning( - "[%s] Telegram polling degraded (%s); gateway stays alive and will retry. Error: %s", - self.name, reason, _redact_telegram_error_text(error), - ) - loop = asyncio.get_running_loop() - self._polling_error_task = loop.create_task(self._handle_polling_network_error(error)) - self._background_tasks.add(self._polling_error_task) - self._polling_error_task.add_done_callback(self._background_tasks.discard) - - async def _delete_webhook_best_effort( - self, *, require_success: bool = False - ) -> bool: - """Clear stale webhook, optionally failing closed on initial connect. - - Reconnect can recover a transient error in background. Cold startup uses - ``require_success`` so GatewayRunner disposes the partial adapter and - retries with a fresh PTB Application instead of publishing degraded state. - """ - 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 duplicate 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 and optionally require real getUpdates readiness. - - Reconnects may recover in background. Initial connect sets - ``require_progress`` so a bootstrap failure or missing first successful - getUpdates response raises; GatewayRunner then disposes this partial - adapter and retries with a fresh PTB Application. - """ - if getattr(self, "_polling_teardown_started", False): - return False - if not (self._app and self._app.updater): - raise RuntimeError("Telegram application/updater not initialized") - - # Strict cold start (#67498): background recovery must not run while - # the readiness gate is waiting. A G1 polling error would otherwise - # schedule _handle_polling_network_error(), which starts generation - # G2 on the same partial application while this coroutine still waits - # on G1's event — the cold connect then either times out on G1 despite - # G2 succeeding, or G2 "heals" the partial app so GatewayRunner never - # disposes it and retries fresh. Instead, capture the first polling - # error and fail the cold attempt immediately; GatewayRunner owns - # disposal and retry with a fresh adapter. - 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: - # PTB registers this callback for the whole polling - # generation. After the readiness gate closes (success), - # delegate to the real callback so ongoing polling errors - # keep flowing into 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) - # PTB invokes error callbacks from the polling task; the - # event must be 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 httpx - # connection pool can hang start_polling() forever at bootstrap - # too (#59614). A propagating TimeoutError is a builtins - # TimeoutError (OSError subclass), so the except below classifies - # it via _looks_like_network_error and schedules background - # recovery instead of blocking connect() indefinitely. - 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 below IS the cold-start verifier; the - # background verifier would only race it on the partial app. - schedule_verifier=not require_progress, - ) - if require_progress: - # Bind to THIS generation's progress event (returned above), - # not self._polling_progress_event — a concurrent task could - # have replaced it with a later generation's 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 strict gate so any later - # polling error flows to the real background-recovery - # callback instead of the (now finished) cold-start gate. - strict_gate_open = False - self._polling_error_callback_ref = error_callback - return True - except _PollingLifecycleAbort: - return False - except Exception as err: - if getattr(self, "_polling_teardown_started", False): - 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), - ) - loop = asyncio.get_running_loop() - self._polling_error_task = loop.create_task(self._handle_polling_conflict(err)) - self._background_tasks.add(self._polling_error_task) - self._polling_error_task.add_done_callback(self._background_tasks.discard) - return False - if self._looks_like_network_error(err): - self._schedule_polling_recovery(err, reason="polling bootstrap") - return False - raise - - async def _handle_polling_network_error(self, error: Exception) -> None: - """Reconnect polling after a transient network interruption. - - Triggered by NetworkError/TimedOut in the polling error callback, which - happen when the host loses connectivity (Mac sleep, WiFi switch, VPN - reconnect, etc.). The gateway process stays alive but the long-poll - connection silently dies; without this handler the bot never recovers. - - Strategy: exponential back-off (5s, 10s, 20s, 40s, 60s cap) up to - MAX_NETWORK_RETRIES attempts, then mark the adapter retryable-fatal so - the supervisor restarts the gateway process. - """ - if getattr(self, "_polling_teardown_started", False): - 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 getattr(self, "_polling_teardown_started", False): - return - - # Capture a stable local reference: self._app can be reassigned to None - # by a concurrent disconnect() while we're suspended across the awaits - # below, and re-reading self._app after that point would silently swap - # in None mid-sequence instead of failing fast in one place. - app = self._app - - try: - if app and app.updater and app.updater.running: - try: - # Guard stop() with a timeout: when the underlying TCP - # connection is in CLOSE-WAIT the PTB polling task is - # blocked on epoll on the dead socket and never wakes up, - # so an unguarded stop() hangs indefinitely. The result - # is that _polling_error_task stays alive-but-blocked - # forever, every subsequent heartbeat probe sees it as - # "in-flight" and skips triggering a new reconnect, and - # the gateway silently drops messages for hours. - # Bounding stop() lets the reconnect ladder always advance. - # Refs: NousResearch/hermes-agent#58270 - await asyncio.wait_for(app.updater.stop(), timeout=_UPDATER_STOP_TIMEOUT) - except asyncio.TimeoutError: - logger.warning( - "[%s] updater.stop() timed out during network-error " - "reconnect (likely CLOSE-WAIT socket); forcing drain " - "and restart without clean stop", - self.name, - ) - except Exception: - pass - - if getattr(self, "_polling_teardown_started", False): - return - await self._drain_polling_connections() - - if getattr(self, "_polling_teardown_started", False): - 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 getattr(self, "_polling_teardown_started", False): - return - safe_retry_error = _redact_telegram_error_text(retry_err) - logger.warning("[%s] Telegram polling reconnect failed: %s", self.name, safe_retry_error) - # start_polling failed — polling is dead and no further error - # callbacks will fire, so schedule the next retry ourselves. - if ( - not self.has_fatal_error - and not getattr(self, "_polling_teardown_started", False) - ): - task = asyncio.ensure_future( - self._handle_polling_network_error(retry_err) - ) - self._background_tasks.add(task) - task.add_done_callback(self._background_tasks.discard) - # This chained retry IS the in-flight recovery attempt — it - # must replace the reentrancy guard, otherwise the heartbeat - # loop, the pending-updates probe, and the PTB error callback - # all see _polling_error_task as "done" and can each start a - # second, concurrent recovery for the same outage. - self._polling_error_task = task - - async def _polling_heartbeat_loop(self) -> None: - """Detect dead Telegram TCP sockets (CLOSE-WAIT) by periodic probing. - - PTB's long-poll task blocks on epoll waiting for Telegram to push an - update. When the underlying TCP connection enters CLOSE-WAIT (the remote - sent a FIN but the httpx pool has not yet noticed), epoll still reports - the socket as readable and no exception is raised — so PTB's - ``error_callback`` never fires and the gateway silently stops receiving - messages. - - This loop probes ``get_me()`` every ``HEARTBEAT_INTERVAL`` seconds on the - *general* request path (not the getUpdates pool), so a healthy long-poll - waiting for the 30-second Telegram window is never interrupted. On any - connect-level failure the loop hands off to - ``_handle_polling_network_error`` — the same path triggered by PTB's own - ``error_callback`` — which drains the dead pool and restarts polling. - - Unlike the generation verifier (a one-shot progress deadline after - every polling start), this loop runs for the full lifetime of the - polling connection, so it catches a socket that wedges later during - steady-state operation without any prior error event. - """ - HEARTBEAT_INTERVAL = 90 # seconds between probes - PROBE_TIMEOUT = 15 # seconds before declaring the path dead - - # Wedged-recovery watchdog state (#66377). Tracked locally so no - # _polling_error_task assignment site needs to stamp a timestamp: the - # heartbeat notes when it first observes a given recovery task still - # in-flight, and force-escalates if the *same* task object is still - # running after _POLLING_ERROR_TASK_STUCK_TIMEOUT. A healthy ladder - # attempt completes (task done) or chains to a new task well before - # then, so a single long-lived task is unambiguously wedged. - stuck_task_ref: Optional[asyncio.Task] = None - stuck_task_since = 0.0 - - while True: - try: - await asyncio.sleep(HEARTBEAT_INTERVAL) - if getattr(self, "_polling_teardown_started", False): - return - if self.has_fatal_error: - return - - # Independent wedged-recovery watchdog (#66377): if the tracked - # recovery task has hung (any await no local bound covers), every - # other recovery path is gated behind it and returns early - # forever — the gateway stays alive but deaf. Force a - # retryable-fatal so the background reconnector rebuilds the - # adapter instead of relying on the frozen ladder. - 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 - # A real PTB Bot always exposes get_me(); if it's absent the - # app isn't a live polling client (e.g. torn down or a test - # double), so there is nothing to probe — exit rather than 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 in place, so this is - # also where a BotFather rename gets picked up: adopt whatever - # handle Telegram just reported before anything routes on it. - self._bot_identity_checked_at = time.monotonic() - self._note_bot_username(getattr(bot, "username", None)) - # get_me() succeeded — the general/send request path is healthy. - # That does NOT prove the getUpdates consumer is alive: PTB can - # report updater.running=True while the long-poll task is wedged, - # so DMs queue in the Bot API and never reach handlers (#42909). - # get_me() is blind to this; get_webhook_info() exposes it via - # pending_update_count. Escalate only after two consecutive - # probes see a non-zero queue while we believe we're polling, so - # a single in-flight update (consumed before the next probe) - # never trips recovery. - await self._probe_pending_updates(bot, PROBE_TIMEOUT) - 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) are not - # CLOSE-WAIT symptoms — let PTB's own handlers surface them. - pass - - async def _probe_pending_updates(self, bot, probe_timeout: float) -> None: - """Detect a wedged getUpdates consumer via pending_update_count. - - PTB can report ``updater.running == True`` while its long-poll task is - silently stuck (e.g. a socket that epoll keeps reporting readable on - WSL2). ``get_me()`` stays healthy because it uses the general request - path, so the CLOSE-WAIT heartbeat never fires — yet DMs queue in the - Bot API and never reach handlers (#42909). - - ``get_webhook_info().pending_update_count`` is the one signal that - exposes this: a growing/stuck queue while we believe we're polling means - the consumer is dead. We only escalate after two consecutive stuck - probes so a single update that's simply in-flight between probes does - not trip a needless recovery. Recovery reuses - ``_handle_polling_network_error`` — the same ladder PTB's own - ``error_callback`` feeds — so no new restart machinery is introduced. - - This also covers the harsher case where the updater has stopped - entirely (``running=False``) with no reconnect in flight: the long-poll - task is gone rather than wedged, so even ``get_webhook_info`` can't - report a queue against a live consumer. We detect the stopped updater - directly and feed the same ladder (#55769). - """ - if getattr(self, "_polling_teardown_started", False): - return - # Only meaningful in polling mode; in webhook mode Telegram pushes - # updates and holds no server-side queue. - if self._webhook_mode: - return - # A reconnect already in flight owns recovery — don't double-trigger, - # and don't misread its brief stop()->start_polling() window (where - # updater.running is transiently False) as a dead updater below. - 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): - # We are in polling mode with no reconnect in flight, yet PTB's - # updater has stopped entirely. This is distinct from the - # wedged-but-running consumer handled below: the long-poll task is - # gone, get_me()/get_webhook_info() on the general request path - # still succeed, so no error_callback or connectivity probe ever - # fires and the gateway silently stops receiving messages while the - # process stays alive (#55769). Escalate through the same reconnect - # ladder as a wedged consumer, debounced over two consecutive 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 getattr(self, "_polling_teardown_started", False): - 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): - # A failed probe is a connectivity symptom the get_me() path or the - # outer handler will catch; don't treat it as 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 getattr(self, "_polling_teardown_started", False): - 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 _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 response on the - dedicated getUpdates request. A general-path getMe success can classify - connectivity, but cannot heal polling health. Connectivity failures - enter the guarded recovery ladder; auth/validation errors do not churn. - """ - PROBE_TIMEOUT = 10 - if getattr(self, "_polling_teardown_started", False): - 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 getattr(self, "_polling_teardown_started", False): - 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 getattr(self, "_polling_teardown_started", False): - 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 getattr(self, "_polling_teardown_started", False): - 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 wraps ``getUpdates`` in ``network_retry_loop`` with - ``max_retries=-1`` (retry forever). When a ``TelegramError`` (including - a 409 ``Conflict``) fires, that loop calls our ``error_callback`` - *synchronously*, then sleeps and re-checks ``while is_running()`` before - polling again. Our ``error_callback`` only schedules an async recovery - task (``loop.create_task(...)``) and returns immediately, so PTB's loop - keeps polling while our handler concurrently runs - ``stop -> sleep -> start_polling``. The two polling sessions overlap and - Telegram returns a fresh 409 — a self-inflicted conflict loop on a - ~31s cadence. - - The loop is wired with ``is_running=lambda: updater.running`` and a - private ``stop_event`` (``do_action`` races that event and returns the - moment it is set). Setting that event *synchronously inside the - callback* — before it returns — makes PTB's loop exit on its own next - tick instead of racing our recovery. Our async handler then performs - the real ``await updater.stop()`` (idempotent) followed by - drain + ``start_polling()``, which builds a fresh ``stop_event`` so the - restart is not poisoned. - - Best-effort and defensive: PTB names the attribute differently across - versions (``_Updater__polling_task_stop_event`` via name-mangling), so - we probe for both spellings. If neither is found we do nothing and - fall back to the prior behaviour (async ``updater.stop()`` racing PTB) — - i.e. we never make things worse than before. - - We deliberately do NOT fall back to flipping ``updater._running``: - ``stop()`` raises ``RuntimeError`` when ``running`` is already False and - our recovery handler guards its ``stop()`` call on ``running``, so - clearing the flag here would skip the real teardown and leave PTB's - stop_event uncleared — poisoning the subsequent ``start_polling()``. - The stop_event lever leaves ``_running`` True, so the handler's - ``await updater.stop()`` still runs, drains the polling task, and clears - the event for a clean restart. - """ - updater = getattr(self._app, "updater", None) if self._app else None - if updater is None: - return - # Preferred (and only) lever: PTB's polling stop_event. Name-mangled on - # Updater, so probe both the mangled and unmangled spellings. - 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 getattr(self, "_polling_teardown_started", False): - return - if self.has_fatal_error and self.fatal_error_code == "telegram_polling_conflict": - return - # Transient 409 Conflict errors arise when the previous gateway process - # has been killed (e.g. during `hermes update` or `--replace` handoffs) - # but its long-poll connection hasn't yet expired on Telegram's servers. - # Telegram holds open getUpdates sessions for up to ~30s after the - # client disconnects, so a new gateway starting immediately will receive - # a 409 until that server-side session expires. - # - # Strategy: stop the local updater, wait long enough for Telegram's - # server-side session to expire (RETRY_DELAY grows with each attempt), - # drain the connection pool, then restart polling. We attempt this - # MAX_CONFLICT_RETRIES times before declaring a fatal error. - # - # Crucially, a failed retry must NOT leave polling in an ambiguous - # state. If start_polling() raises, the updater is neither running - # nor fatal — messages are silently dropped. We schedule another - # retry attempt instead of returning silently, and only escalate to - # fatal after all retries are exhausted. - self._polling_conflict_count += 1 - - MAX_CONFLICT_RETRIES = 5 - # Delay grows with each attempt: 15s, 25s, 35s, 45s, 55s. - # Telegram server-side getUpdates sessions typically expire within - # 30s; the increasing back-off ensures we clear that 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 local updater cleanly before sleeping. If it's already - # stopped (e.g. PTB raised before updater.running was set) this is - # a no-op. Bounded with a timeout for the same reason as the - # network-error path: a CLOSE-WAIT socket can wedge stop() on epoll - # forever, which would stall the conflict-retry ladder. - try: - if self._app and self._app.updater and self._app.updater.running: - try: - await asyncio.wait_for(self._app.updater.stop(), timeout=_UPDATER_STOP_TIMEOUT) - except asyncio.TimeoutError: - logger.warning( - "[%s] updater.stop() timed out during conflict " - "retry (likely CLOSE-WAIT socket); continuing", - self.name, - ) - except Exception: - pass - - await asyncio.sleep(RETRY_DELAY) - if getattr(self, "_polling_teardown_started", False): - return - await self._drain_polling_connections() - if getattr(self, "_polling_teardown_started", False): - return - - # Capture a stable local reference: self._app can be reassigned to - # None by a concurrent disconnect() while we're suspended across - # the awaits above (same race #55992 fixed on the network path). - # Re-reading self._app after that point would raise - # AttributeError deep inside start_polling instead of failing fast - # here, where the except below reschedules or escalates to fatal. - 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 tells Telegram to terminate any - # other active getUpdates sessions for this bot token. The - # competing session is either a zombie from the previous - # gateway process (whose long-poll hasn't expired server-side - # yet) or our own previous retry's still-expiring session. - # Without this, each retry starts a new getUpdates session - # that immediately gets 409'd by the previous one, creating - # the very conflict we are trying to recover from (#75017). - self._polling_conflict_recovery_generation = expected_generation - try: - await self._start_polling_once( - app, - drop_pending_updates=True, - error_callback=self._polling_error_callback_ref, - ) - logger.info( - "[%s] Telegram polling restarted after conflict retry %d/%d; " - "health pending getUpdates progress", - self.name, self._polling_conflict_count, MAX_CONFLICT_RETRIES, - ) - return - except _PollingLifecycleAbort: - return - except Exception as retry_err: - if getattr(self, "_polling_teardown_started", False): - 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), - ) - # Schedule the next retry rather than returning silently. - # Returning here without either restarting polling or setting - # a fatal error leaves the adapter in a limbo state: the - # gateway process is alive and reports "connected" but - # no messages are received or sent. - if ( - self._polling_conflict_count < MAX_CONFLICT_RETRIES - and not getattr(self, "_polling_teardown_started", False) - ): - # We are inside a running coroutine, so the running loop is - # guaranteed to exist. asyncio.get_event_loop() is deprecated - # and raises "RuntimeError: There is no current event loop in - # thread 'MainThread'" on Python 3.10+ when invoked from a - # context without an attached loop (which can happen when PTB - # dispatches this error callback). Use get_running_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 getattr(self, "_polling_teardown_started", False): - return - - # Exhausted all retries — declare a fatal error so the gateway - # runner can surface this clearly 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 are the call that actually transitions to fatal. - # A concurrent retry task scheduled by an earlier conflict may already - # be suspended past the entry guard; once _set_fatal_error flips the - # flag, adding an await below (the bounded stop()) yields the loop and - # lets that task reach this branch too — double-notifying the fatal - # handler. 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 asyncio.wait_for(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. ``disconnect()`` - cancels the tracked polling-recovery task and the heartbeat task, so - retaining the current notifier in either field would cancel the fatal - callback before the runner can finish its reconnect or shutdown - decision. Release only the current owner from whichever field tracks - it; unrelated tasks remain under teardown control. - """ - 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, @@ -3394,85 +2231,8 @@ def _with_limits(httpx_kwargs: Optional[dict] = None) -> dict: await self._app.start() # Decide between webhook and polling mode - webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").strip() - - if webhook_url: - # ── Webhook mode ───────────────────────────────────── - # Telegram pushes updates to our HTTP endpoint. This - # enables cloud platforms (Fly.io, Railway) to auto-wake - # suspended machines on inbound HTTP traffic. - # - # SECURITY: TELEGRAM_WEBHOOK_SECRET is REQUIRED. Without it, - # python-telegram-bot passes secret_token=None and the - # webhook endpoint accepts any HTTP POST — attackers can - # inject forged updates as if from Telegram. Refuse to - # start rather than silently run in fail-open mode. - # See GHSA-3vpc-7q5r-276h. - webhook_port = env_int("TELEGRAM_WEBHOOK_PORT", 8443) - # Bind host. Default "" → tornado bind_sockets opens one - # listening socket per address family (IPv4 + IPv6). The old - # hardcoded "0.0.0.0" bound IPv4 ONLY and was unreachable - # over IPv6-only private networks (e.g. Fly.io 6PN) — same - # bug as the LINE adapter (NS-603). Pin via - # TELEGRAM_WEBHOOK_HOST or platforms.telegram.extra.webhook_host. - webhook_host = ( - os.getenv("TELEGRAM_WEBHOOK_HOST", "").strip() - or str((self.config.extra or {}).get("webhook_host") or "").strip() - ) - # Profile-scoped read (adapter startup, Slack pattern - # #59739): a scoped read honors the profile's own secret; - # only an UNSCOPED read under multiplex (default-profile - # startup loop) falls back to the process env, which is that - # profile's own value. - 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 — Telegram does not hold a - # server-side getUpdates queue, so this flag is a no-op - # in practice. Mirror the polling path's reconnect - # semantics for consistency. - 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, - ) - else: + webhook_started = await self._start_webhook(is_reconnect=is_reconnect) + if not webhook_started: # ── Polling mode (default) ─────────────────────────── # Clear any stale webhook first so polling doesn't inherit a # previous webhook registration and silently stop receiving @@ -3665,7 +2425,6 @@ async def disconnect(self) -> None: 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 # Recovery can be suspended in stop/drain/start while disconnect begins. diff --git a/plugins/platforms/telegram/telegram_polling.py b/plugins/platforms/telegram/telegram_polling.py new file mode 100644 index 0000000000000..d359384464906 --- /dev/null +++ b/plugins/platforms/telegram/telegram_polling.py @@ -0,0 +1,1310 @@ +"""Polling/updates transport mixin for the Telegram adapter (adapter god-file slice). + +Extracted from ``plugins/platforms/telegram/adapter.py``: the getUpdates +polling lifecycle (start/drain/reconnect/conflict ladders), the CLOSE-WAIT +heartbeat and pending-update probe, polling progress generations and their +verifiers, webhook startup, and the teardown fencing that serializes them. +``TelegramAdapter`` imports ``TelegramPollingMixin`` back and inherits from it +(the mixin pattern proven by the gateway authorization/topic mixins). + +Adapter-local module globals the moved methods read at call time (error +redaction, polling constants, the generation ContextVar, the thread-deadline +helpers, the runtime-rebound ``Update``) stay on the adapter and are imported +lazily inside each method body, so this module never imports the adapter at +import time -> no import cycle, and monkeypatches of ``adapter.`` keep +working. Shared error classifiers (``_looks_like_network_error`` / +``_looks_like_polling_conflict``) stay on the adapter class and resolve via +``self`` (MRO). +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import time +from typing import Optional + +from utils import env_int + +# Keep log records under the adapter's logger name so operator log filters +# and caplog assertions keyed on the adapter keep working after the slice. +logger = logging.getLogger("plugins.platforms.telegram.adapter") + + +class TelegramPollingMixin: + """Polling/updates-transport methods for TelegramAdapter.""" + + def _init_polling_state(self) -> None: + """Initialize polling/updates-transport instance state. + + Extracted verbatim from ``TelegramAdapter.__init__`` so the polling + mixin owns its transport state fields. + """ + self._webhook_mode: bool = False + self._polling_error_task: Optional[asyncio.Task] = None + self._polling_generation: int = 0 + self._polling_progress_event = asyncio.Event() + self._polling_progress_accepting: bool = False + self._polling_teardown_started: bool = False + self._polling_error_callback_ref = None + + async def _drain_polling_connections(self) -> None: + """Reset the httpx connection pool used for getUpdates polling. + + Network errors (especially through proxies like sing-box) can leave + httpx connections in a half-closed state that still occupy pool slots. + After enough reconnect cycles the pool fills up entirely, causing + ``Pool timeout: All connections in the connection pool are occupied.`` + + We reset ONLY ``_request[0]`` (the getUpdates request) — the general + request (``_request[1]``) is left untouched so concurrent + ``send_message`` / ``edit_message`` calls are never interrupted. + + Implementation note: accesses ``Bot._request[0]`` which is the + get-updates ``BaseRequest`` in the PTB 22.x internal tuple + ``(get_updates_request, general_request)``. There is no public + accessor for the polling request; review if upgrading to PTB 23+. + """ + from plugins.platforms.telegram.adapter import _DRAIN_TIMEOUT + if not (self._app and self._app.bot): + return + try: + # PTB 22.x: _request is a (get_updates, general) tuple; + # no public accessor exists for the polling request. + polling_req = self._app.bot._request[0] # noqa: SLF001 + except Exception: + return + try: + # Bounded: a wedged CLOSE-WAIT socket can make this close hang + # forever and freeze the reconnect ladder (#66377). + await asyncio.wait_for(polling_req.shutdown(), timeout=_DRAIN_TIMEOUT) + except Exception: + logger.debug( + "[%s] Polling request shutdown failed/timed out (non-fatal)", + self.name, exc_info=True, + ) + try: + await asyncio.wait_for(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, + ) + + def _begin_polling_generation(self) -> tuple[int, asyncio.Event]: + """Start accepting progress for a new getUpdates polling generation.""" + if getattr(self, "_polling_teardown_started", False): + 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 + 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 getattr(self, "_polling_teardown_started", False): + return + if not self._polling_progress_accepting: + return + if generation != self._polling_generation: + return + self._polling_progress_event.set() + 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 + 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. Kept as its own method so the observation + logic is shared and independently testable. + """ + status_code, payload = result + if generation is None or not (200 <= status_code < 300): + return + try: + # Use the request's own parser so health observation agrees + # exactly with PTB's authoritative response handling (e.g. + # UTF-8 replacement decoding and 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's request classes (``BaseRequest`` / ``HTTPXRequest``) use + ``__slots__``. On Python 3.13 their instances no longer carry a + ``__dict__`` (the ``AbstractAsyncContextManager`` MRO stopped yielding + one), so ``request.do_request = wrapper`` raises + ``AttributeError: 'HTTPXRequest' object attribute 'do_request' is + read-only`` and the whole Telegram connect fails (#64482). It only + appeared to work on Python 3.12, where those instances still had a + ``__dict__``. + + Instead of monkey-patching the instance, re-tag it to a thin subclass + that overrides ``do_request``. This is portable across Python versions + and works for both the real request and the test doubles. The subclass + declares ``__slots__ = ()`` so its instance layout stays identical to + the base, which is what makes the ``__class__`` swap legal on a slotted + instance. + """ + from plugins.platforms.telegram.adapter import _POLLING_GENERATION_CONTEXT + 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 the ``(generation, progress_event)`` pair created for this + polling generation so callers that must gate on readiness (strict + cold start, #67498) can bind to exactly this generation instead of + re-reading ``self._polling_progress_event`` — which a concurrent + recovery task may have replaced with a newer generation's event. + """ + from plugins.platforms.telegram.adapter import Update, _POLLING_GENERATION_CONTEXT, _PollingLifecycleAbort, _UPDATER_START_TIMEOUT, _await_with_thread_deadline, _shutdown_abandoned_app + if getattr(self, "_polling_teardown_started", False): + 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 getattr(self, "_polling_teardown_started", False): + 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 for cancellation to escape + # httpcore/AnyIO shielded scopes (#58236/#67498). Reuse the + # proven wall-deadline helper and abandon the partial updater; + # caller recovery will dispose/rebuild the whole adapter. + 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 getattr(self, "_polling_teardown_started", False): + 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 getattr(self, "_polling_teardown_started", False): + 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 Bot API request pool after a confirmed send pool timeout. + + ``send_message`` uses PTB's general request pool (``_request[1]``). + When httpx reports that this pool is exhausted, PTB says the request + was not sent, so it is safe to reset the wedged pool before retrying. + """ + bot = getattr(getattr(self, "_app", None), "bot", None) + if bot is None: + bot = getattr(self, "_bot", None) + if bot is None: + return + try: + # PTB 22.x: _request is (get_updates_request, general_request). + general_req = bot._request[1] # noqa: SLF001 + except Exception: + return + async with self._get_general_request_drain_lock(): + try: + await general_req.shutdown() + except Exception: + logger.debug( + "[%s] General request shutdown failed after pool timeout (non-fatal)", + self.name, exc_info=True, + ) + try: + await general_req.initialize() + logger.warning( + "[%s] General request pool drained after Telegram pool timeout", + self.name, + ) + except Exception: + logger.debug( + "[%s] General request re-initialize failed after pool timeout (non-fatal)", + self.name, exc_info=True, + ) + + def _schedule_polling_recovery(self, error: Exception, *, reason: str) -> None: + """Schedule polling recovery without failing gateway startup. + + A Telegram bootstrap failure (deleteWebhook / initial start_polling) + caused by a transient network error should degrade only the Telegram + adapter: the gateway process stays alive and the existing reconnect + ladder (``_handle_polling_network_error``) recovers in the background. + """ + from plugins.platforms.telegram.adapter import _redact_telegram_error_text + if getattr(self, "_polling_teardown_started", False): + 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 + logger.warning( + "[%s] Telegram polling degraded (%s); gateway stays alive and will retry. Error: %s", + self.name, reason, _redact_telegram_error_text(error), + ) + loop = asyncio.get_running_loop() + self._polling_error_task = loop.create_task(self._handle_polling_network_error(error)) + self._background_tasks.add(self._polling_error_task) + self._polling_error_task.add_done_callback(self._background_tasks.discard) + + async def _delete_webhook_best_effort( + self, *, require_success: bool = False + ) -> bool: + """Clear stale webhook, optionally failing closed on initial connect. + + Reconnect can recover a transient error in background. Cold startup uses + ``require_success`` so GatewayRunner disposes the partial adapter and + retries with a fresh PTB Application instead of publishing degraded state. + """ + from plugins.platforms.telegram.adapter import _UPDATER_START_TIMEOUT, _await_with_thread_deadline, _redact_telegram_error_text + 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 duplicate 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 and optionally require real getUpdates readiness. + + Reconnects may recover in background. Initial connect sets + ``require_progress`` so a bootstrap failure or missing first successful + getUpdates response raises; GatewayRunner then disposes this partial + adapter and retries with a fresh PTB Application. + """ + from plugins.platforms.telegram.adapter import _INITIAL_POLLING_PROGRESS_TIMEOUT, _PollingLifecycleAbort, _await_with_thread_deadline, _first_completed, _redact_telegram_error_text + if getattr(self, "_polling_teardown_started", False): + return False + if not (self._app and self._app.updater): + raise RuntimeError("Telegram application/updater not initialized") + + # Strict cold start (#67498): background recovery must not run while + # the readiness gate is waiting. A G1 polling error would otherwise + # schedule _handle_polling_network_error(), which starts generation + # G2 on the same partial application while this coroutine still waits + # on G1's event — the cold connect then either times out on G1 despite + # G2 succeeding, or G2 "heals" the partial app so GatewayRunner never + # disposes it and retries fresh. Instead, capture the first polling + # error and fail the cold attempt immediately; GatewayRunner owns + # disposal and retry with a fresh adapter. + 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: + # PTB registers this callback for the whole polling + # generation. After the readiness gate closes (success), + # delegate to the real callback so ongoing polling errors + # keep flowing into 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) + # PTB invokes error callbacks from the polling task; the + # event must be 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 httpx + # connection pool can hang start_polling() forever at bootstrap + # too (#59614). A propagating TimeoutError is a builtins + # TimeoutError (OSError subclass), so the except below classifies + # it via _looks_like_network_error and schedules background + # recovery instead of blocking connect() indefinitely. + 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 below IS the cold-start verifier; the + # background verifier would only race it on the partial app. + schedule_verifier=not require_progress, + ) + if require_progress: + # Bind to THIS generation's progress event (returned above), + # not self._polling_progress_event — a concurrent task could + # have replaced it with a later generation's 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 strict gate so any later + # polling error flows to the real background-recovery + # callback instead of the (now finished) cold-start gate. + strict_gate_open = False + self._polling_error_callback_ref = error_callback + return True + except _PollingLifecycleAbort: + return False + except Exception as err: + if getattr(self, "_polling_teardown_started", False): + 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), + ) + loop = asyncio.get_running_loop() + self._polling_error_task = loop.create_task(self._handle_polling_conflict(err)) + self._background_tasks.add(self._polling_error_task) + self._polling_error_task.add_done_callback(self._background_tasks.discard) + return False + if self._looks_like_network_error(err): + self._schedule_polling_recovery(err, reason="polling bootstrap") + return False + raise + + async def _handle_polling_network_error(self, error: Exception) -> None: + """Reconnect polling after a transient network interruption. + + Triggered by NetworkError/TimedOut in the polling error callback, which + happen when the host loses connectivity (Mac sleep, WiFi switch, VPN + reconnect, etc.). The gateway process stays alive but the long-poll + connection silently dies; without this handler the bot never recovers. + + Strategy: exponential back-off (5s, 10s, 20s, 40s, 60s cap) up to + MAX_NETWORK_RETRIES attempts, then mark the adapter retryable-fatal so + the supervisor restarts the gateway process. + """ + from plugins.platforms.telegram.adapter import _PollingLifecycleAbort, _UPDATER_STOP_TIMEOUT, _redact_telegram_error_text + if getattr(self, "_polling_teardown_started", False): + 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 getattr(self, "_polling_teardown_started", False): + return + + # Capture a stable local reference: self._app can be reassigned to None + # by a concurrent disconnect() while we're suspended across the awaits + # below, and re-reading self._app after that point would silently swap + # in None mid-sequence instead of failing fast in one place. + app = self._app + + try: + if app and app.updater and app.updater.running: + try: + # Guard stop() with a timeout: when the underlying TCP + # connection is in CLOSE-WAIT the PTB polling task is + # blocked on epoll on the dead socket and never wakes up, + # so an unguarded stop() hangs indefinitely. The result + # is that _polling_error_task stays alive-but-blocked + # forever, every subsequent heartbeat probe sees it as + # "in-flight" and skips triggering a new reconnect, and + # the gateway silently drops messages for hours. + # Bounding stop() lets the reconnect ladder always advance. + # Refs: NousResearch/hermes-agent#58270 + await asyncio.wait_for(app.updater.stop(), timeout=_UPDATER_STOP_TIMEOUT) + except asyncio.TimeoutError: + logger.warning( + "[%s] updater.stop() timed out during network-error " + "reconnect (likely CLOSE-WAIT socket); forcing drain " + "and restart without clean stop", + self.name, + ) + except Exception: + pass + + if getattr(self, "_polling_teardown_started", False): + return + await self._drain_polling_connections() + + if getattr(self, "_polling_teardown_started", False): + 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 getattr(self, "_polling_teardown_started", False): + return + safe_retry_error = _redact_telegram_error_text(retry_err) + logger.warning("[%s] Telegram polling reconnect failed: %s", self.name, safe_retry_error) + # start_polling failed — polling is dead and no further error + # callbacks will fire, so schedule the next retry ourselves. + if ( + not self.has_fatal_error + and not getattr(self, "_polling_teardown_started", False) + ): + task = asyncio.ensure_future( + self._handle_polling_network_error(retry_err) + ) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + # This chained retry IS the in-flight recovery attempt — it + # must replace the reentrancy guard, otherwise the heartbeat + # loop, the pending-updates probe, and the PTB error callback + # all see _polling_error_task as "done" and can each start a + # second, concurrent recovery for the same outage. + self._polling_error_task = task + + async def _polling_heartbeat_loop(self) -> None: + """Detect dead Telegram TCP sockets (CLOSE-WAIT) by periodic probing. + + PTB's long-poll task blocks on epoll waiting for Telegram to push an + update. When the underlying TCP connection enters CLOSE-WAIT (the remote + sent a FIN but the httpx pool has not yet noticed), epoll still reports + the socket as readable and no exception is raised — so PTB's + ``error_callback`` never fires and the gateway silently stops receiving + messages. + + This loop probes ``get_me()`` every ``HEARTBEAT_INTERVAL`` seconds on the + *general* request path (not the getUpdates pool), so a healthy long-poll + waiting for the 30-second Telegram window is never interrupted. On any + connect-level failure the loop hands off to + ``_handle_polling_network_error`` — the same path triggered by PTB's own + ``error_callback`` — which drains the dead pool and restarts polling. + + Unlike the generation verifier (a one-shot progress deadline after + every polling start), this loop runs for the full lifetime of the + polling connection, so it catches a socket that wedges later during + steady-state operation without any prior error event. + """ + from plugins.platforms.telegram.adapter import _POLLING_ERROR_TASK_STUCK_TIMEOUT + HEARTBEAT_INTERVAL = 90 # seconds between probes + PROBE_TIMEOUT = 15 # seconds before declaring the path dead + + # Wedged-recovery watchdog state (#66377). Tracked locally so no + # _polling_error_task assignment site needs to stamp a timestamp: the + # heartbeat notes when it first observes a given recovery task still + # in-flight, and force-escalates if the *same* task object is still + # running after _POLLING_ERROR_TASK_STUCK_TIMEOUT. A healthy ladder + # attempt completes (task done) or chains to a new task well before + # then, so a single long-lived task is unambiguously wedged. + stuck_task_ref: Optional[asyncio.Task] = None + stuck_task_since = 0.0 + + while True: + try: + await asyncio.sleep(HEARTBEAT_INTERVAL) + if getattr(self, "_polling_teardown_started", False): + return + if self.has_fatal_error: + return + + # Independent wedged-recovery watchdog (#66377): if the tracked + # recovery task has hung (any await no local bound covers), every + # other recovery path is gated behind it and returns early + # forever — the gateway stays alive but deaf. Force a + # retryable-fatal so the background reconnector rebuilds the + # adapter instead of relying on the frozen ladder. + 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 + # A real PTB Bot always exposes get_me(); if it's absent the + # app isn't a live polling client (e.g. torn down or a test + # double), so there is nothing to probe — exit rather than 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 in place, so this is + # also where a BotFather rename gets picked up: adopt whatever + # handle Telegram just reported before anything routes on it. + self._bot_identity_checked_at = time.monotonic() + self._note_bot_username(getattr(bot, "username", None)) + # get_me() succeeded — the general/send request path is healthy. + # That does NOT prove the getUpdates consumer is alive: PTB can + # report updater.running=True while the long-poll task is wedged, + # so DMs queue in the Bot API and never reach handlers (#42909). + # get_me() is blind to this; get_webhook_info() exposes it via + # pending_update_count. Escalate only after two consecutive + # probes see a non-zero queue while we believe we're polling, so + # a single in-flight update (consumed before the next probe) + # never trips recovery. + await self._probe_pending_updates(bot, PROBE_TIMEOUT) + 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) are not + # CLOSE-WAIT symptoms — let PTB's own handlers surface them. + pass + + async def _probe_pending_updates(self, bot, probe_timeout: float) -> None: + """Detect a wedged getUpdates consumer via pending_update_count. + + PTB can report ``updater.running == True`` while its long-poll task is + silently stuck (e.g. a socket that epoll keeps reporting readable on + WSL2). ``get_me()`` stays healthy because it uses the general request + path, so the CLOSE-WAIT heartbeat never fires — yet DMs queue in the + Bot API and never reach handlers (#42909). + + ``get_webhook_info().pending_update_count`` is the one signal that + exposes this: a growing/stuck queue while we believe we're polling means + the consumer is dead. We only escalate after two consecutive stuck + probes so a single update that's simply in-flight between probes does + not trip a needless recovery. Recovery reuses + ``_handle_polling_network_error`` — the same ladder PTB's own + ``error_callback`` feeds — so no new restart machinery is introduced. + + This also covers the harsher case where the updater has stopped + entirely (``running=False``) with no reconnect in flight: the long-poll + task is gone rather than wedged, so even ``get_webhook_info`` can't + report a queue against a live consumer. We detect the stopped updater + directly and feed the same ladder (#55769). + """ + if getattr(self, "_polling_teardown_started", False): + return + # Only meaningful in polling mode; in webhook mode Telegram pushes + # updates and holds no server-side queue. + if self._webhook_mode: + return + # A reconnect already in flight owns recovery — don't double-trigger, + # and don't misread its brief stop()->start_polling() window (where + # updater.running is transiently False) as a dead updater below. + 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): + # We are in polling mode with no reconnect in flight, yet PTB's + # updater has stopped entirely. This is distinct from the + # wedged-but-running consumer handled below: the long-poll task is + # gone, get_me()/get_webhook_info() on the general request path + # still succeed, so no error_callback or connectivity probe ever + # fires and the gateway silently stops receiving messages while the + # process stays alive (#55769). Escalate through the same reconnect + # ladder as a wedged consumer, debounced over two consecutive 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 getattr(self, "_polling_teardown_started", False): + 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): + # A failed probe is a connectivity symptom the get_me() path or the + # outer handler will catch; don't treat it as 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 getattr(self, "_polling_teardown_started", False): + 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 _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 response on the + dedicated getUpdates request. A general-path getMe success can classify + connectivity, but cannot heal polling health. Connectivity failures + enter the guarded recovery ladder; auth/validation errors do not churn. + """ + from plugins.platforms.telegram.adapter import _POLLING_PROGRESS_TIMEOUT, _redact_telegram_error_text + PROBE_TIMEOUT = 10 + if getattr(self, "_polling_teardown_started", False): + 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 getattr(self, "_polling_teardown_started", False): + 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 getattr(self, "_polling_teardown_started", False): + 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 getattr(self, "_polling_teardown_started", False): + 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 wraps ``getUpdates`` in ``network_retry_loop`` with + ``max_retries=-1`` (retry forever). When a ``TelegramError`` (including + a 409 ``Conflict``) fires, that loop calls our ``error_callback`` + *synchronously*, then sleeps and re-checks ``while is_running()`` before + polling again. Our ``error_callback`` only schedules an async recovery + task (``loop.create_task(...)``) and returns immediately, so PTB's loop + keeps polling while our handler concurrently runs + ``stop -> sleep -> start_polling``. The two polling sessions overlap and + Telegram returns a fresh 409 — a self-inflicted conflict loop on a + ~31s cadence. + + The loop is wired with ``is_running=lambda: updater.running`` and a + private ``stop_event`` (``do_action`` races that event and returns the + moment it is set). Setting that event *synchronously inside the + callback* — before it returns — makes PTB's loop exit on its own next + tick instead of racing our recovery. Our async handler then performs + the real ``await updater.stop()`` (idempotent) followed by + drain + ``start_polling()``, which builds a fresh ``stop_event`` so the + restart is not poisoned. + + Best-effort and defensive: PTB names the attribute differently across + versions (``_Updater__polling_task_stop_event`` via name-mangling), so + we probe for both spellings. If neither is found we do nothing and + fall back to the prior behaviour (async ``updater.stop()`` racing PTB) — + i.e. we never make things worse than before. + + We deliberately do NOT fall back to flipping ``updater._running``: + ``stop()`` raises ``RuntimeError`` when ``running`` is already False and + our recovery handler guards its ``stop()`` call on ``running``, so + clearing the flag here would skip the real teardown and leave PTB's + stop_event uncleared — poisoning the subsequent ``start_polling()``. + The stop_event lever leaves ``_running`` True, so the handler's + ``await updater.stop()`` still runs, drains the polling task, and clears + the event for a clean restart. + """ + updater = getattr(self._app, "updater", None) if self._app else None + if updater is None: + return + # Preferred (and only) lever: PTB's polling stop_event. Name-mangled on + # Updater, so probe both the mangled and unmangled spellings. + 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: + from plugins.platforms.telegram.adapter import _PollingLifecycleAbort, _UPDATER_STOP_TIMEOUT, _redact_telegram_error_text + if getattr(self, "_polling_teardown_started", False): + return + if self.has_fatal_error and self.fatal_error_code == "telegram_polling_conflict": + return + # Transient 409 Conflict errors arise when the previous gateway process + # has been killed (e.g. during `hermes update` or `--replace` handoffs) + # but its long-poll connection hasn't yet expired on Telegram's servers. + # Telegram holds open getUpdates sessions for up to ~30s after the + # client disconnects, so a new gateway starting immediately will receive + # a 409 until that server-side session expires. + # + # Strategy: stop the local updater, wait long enough for Telegram's + # server-side session to expire (RETRY_DELAY grows with each attempt), + # drain the connection pool, then restart polling. We attempt this + # MAX_CONFLICT_RETRIES times before declaring a fatal error. + # + # Crucially, a failed retry must NOT leave polling in an ambiguous + # state. If start_polling() raises, the updater is neither running + # nor fatal — messages are silently dropped. We schedule another + # retry attempt instead of returning silently, and only escalate to + # fatal after all retries are exhausted. + self._polling_conflict_count += 1 + + MAX_CONFLICT_RETRIES = 5 + # Delay grows with each attempt: 15s, 25s, 35s, 45s, 55s. + # Telegram server-side getUpdates sessions typically expire within + # 30s; the increasing back-off ensures we clear that 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 local updater cleanly before sleeping. If it's already + # stopped (e.g. PTB raised before updater.running was set) this is + # a no-op. Bounded with a timeout for the same reason as the + # network-error path: a CLOSE-WAIT socket can wedge stop() on epoll + # forever, which would stall the conflict-retry ladder. + try: + if self._app and self._app.updater and self._app.updater.running: + try: + await asyncio.wait_for(self._app.updater.stop(), timeout=_UPDATER_STOP_TIMEOUT) + except asyncio.TimeoutError: + logger.warning( + "[%s] updater.stop() timed out during conflict " + "retry (likely CLOSE-WAIT socket); continuing", + self.name, + ) + except Exception: + pass + + await asyncio.sleep(RETRY_DELAY) + if getattr(self, "_polling_teardown_started", False): + return + await self._drain_polling_connections() + if getattr(self, "_polling_teardown_started", False): + return + + # Capture a stable local reference: self._app can be reassigned to + # None by a concurrent disconnect() while we're suspended across + # the awaits above (same race #55992 fixed on the network path). + # Re-reading self._app after that point would raise + # AttributeError deep inside start_polling instead of failing fast + # here, where the except below reschedules or escalates to fatal. + 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 tells Telegram to terminate any + # other active getUpdates sessions for this bot token. The + # competing session is either a zombie from the previous + # gateway process (whose long-poll hasn't expired server-side + # yet) or our own previous retry's still-expiring session. + # Without this, each retry starts a new getUpdates session + # that immediately gets 409'd by the previous one, creating + # the very conflict we are trying to recover from (#75017). + self._polling_conflict_recovery_generation = expected_generation + try: + await self._start_polling_once( + app, + drop_pending_updates=True, + error_callback=self._polling_error_callback_ref, + ) + logger.info( + "[%s] Telegram polling restarted after conflict retry %d/%d; " + "health pending getUpdates progress", + self.name, self._polling_conflict_count, MAX_CONFLICT_RETRIES, + ) + return + except _PollingLifecycleAbort: + return + except Exception as retry_err: + if getattr(self, "_polling_teardown_started", False): + 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), + ) + # Schedule the next retry rather than returning silently. + # Returning here without either restarting polling or setting + # a fatal error leaves the adapter in a limbo state: the + # gateway process is alive and reports "connected" but + # no messages are received or sent. + if ( + self._polling_conflict_count < MAX_CONFLICT_RETRIES + and not getattr(self, "_polling_teardown_started", False) + ): + # We are inside a running coroutine, so the running loop is + # guaranteed to exist. asyncio.get_event_loop() is deprecated + # and raises "RuntimeError: There is no current event loop in + # thread 'MainThread'" on Python 3.10+ when invoked from a + # context without an attached loop (which can happen when PTB + # dispatches this error callback). Use get_running_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 getattr(self, "_polling_teardown_started", False): + return + + # Exhausted all retries — declare a fatal error so the gateway + # runner can surface this clearly 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 are the call that actually transitions to fatal. + # A concurrent retry task scheduled by an earlier conflict may already + # be suspended past the entry guard; once _set_fatal_error flips the + # flag, adding an await below (the bounded stop()) yields the loop and + # lets that task reach this branch too — double-notifying the fatal + # handler. 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 asyncio.wait_for(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. ``disconnect()`` + cancels the tracked polling-recovery task and the heartbeat task, so + retaining the current notifier in either field would cancel the fatal + callback before the runner can finish its reconnect or shutdown + decision. Release only the current owner from whichever field tracks + it; unrelated tasks remain under teardown control. + """ + 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 _start_webhook(self, *, is_reconnect: bool) -> bool: + """Start the webhook transport when ``TELEGRAM_WEBHOOK_URL`` is set. + + Extracted from ``TelegramAdapter.connect`` so the webhook-vs-polling + decision and its security checks live with the polling/updates + transport mixin. Returns True when webhook mode is active (callers + skip polling startup); False when polling mode should start instead. + """ + from plugins.platforms.telegram.adapter import Update + + webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").strip() + + if webhook_url: + # ── Webhook mode ───────────────────────────────────── + # Telegram pushes updates to our HTTP endpoint. This + # enables cloud platforms (Fly.io, Railway) to auto-wake + # suspended machines on inbound HTTP traffic. + # + # SECURITY: TELEGRAM_WEBHOOK_SECRET is REQUIRED. Without it, + # python-telegram-bot passes secret_token=None and the + # webhook endpoint accepts any HTTP POST — attackers can + # inject forged updates as if from Telegram. Refuse to + # start rather than silently run in fail-open mode. + # See GHSA-3vpc-7q5r-276h. + webhook_port = env_int("TELEGRAM_WEBHOOK_PORT", 8443) + # Bind host. Default "" → tornado bind_sockets opens one + # listening socket per address family (IPv4 + IPv6). The old + # hardcoded "0.0.0.0" bound IPv4 ONLY and was unreachable + # over IPv6-only private networks (e.g. Fly.io 6PN) — same + # bug as the LINE adapter (NS-603). Pin via + # TELEGRAM_WEBHOOK_HOST or platforms.telegram.extra.webhook_host. + webhook_host = ( + os.getenv("TELEGRAM_WEBHOOK_HOST", "").strip() + or str((self.config.extra or {}).get("webhook_host") or "").strip() + ) + # Profile-scoped read (adapter startup, Slack pattern + # #59739): a scoped read honors the profile's own secret; + # only an UNSCOPED read under multiplex (default-profile + # startup loop) falls back to the process env, which is that + # profile's own value. + 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 — Telegram does not hold a + # server-side getUpdates queue, so this flag is a no-op + # in practice. Mirror the polling path's reconnect + # semantics for consistency. + 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, + ) + + return bool(webhook_url) From e0275437a9cdfec4f4855eccc66f3e3e7bad3c44 Mon Sep 17 00:00:00 2001 From: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:28:38 -0500 Subject: [PATCH 05/10] test(telegram): retarget webhook-secret source pin across the polling mixin The webhook-start block (and its GHSA-3vpc-7q5r-276h secret guard) moved from TelegramAdapter.connect() into TelegramPollingMixin._start_webhook during the adapter god-file slice. The source-level pin now scans both adapter.py and telegram_polling.py so the invariant survives either layout, and the polling-branch check anchors on the new ``if not webhook_started:`` dispatch instead of the old else-branch. Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> --- tests/gateway/test_telegram_webhook_secret.py | 55 ++++++++++++------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/tests/gateway/test_telegram_webhook_secret.py b/tests/gateway/test_telegram_webhook_secret.py index 7a4160a9cffa7..af0dfa3f0c952 100644 --- a/tests/gateway/test_telegram_webhook_secret.py +++ b/tests/gateway/test_telegram_webhook_secret.py @@ -31,38 +31,55 @@ class TestTelegramWebhookSecretRequired: """ def _get_source(self) -> str: - path = Path(_repo) / "plugins" / "platforms" / "telegram" / "adapter.py" - return path.read_text(encoding="utf-8") + """Return adapter + polling-mixin sources concatenated. + + The webhook-start block (and its secret guard) moved into + ``telegram_polling.py`` as ``_start_webhook`` during the adapter + god-file slice; scanning both files keeps this pin valid across + either layout. + """ + repo = Path(_repo) + adapter = (repo / "plugins" / "platforms" / "telegram" / "adapter.py").read_text(encoding="utf-8") + polling = (repo / "plugins" / "platforms" / "telegram" / "telegram_polling.py").read_text(encoding="utf-8") + return adapter + "\n" + polling def test_webhook_branch_checks_secret(self): - """The webhook-mode branch of connect() must read - TELEGRAM_WEBHOOK_SECRET and refuse when empty.""" + """The webhook branch must read TELEGRAM_WEBHOOK_SECRET and refuse + when empty (GHSA-3vpc-7q5r-276h).""" src = self._get_source() # The guard must appear after TELEGRAM_WEBHOOK_URL is set assert re.search( r'TELEGRAM_WEBHOOK_SECRET.*?\.strip\(\)\s*\n\s*if not webhook_secret:', src, re.DOTALL, ), ( - "TelegramAdapter.connect() must strip TELEGRAM_WEBHOOK_SECRET " - "and raise when the secret is empty — see GHSA-3vpc-7q5r-276h" + "The webhook transport (_start_webhook) must strip " + "TELEGRAM_WEBHOOK_SECRET and raise when the secret is empty — " + "see GHSA-3vpc-7q5r-276h" ) def test_polling_branch_has_no_secret_guard(self): - """Polling mode (else-branch) must NOT require the webhook secret — - polling authenticates via the bot token, not a webhook secret.""" + """Polling mode must NOT require the webhook secret — polling + authenticates via the bot token, not a webhook secret.""" src = self._get_source() - # The guard should appear inside the `if webhook_url:` branch, - # not the `else:` polling branch. Rough check: the raise is - # followed (within ~60 lines) by an `else:` that starts the - # polling branch, and there's no secret-check in that polling - # branch. + # The guard must live inside the webhook-start block + # (_start_webhook's `if webhook_url:` branch), not in the polling + # branch that connect() falls into when webhook mode is off. webhook_block = re.search( - r'if webhook_url:\s*\n(.*?)\n else:\s*\n(.*?)\n', + r'if webhook_url:\s*\n(.*?)\n\s*return bool\(webhook_url\)', + src, re.DOTALL, + ) + assert webhook_block, ( + "telegram_polling.py _start_webhook() must gate webhook startup " + "on TELEGRAM_WEBHOOK_URL (see GHSA-3vpc-7q5r-276h)" + ) + webhook_body = webhook_block.group(1) + assert "TELEGRAM_WEBHOOK_SECRET" in webhook_body + # The polling branch in connect() (after the _start_webhook dispatch) + # must not contain the secret guard. + polling_branch = re.search( + r'if not webhook_started:\s*\n(.*?)\n\s*self\._mark_connected\(\)', src, re.DOTALL, ) - if webhook_block: - webhook_body = webhook_block.group(1) - polling_body = webhook_block.group(2) - assert "TELEGRAM_WEBHOOK_SECRET" in webhook_body - assert "TELEGRAM_WEBHOOK_SECRET" not in polling_body + if polling_branch: + assert "TELEGRAM_WEBHOOK_SECRET" not in polling_branch.group(1) From 6012fd5e5d2b52cca3616d45492461c57ed09837 Mon Sep 17 00:00:00 2001 From: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:36:25 -0500 Subject: [PATCH 06/10] fix(telegram): shim cache_image_from_bytes through the adapter namespace The inbound mixin called the media cache via its own module global; the gateway test suite monkeypatches plugins.platforms.telegram.adapter. cache_image_from_bytes (media-group batching), so the patch missed and the real guard rejected the fake payloads, breaking photo-burst buffering. Function-local lazy imports route resolution through the adapter module, matching the landed slices' shim pattern. Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> --- plugins/platforms/telegram/telegram_inbound.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/platforms/telegram/telegram_inbound.py b/plugins/platforms/telegram/telegram_inbound.py index 5696283e4cba6..82e66fa22910e 100644 --- a/plugins/platforms/telegram/telegram_inbound.py +++ b/plugins/platforms/telegram/telegram_inbound.py @@ -697,6 +697,10 @@ def _enqueue_photo_event(self, batch_key: str, event: MessageEvent) -> None: async def _handle_media_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming media messages, downloading images to local cache.""" + # Shim: resolve through the adapter module so tests (and runtime + # rebinding) patch one namespace. cache_image_from_bytes is + # monkeypatched by the gateway test suite (media-group batching). + from plugins.platforms.telegram.adapter import cache_image_from_bytes if not update.message: return if not self._is_user_authorized_from_message(update.message): @@ -1058,6 +1062,9 @@ async def _handle_sticker(self, msg: Message, event: "MessageEvent") -> None: build_animated_sticker_injection, STICKER_VISION_PROMPT, ) + # Shim: resolve through the adapter module so tests (and runtime + # rebinding) patch one namespace (media-cache monkeypatch surface). + from plugins.platforms.telegram.adapter import cache_image_from_bytes sticker = msg.sticker emoji = sticker.emoji or "" From a8890d948621159e505ac057ba7f2b7653ce258f Mon Sep 17 00:00:00 2001 From: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:19:41 -0500 Subject: [PATCH 07/10] refactor(telegram): extract DM-topic machinery into TelegramDmTopicMixin (adapter god-file slice A1) Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> --- plugins/platforms/telegram/adapter.py | 663 +--------------- .../platforms/telegram/telegram_dm_topics.py | 725 ++++++++++++++++++ tests/gateway/test_dm_topics.py | 76 ++ 3 files changed, 803 insertions(+), 661 deletions(-) create mode 100644 plugins/platforms/telegram/telegram_dm_topics.py diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 1a14647612f51..2419b2d48061c 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -296,6 +296,7 @@ class _MockContextTypes: ) from plugins.platforms.telegram.telegram_rich import TelegramRichMixin from plugins.platforms.telegram.telegram_polling import TelegramPollingMixin +from plugins.platforms.telegram.telegram_dm_topics import TelegramDmTopicMixin from utils import atomic_replace, env_float, env_int from plugins.platforms.telegram.telegram_inbound import ( @@ -547,7 +548,7 @@ class _PollingLifecycleAbort(RuntimeError): """Internal control flow for polling startup fenced by teardown.""" -class TelegramAdapter(TelegramPollingMixin, TelegramIngestMixin, TelegramTextDeliveryMixin, TelegramRichMixin, BasePlatformAdapter): +class TelegramAdapter(TelegramPollingMixin, TelegramIngestMixin, TelegramTextDeliveryMixin, TelegramRichMixin, TelegramDmTopicMixin, BasePlatformAdapter): """ Telegram bot adapter. @@ -1046,265 +1047,6 @@ def _is_user_authorized_from_message(self, message: Message) -> bool: # Unauthorized DM that 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]: - """Return Telegram send kwargs for forum and direct-message topic routing. - - Supergroup/forum topics use ``message_thread_id``. True Bot API Direct - Messages topics can opt in with explicit ``direct_messages_topic_id`` - metadata. Hermes-created private-chat topic lanes are marked with - ``telegram_dm_topic_reply_fallback``. Live replies send the private - topic thread id together with a reply anchor; synthetic/resumed sends - without an anchor use ``direct_messages_topic_id`` when metadata has it. - ``message_thread_id`` alone can render outside the visible lane. - - When ``reply_to_mode`` is ``"off"``, the reply anchor is suppressed for - DM topic fallback sends while preserving the ``message_thread_id`` so - the message still lands in the correct topic. - """ - 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: - 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)} - - @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]: - # Asymmetric with _message_thread_id_for_send on purpose. Telegram's - # sendMessage and sendChatAction treat thread id "1" (the forum General - # topic) differently: sends reject message_thread_id=1 and must omit it, - # but sendChatAction needs message_thread_id=1 to place the typing - # bubble in the General topic (omitting it hides the bubble entirely - # from the client's view of that topic). Preserve the real id here — - # sends still map "1" → None via _message_thread_id_for_send. - 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, - ) -> None: - """Drop the stale ``telegram_dm_topic_bindings`` row for a - topic Telegram has confirmed deleted. - - Without this prune the recovery logic in - ``gateway.run._recover_telegram_topic_thread_id`` keeps - steering future inbound messages to the dead thread (the - bug behind #31501 — tool progress, approvals, replies all - end up in the wrong place even though the user has moved - on to a fresh topic). Best-effort: we never raise from a - send-fallback path — a failed cleanup must not turn into a - failed user-facing send. - """ - 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: - removed = db.delete_telegram_topic_binding( - chat_id=str(chat_id), thread_id=str(thread_id), - ) - 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. - - Two cases trigger the retry: - - 1. The original anchor-stale case — the reply target was deleted, so - Bot API returns "message to be replied not found". The retry drops - the reply anchor and the topic id together. - - 2. The synthetic-event case (added when #27937 introduced - ``direct_messages_topic_id`` fallback for sends without an anchor): - if Bot API rejects the topic id itself with any BadRequest that - mentions topic/thread routing, we retry without routing rather - than dropping the message. - """ - if not (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 - # Synthetic / resumed sends route via ``direct_messages_topic_id`` - # instead of a reply anchor. If Telegram rejects the topic id, fall - # back to a plain DM send. - if metadata.get("direct_messages_topic_id"): - 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 [] @@ -1444,310 +1186,6 @@ def _coerce_float_extra( return parsed - - 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. - - Uses Bot API 9.4's createForumTopic which now works for 1-on-1 chats. - Returns the message_thread_id on success, 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() - # If topic already exists, try to find it via getForumTopicIconStickers - # or we just log and skip — Telegram doesn't provide a "list topics" API - 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. - - Works for DM topics (Bot API 9.4+, requires user to enable Topics - in their chat with the bot) and 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 persists across 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 - - import yaml as _yaml - with open(config_path, "r", encoding="utf-8") as f: - config = _yaml.safe_load(f) or {} - - # Navigate to platforms.telegram.extra.dm_topics, creating the path - # when a named delivery target asks us to create a topic that was - # 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"): - if 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: - from hermes_cli.config import atomic_config_write - - 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. - - Reads config.extra['dm_topics'] — a list of dicts: - [ - { - "chat_id": 123456789, - "topics": [ - {"name": "General", "icon_color": 7322096, "thread_id": 100}, - {"name": "Accessibility Auditor", "icon_color": 9367192, "skill": "accessibility-auditor"} - ] - } - ] - - If a topic already has a thread_id in the config (persisted from a previous - creation), it is loaded into the cache without calling createForumTopic. - Only topics without a thread_id are created via the API, and their thread_id - is then saved back to config.yaml for future restarts. - """ - 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}" - - # If thread_id is already persisted in config, just load into cache - 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 - - # No persisted thread_id — create the topic via API - 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, - ) - # Persist thread_id to config so we don't recreate on next restart - self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id) - - # Send a seed message so the topic is visible in Telegram's client. - # Empty topics are hidden by the client UI until they contain a message. - 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. @@ -5629,103 +5067,6 @@ async def _ensure_forum_commands(self, message) -> None: except Exception as e: logger.warning("[%s] Forum command lazy-registration failed: %s", self.name, _redact_telegram_error_text(e)) - def _reload_dm_topics_from_config(self) -> None: - """Re-read dm_topics from config.yaml and load any new thread_ids into cache. - - This allows topics created externally (e.g. by the agent via API) to be - recognized without a gateway restart. - """ - try: - # Canonical loader: behavioral read (dm_topics routing) now honors - # managed-scope overlay + ${VAR} expansion like every other read. - 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: - # Clear both config and precomputed set when all topics are removed - self._dm_topics_config = [] - self._dm_topic_chat_ids = set() - return - - # Update in-memory config and cache any new thread_ids - self._dm_topics_config = dm_topics - # Rebuild the chat_id set for 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]]: - """Look up DM topic config by chat_id and thread_id. - - Returns the topic config dict (name, skill, etc.) if this thread_id - matches a known DM topic, or None. - """ - if not thread_id: - return None - - thread_id_int = int(thread_id) - - # Check cached topics first (created by us or loaded at startup) - 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] - # Find the full config for this topic - 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} - - # Not in cache — hot-reload config in case topics were added externally - self._reload_dm_topics_from_config() - - # Check cache again after reload - 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 - - 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, - ) - # ── Message reactions (processing lifecycle) ────────────────────────── def _reactions_enabled(self) -> bool: diff --git a/plugins/platforms/telegram/telegram_dm_topics.py b/plugins/platforms/telegram/telegram_dm_topics.py new file mode 100644 index 0000000000000..0413b4b355901 --- /dev/null +++ b/plugins/platforms/telegram/telegram_dm_topics.py @@ -0,0 +1,725 @@ +"""DM-topic machinery mixin for the Telegram adapter (adapter god-file slice A1). + +Extracted from ``plugins/platforms/telegram/adapter.py``: the private-chat +DM-topic machinery — metadata/thread-id helpers (``_metadata_*``), +DM-topic send routing with reply-anchor fallback and retry +(``_thread_kwargs_for_send`` / ``_send_with_dm_topic_reply_anchor_retry``), +topic creation and persistence (``_create_dm_topic``, ``ensure_dm_topic``, +``rename_dm_topic``, ``_persist_dm_topic_thread_id``, ``_setup_dm_topics``), +stale-binding pruning, and the config reload/cache trio +(``_reload_dm_topics_from_config`` / ``_get_dm_topic_info`` / +``_cache_dm_topic_from_message``). ``TelegramAdapter`` imports +``TelegramDmTopicMixin`` back and inherits from it (the mixin pattern proven +by the gateway authorization/topic mixins and the wave-1 telegram slices). + +Adapter-local module globals the moved methods read at call time (error +redaction) stay on the adapter and are imported lazily inside each method +body, so this module never imports the adapter at import time -> no import +cycle, and monkeypatches of ``adapter.`` keep working. Shared class and +instance state (``_dm_topics`` / ``_dm_topics_config`` / ``_dm_topic_chat_ids`` +/ ``_GENERAL_TOPIC_THREAD_ID``) stays on the adapter class and resolves via +``self``/``cls`` (MRO). +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +from plugins.platforms.telegram.telegram_ids import normalize_telegram_chat_id + +# Keep log records under the adapter's logger name so operator log filters +# and caplog assertions keyed on the adapter keep working after the slice. +logger = logging.getLogger("plugins.platforms.telegram.adapter") + + +class TelegramDmTopicMixin: + """DM-topic routing/creation/persistence methods for TelegramAdapter.""" + + + + @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]: + """Return Telegram send kwargs for forum and direct-message topic routing. + + Supergroup/forum topics use ``message_thread_id``. True Bot API Direct + Messages topics can opt in with explicit ``direct_messages_topic_id`` + metadata. Hermes-created private-chat topic lanes are marked with + ``telegram_dm_topic_reply_fallback``. Live replies send the private + topic thread id together with a reply anchor; synthetic/resumed sends + without an anchor use ``direct_messages_topic_id`` when metadata has it. + ``message_thread_id`` alone can render outside the visible lane. + + When ``reply_to_mode`` is ``"off"``, the reply anchor is suppressed for + DM topic fallback sends while preserving the ``message_thread_id`` so + the message still lands in the correct topic. + """ + 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: + 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)} + + + @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]: + # Asymmetric with _message_thread_id_for_send on purpose. Telegram's + # sendMessage and sendChatAction treat thread id "1" (the forum General + # topic) differently: sends reject message_thread_id=1 and must omit it, + # but sendChatAction needs message_thread_id=1 to place the typing + # bubble in the General topic (omitting it hides the bubble entirely + # from the client's view of that topic). Preserve the real id here — + # sends still map "1" → None via _message_thread_id_for_send. + 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, + ) -> None: + """Drop the stale ``telegram_dm_topic_bindings`` row for a + topic Telegram has confirmed deleted. + + Without this prune the recovery logic in + ``gateway.run._recover_telegram_topic_thread_id`` keeps + steering future inbound messages to the dead thread (the + bug behind #31501 — tool progress, approvals, replies all + end up in the wrong place even though the user has moved + on to a fresh topic). Best-effort: we never raise from a + send-fallback path — a failed cleanup must not turn into a + failed user-facing send. + """ + 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: + removed = db.delete_telegram_topic_binding( + chat_id=str(chat_id), thread_id=str(thread_id), + ) + 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. + + Two cases trigger the retry: + + 1. The original anchor-stale case — the reply target was deleted, so + Bot API returns "message to be replied not found". The retry drops + the reply anchor and the topic id together. + + 2. The synthetic-event case (added when #27937 introduced + ``direct_messages_topic_id`` fallback for sends without an anchor): + if Bot API rejects the topic id itself with any BadRequest that + mentions topic/thread routing, we retry without routing rather + than dropping the message. + """ + if not (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 + # Synthetic / resumed sends route via ``direct_messages_topic_id`` + # instead of a reply anchor. If Telegram rejects the topic id, fall + # back to a plain DM send. + if metadata.get("direct_messages_topic_id"): + 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.""" + from plugins.platforms.telegram.adapter import _redact_telegram_error_text + + 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) + + + 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. + + Uses Bot API 9.4's createForumTopic which now works for 1-on-1 chats. + Returns the message_thread_id on success, None on failure. + """ + from plugins.platforms.telegram.adapter import _redact_telegram_error_text + + 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() + # If topic already exists, try to find it via getForumTopicIconStickers + # or we just log and skip — Telegram doesn't provide a "list topics" API + 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. + + Works for DM topics (Bot API 9.4+, requires user to enable Topics + in their chat with the bot) and 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 persists across 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 + + import yaml as _yaml + with open(config_path, "r", encoding="utf-8") as f: + config = _yaml.safe_load(f) or {} + + # Navigate to platforms.telegram.extra.dm_topics, creating the path + # when a named delivery target asks us to create a topic that was + # 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"): + if 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: + from hermes_cli.config import atomic_config_write + + 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. + + Reads config.extra['dm_topics'] — a list of dicts: + [ + { + "chat_id": 123456789, + "topics": [ + {"name": "General", "icon_color": 7322096, "thread_id": 100}, + {"name": "Accessibility Auditor", "icon_color": 9367192, "skill": "accessibility-auditor"} + ] + } + ] + + If a topic already has a thread_id in the config (persisted from a previous + creation), it is loaded into the cache without calling createForumTopic. + Only topics without a thread_id are created via the API, and their thread_id + is then saved back to config.yaml for future restarts. + """ + 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}" + + # If thread_id is already persisted in config, just load into cache + 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 + + # No persisted thread_id — create the topic via API + 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, + ) + # Persist thread_id to config so we don't recreate on next restart + self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id) + + # Send a seed message so the topic is visible in Telegram's client. + # Empty topics are hidden by the client UI until they contain a message. + 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, + ) + + + def _reload_dm_topics_from_config(self) -> None: + """Re-read dm_topics from config.yaml and load any new thread_ids into cache. + + This allows topics created externally (e.g. by the agent via API) to be + recognized without a gateway restart. + """ + try: + # Canonical loader: behavioral read (dm_topics routing) now honors + # managed-scope overlay + ${VAR} expansion like every other read. + 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: + # Clear both config and precomputed set when all topics are removed + self._dm_topics_config = [] + self._dm_topic_chat_ids = set() + return + + # Update in-memory config and cache any new thread_ids + self._dm_topics_config = dm_topics + # Rebuild the chat_id set for 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]]: + """Look up DM topic config by chat_id and thread_id. + + Returns the topic config dict (name, skill, etc.) if this thread_id + matches a known DM topic, or None. + """ + if not thread_id: + return None + + thread_id_int = int(thread_id) + + # Check cached topics first (created by us or loaded at startup) + 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] + # Find the full config for this topic + 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} + + # Not in cache — hot-reload config in case topics were added externally + self._reload_dm_topics_from_config() + + # Check cache again after reload + 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 + + + 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, + ) + diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index f889f95c66b01..4817513932ac1 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -40,6 +40,8 @@ sys.modules.pop("plugins.platforms.telegram.adapter", None) from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402 +from plugins.platforms.telegram.telegram_dm_topics import TelegramDmTopicMixin # noqa: E402 +from gateway.platforms.base import BasePlatformAdapter # noqa: E402 def _make_adapter(dm_topics_config=None, group_topics_config=None): @@ -478,3 +480,77 @@ def test_group_topic_skill_binding_second_topic(): # ── _build_message_event: from_user=None fallback in DMs ── +# ── TelegramDmTopicMixin seam identity (adapter god-file slice A1) ── + + +# Every DM-topic method extracted into TelegramDmTopicMixin. If a future +# slice moves these names again, update the list alongside the extraction. +_DM_TOPIC_MIXIN_METHODS = [ + "_metadata_thread_id", + "_metadata_direct_messages_topic_id", + "_metadata_reply_to_message_id", + "_is_private_dm_topic_send", + "_dm_topic_missing_anchor_error", + "_reply_to_message_id_for_send", + "_thread_kwargs_for_send", + "_message_thread_id_for_send", + "_message_thread_id_for_typing", + "_is_thread_not_found_error", + "_prune_stale_dm_topic_binding", + "_is_bad_request_error", + "_should_retry_without_dm_topic_reply_anchor", + "_send_with_dm_topic_reply_anchor_retry", + "_create_dm_topic", + "create_handoff_thread", + "ensure_dm_topic", + "rename_dm_topic", + "_persist_dm_topic_thread_id", + "_setup_dm_topics", + "_reload_dm_topics_from_config", + "_get_dm_topic_info", + "_cache_dm_topic_from_message", +] + + +def _underlying(cls, name): + """Resolve the underlying function object for a class attribute. + + ``getattr(Class, name)`` on a classmethod yields a fresh bound-method + wrapper per class, so identity must be compared on ``__func__``. + """ + attr = getattr(cls, name) + return getattr(attr, "__func__", attr) + + +def test_dm_topic_mixin_seam_identity(): + """The DM-topic slice must not change any function object. + + ``TelegramAdapter`` inherits ``TelegramDmTopicMixin``; every extracted + name must resolve through the adapter to the exact same function object + the mixin defines (classmethods unwrapped via ``__func__``). This pins + name resolution for tests and monkeypatches that target the adapter + namespace, and keeps the mixin ahead of ``BasePlatformAdapter`` in the + MRO so overrides like ``create_handoff_thread`` keep winning. + """ + assert TelegramDmTopicMixin in TelegramAdapter.__mro__ + assert TelegramAdapter.__mro__.index(TelegramDmTopicMixin) < TelegramAdapter.__mro__.index( + BasePlatformAdapter + ) + + for name in _DM_TOPIC_MIXIN_METHODS: + mixin_attr = _underlying(TelegramDmTopicMixin, name) + adapter_attr = _underlying(TelegramAdapter, name) + assert adapter_attr is mixin_attr, f"seam broken for {name}: adapter resolves a different object" + assert name not in TelegramAdapter.__dict__, f"adapter shadows {name} in its own __dict__" + + # Behavior through the adapter namespace still resolves and executes. + assert TelegramAdapter._message_thread_id_for_send("1") is None + assert TelegramAdapter._message_thread_id_for_send("7") == 7 + assert TelegramAdapter._thread_kwargs_for_send( + "111", "7", {"telegram_dm_topic_reply_fallback": True}, reply_to_message_id=42 + ) == {"message_thread_id": 7} + assert TelegramAdapter._dm_topic_missing_anchor_error().startswith( + "Telegram DM topic delivery requires a reply anchor" + ) + + From 472523ec9d0388f324e9b3e482caa691f4c286ff Mon Sep 17 00:00:00 2001 From: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:37:25 -0500 Subject: [PATCH 08/10] test(telegram): explicit utf-8 on config round-trips in dm-topics fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bare open() calls in test_dm_topics.py violate the blocking windows-footguns gate (platform-default encoding on Windows is cp1252). Explicit encoding="utf-8" on both write and read sides of the same round-trip — zero behavior change for the fixtures, closes the live gate violations in a file this PR already touches. Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> --- tests/gateway/test_dm_topics.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index 4817513932ac1..a10a5ca8671b4 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -194,7 +194,7 @@ def test_persist_dm_topic_thread_id_writes_config(tmp_path): config_file = tmp_path / ".hermes" / "config.yaml" config_file.parent.mkdir(parents=True) - with open(config_file, "w") as f: + with open(config_file, "w", encoding="utf-8") as f: yaml.dump(config_data, f) adapter = _make_adapter() @@ -203,7 +203,7 @@ def test_persist_dm_topic_thread_id_writes_config(tmp_path): patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}): adapter._persist_dm_topic_thread_id(111, "General", 999) - with open(config_file) as f: + with open(config_file, encoding="utf-8") as f: result = yaml.safe_load(f) topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"] @@ -303,7 +303,7 @@ def test_get_dm_topic_info_hot_reloads_from_config(tmp_path): } config_file = tmp_path / ".hermes" / "config.yaml" config_file.parent.mkdir(parents=True) - with open(config_file, "w") as f: + with open(config_file, "w", encoding="utf-8") as f: yaml.dump(config_data, f) with patch.object(Path, "home", return_value=tmp_path), \ From 7279039c60a45c1dc3bf280d3485018a14d2a561 Mon Sep 17 00:00:00 2001 From: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:43:42 -0500 Subject: [PATCH 09/10] fix(telegram): strip trailing blank line at EOF in telegram_dm_topics.py Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> --- plugins/platforms/telegram/telegram_dm_topics.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/platforms/telegram/telegram_dm_topics.py b/plugins/platforms/telegram/telegram_dm_topics.py index 0413b4b355901..844b05eb78727 100644 --- a/plugins/platforms/telegram/telegram_dm_topics.py +++ b/plugins/platforms/telegram/telegram_dm_topics.py @@ -722,4 +722,3 @@ def _cache_dm_topic_from_message(self, chat_id: str, thread_id: str, topic_name: "[%s] Cached DM topic from message: %s -> thread_id=%s", self.name, cache_key, thread_id, ) - From 8207af5ec68fb998cf5b03ded4114153d92acf6d Mon Sep 17 00:00:00 2001 From: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:49:20 -0500 Subject: [PATCH 10/10] style(telegram): drop stray blank lines after mixin docstring Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> --- plugins/platforms/telegram/telegram_dm_topics.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/platforms/telegram/telegram_dm_topics.py b/plugins/platforms/telegram/telegram_dm_topics.py index 844b05eb78727..cc56f228be644 100644 --- a/plugins/platforms/telegram/telegram_dm_topics.py +++ b/plugins/platforms/telegram/telegram_dm_topics.py @@ -36,8 +36,6 @@ class TelegramDmTopicMixin: """DM-topic routing/creation/persistence methods for TelegramAdapter.""" - - @classmethod def _metadata_thread_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str]: if not metadata: