diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 55ae362bfe0..b7e270a9f29 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -753,6 +753,14 @@ class TelegramAdapter(BasePlatformAdapter): 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 + # it inside get_me(), so a BotFather rename leaves self._bot.username + # pointing at the old handle until something calls getMe again. Every + # mention/routing comparison reads _current_bot_username() instead. + self._bot_username_observed: Optional[str] = None + self._bot_identity_checked_at: float = 0.0 + self._bot_identity_refresh_task: Optional[asyncio.Task] = None # Consecutive heartbeat probes that saw queued updates the running # poller is not consuming. get_me() can't see this — the send path is # healthy while the getUpdates consumer is wedged — so the heartbeat @@ -2674,6 +2682,11 @@ class TelegramAdapter(BasePlatformAdapter): 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, @@ -3441,6 +3454,30 @@ class TelegramAdapter(BasePlatformAdapter): self.name, topic_name, seed_err, ) + async def _bot_identity_refresh_loop(self) -> None: + """Keep the cached @username fresh when no heartbeat is running. + + Polling mode re-reads identity via the heartbeat's ``get_me()`` probe. + Webhook mode has no such probe — nothing calls ``get_me()`` again after + ``initialize()`` — so without this loop a BotFather rename breaks + mention routing until the gateway restarts. + """ + while True: + try: + await asyncio.sleep(self._BOT_IDENTITY_TTL_SECONDS) + if getattr(self, "_polling_teardown_started", False): + return + if self.has_fatal_error: + return + await self._refresh_bot_identity(force=True) + except asyncio.CancelledError: + return + except Exception: + logger.debug( + "[%s] Telegram identity refresh loop iteration failed", + self.name, exc_info=True, + ) + def _start_post_connect_housekeeping(self) -> None: """Kick off deferred post-connect housekeeping in the background. @@ -4009,6 +4046,21 @@ class TelegramAdapter(BasePlatformAdapter): self._polling_heartbeat_loop() ) + # Seed the live identity from whatever PTB cached during + # initialize(), then keep it fresh. Polling mode rides the + # heartbeat's get_me() probe; webhook mode has no probe at all, so + # it gets a dedicated low-frequency refresh loop — otherwise a + # BotFather rename breaks mention routing until restart. + self._note_bot_username(getattr(self._bot, "username", None)) + self._bot_identity_checked_at = time.monotonic() + if self._webhook_mode: + identity_task = getattr(self, "_bot_identity_refresh_task", None) + if identity_task and not identity_task.done(): + identity_task.cancel() + self._bot_identity_refresh_task = asyncio.ensure_future( + self._bot_identity_refresh_loop() + ) + # Command-menu registration, DM-topic setup, and the status # indicator each make Bot API calls that can stall for certain # tokens. Running them here — inside the connect() coroutine that @@ -4172,6 +4224,17 @@ class TelegramAdapter(BasePlatformAdapter): pass self._polling_heartbeat_task = None + # Cancel the webhook-mode identity refresh loop on the same fence as + # the heartbeat so it cannot fire get_me() into a torn-down client. + identity_task = getattr(self, "_bot_identity_refresh_task", None) + if identity_task and not identity_task.done(): + identity_task.cancel() + try: + await identity_task + except asyncio.CancelledError: + pass + self._bot_identity_refresh_task = None + # Mark the bot "Offline" in its short description while the bot's HTTP # client is still alive (before app shutdown closes it). Opt-in via # extra.status_indicator. Non-fatal. This is the clean-shutdown path; @@ -7746,22 +7809,128 @@ class TelegramAdapter(BasePlatformAdapter): return cls._GENERAL_TOPIC_THREAD_ID return None + # Telegram bot handles historically had to end in "bot", but collectible + # (Fragment) usernames can be assigned to bots and drop that suffix + # entirely (@jarvis, @pic, ...). This pattern is used ONLY to decide + # whether some FOREIGN @handle in a message is bot-shaped; our own handle + # is matched by identity, never by shape. + _FOREIGN_BOT_HANDLE_RE = re.compile(r"[a-z0-9_]{2,29}bot", re.IGNORECASE) + # How long an observed identity is trusted before the heartbeat re-checks. + _BOT_IDENTITY_TTL_SECONDS = 300.0 + + def _current_bot_username(self) -> str: + """Return this bot's live @username (lowercased, no leading ``@``). + + Prefers the most recently observed handle over PTB's ``get_me()`` + cache. ``Bot.username`` reads ``Bot._bot_user``, which is written only + by ``get_me()`` — after a BotFather rename it keeps returning the old + handle, so every mention comparison silently stops matching and the + exclusive-mention gate concludes the message is addressed to a + different bot. Observing the handle from inbound updates closes that + window without an extra Bot API round-trip. + """ + observed = getattr(self, "_bot_username_observed", None) + if observed: + return observed + return (getattr(self._bot, "username", None) or "").lstrip("@").lower() + + def _note_bot_username(self, username: Optional[str]) -> None: + """Record the bot's current @username, logging real renames.""" + handle = (username or "").lstrip("@").lower() + if not handle: + return + previous = getattr(self, "_bot_username_observed", None) + if previous == handle: + return + self._bot_username_observed = handle + self._bot_identity_checked_at = time.monotonic() + if previous: + logger.info( + "[%s] Telegram bot username changed: @%s -> @%s " + "(mention routing now follows the new handle)", + self.name, previous, handle, + ) + + def _observe_bot_identity_from_message(self, message: Message) -> None: + """Learn our own handle from a message Telegram says we authored. + + Telegram stamps the *current* username on the bot's own outgoing + messages and on ``reply_to_message`` when a user replies to us, so a + rename is observable from the update stream itself — no getMe needed. + Only trusted when the user id matches this bot, so another account's + handle can never be adopted as our own. + """ + bot_id = getattr(self._bot, "id", None) + if bot_id is None: + return + for candidate in ( + getattr(message, "from_user", None), + getattr(getattr(message, "reply_to_message", None), "from_user", None), + ): + if candidate is None: + continue + if getattr(candidate, "id", None) != bot_id: + continue + self._note_bot_username(getattr(candidate, "username", None)) + + async def _refresh_bot_identity(self, *, force: bool = False) -> None: + """Re-read the bot's identity from Telegram when the cache may be stale. + + ``get_me()`` rewrites PTB's ``Bot._bot_user`` in place, so this also + repairs every other consumer of ``self._bot.username``. Best-effort: + a failed probe leaves the last known handle in place. + """ + bot = self._bot + if bot is None or not callable(getattr(bot, "get_me", None)): + return + now = time.monotonic() + if not force and (now - getattr(self, "_bot_identity_checked_at", 0.0)) < self._BOT_IDENTITY_TTL_SECONDS: + return + try: + me = await asyncio.wait_for(bot.get_me(), self._BOT_IDENTITY_PROBE_TIMEOUT) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.debug( + "[%s] Telegram identity refresh failed (keeping @%s): %s", + self.name, self._current_bot_username() or "unknown", exc, + ) + return + self._bot_identity_checked_at = time.monotonic() + self._note_bot_username(getattr(me, "username", None)) + + _BOT_IDENTITY_PROBE_TIMEOUT = 15.0 + def _is_reply_to_bot(self, message: Message) -> bool: if not self._bot or not getattr(message, "reply_to_message", None): return False reply_user = getattr(message.reply_to_message, "from_user", None) return bool(reply_user and getattr(reply_user, "id", None) == getattr(self._bot, "id", None)) - @staticmethod - def _extract_bot_mention_usernames(message: Message) -> set[str]: + @classmethod + def _extract_bot_mention_usernames(cls, message: Message, self_username: str = "") -> set[str]: """Extract explicit Telegram bot usernames mentioned in text/captions. - Telegram bot usernames are 5-32 characters and must end in "bot". + Foreign handles are only treated as bot mentions when they look + bot-shaped (``...bot``), which keeps human ``@handles`` from acting as + routing hints. ``self_username`` opts our OWN handle into the same set + regardless of shape: collectible (Fragment) usernames can be assigned + to bots and need not end in "bot" (@jarvis, @pic), and a bot addressed + by such a handle must still recognise itself. + Entity mentions are authoritative. The raw-text fallback is intentionally narrow so entity-less mobile/client variants still work without treating email addresses or arbitrary substrings as bot mentions. """ mentioned_bot_usernames: set[str] = set() + own = (self_username or "").lstrip("@").lower() + + def _is_bot_handle(handle: str) -> bool: + if not handle: + return False + if own and handle == own: + return True + return bool(cls._FOREIGN_BOT_HANDLE_RE.fullmatch(handle)) def _iter_sources(): yield getattr(message, "text", None) or "", getattr(message, "entities", None) or [] @@ -7780,7 +7949,7 @@ class TelegramAdapter(BasePlatformAdapter): entity_text = source_text[offset:offset + length].strip() if entity_type == "mention": handle = entity_text.lstrip("@").lower() - if re.fullmatch(r"[a-z0-9_]{2,29}bot", handle, re.IGNORECASE): + if _is_bot_handle(handle): mentioned_bot_usernames.add(handle) continue @@ -7792,7 +7961,7 @@ class TelegramAdapter(BasePlatformAdapter): if at_index < 0: continue command_target = entity_text[at_index + 1:].strip().lower() - if re.fullmatch(r"[a-z0-9_]{2,29}bot", command_target, re.IGNORECASE): + if _is_bot_handle(command_target): mentioned_bot_usernames.add(command_target) # Entity-less fallback for older/client-specific updates. If Telegram @@ -7801,8 +7970,10 @@ class TelegramAdapter(BasePlatformAdapter): for raw_text, entities in _iter_sources(): if not raw_text or entities: continue - for match in re.finditer(r"(?i)(? None: + """Fire a TTL-guarded identity refresh in the background. + + Called when routing is about to discard a message because the bot + handles it names don't include ours — the exact symptom of a stale + username after a BotFather rename. The TTL in + ``_refresh_bot_identity`` bounds this to one getMe per + ``_BOT_IDENTITY_TTL_SECONDS``, so a busy group that legitimately + addresses other bots cannot turn this into per-message API traffic. + Fire-and-forget: the current message still routes on what we know now. + """ + existing = getattr(self, "_bot_identity_refresh_task", None) + if existing is not None and not existing.done(): + return + if (time.monotonic() - getattr(self, "_bot_identity_checked_at", 0.0)) < self._BOT_IDENTITY_TTL_SECONDS: + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + task = loop.create_task(self._refresh_bot_identity()) + self._bot_identity_refresh_task = task + tracked = getattr(self, "_background_tasks", None) + if isinstance(tracked, set): + tracked.add(task) + task.add_done_callback(tracked.discard) + def _explicit_bot_mentions_exclude_self(self, message: Message) -> bool: """Return True when explicit bot handles target other bots, not this one. @@ -7872,19 +8070,27 @@ class TelegramAdapter(BasePlatformAdapter): adapter's own bot username, this adapter should ignore the message. MessageEntity values are preferred, but some Telegram clients expose - selected bot handles as plain text in group messages. The raw-text - fallback is intentionally limited to usernames ending in "bot", which - Telegram requires for bot accounts. + selected bot handles as plain text in group messages. Foreign handles + are limited to the ``...bot`` shape so human @handles never suppress + this bot; our own handle is matched by identity, so a collectible + username without that suffix still counts as addressing us. """ if not self._bot: return False - bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower() + bot_username = self._current_bot_username() if not bot_username: return False - mentioned_bot_usernames = self._extract_bot_mention_usernames(message) - return bool(mentioned_bot_usernames) and bot_username not in mentioned_bot_usernames + mentioned_bot_usernames = self._extract_bot_mention_usernames(message, bot_username) + excludes_self = bool(mentioned_bot_usernames) and bot_username not in mentioned_bot_usernames + if excludes_self: + # Either the message really is for another bot, or our cached + # handle is stale after a rename and we are about to ignore a + # message addressed to us. Re-check identity out of band (TTL + # bounded) so the mistake self-corrects instead of persisting. + self._schedule_bot_identity_recheck() + return excludes_self def _message_matches_mention_patterns(self, message: Message) -> bool: if not self._mention_patterns: @@ -7906,9 +8112,10 @@ class TelegramAdapter(BasePlatformAdapter): return self._telegram_guest_mode() and self._message_mentions_bot(message) def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]: - if not text or not self._bot or not getattr(self._bot, "username", None): + bot_username = self._current_bot_username() + if not text or not bot_username: return text - username = re.escape(self._bot.username) + username = re.escape(bot_username) cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip() return cleaned or text @@ -7975,7 +8182,7 @@ class TelegramAdapter(BasePlatformAdapter): return f"[{sender}|{user_id}]\n{event.text or ''}" def _telegram_group_observe_channel_prompt(self) -> str: - username = getattr(getattr(self, "_bot", None), "username", None) or "unknown" + username = self._current_bot_username() or "unknown" bot_id = getattr(getattr(self, "_bot", None), "id", None) or "unknown" return ( "You are handling a Telegram group chat message.\n" @@ -8277,6 +8484,13 @@ class TelegramAdapter(BasePlatformAdapter): # 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 diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index 1dc9a13a6f2..018be18778a 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -1412,3 +1412,188 @@ def test_unmentioned_unsupported_document_observed_and_cached(monkeypatch): assert "program.exe" in message["content"] asyncio.run(_run()) + + +# ── Bot identity: renames and non-"bot"-suffixed handles ──────────────────── +# Two failure modes fixed together (both break the mention gate): +# 1. PTB caches getMe() in Bot._bot_user and only rewrites it inside +# get_me(). After a BotFather rename the adapter compares against the OLD +# handle, so the exclusive-mention gate reads a message addressed to us as +# one addressed to a different bot and silently drops it. +# 2. The bot-handle pattern assumed every bot username ends in "bot". +# Collectible (Fragment) usernames can be assigned to bots and don't +# (@jarvis, @pic), making such a bot unable to recognise its own handle. + + +class _IdentityBot: + """Stand-in for PTB's Bot: ``.username`` only changes when get_me() runs.""" + + def __init__(self, bot_id=999, cached="hermes_bot", server=None): + self.id = bot_id + self._cached = cached + self._server = server if server is not None else cached + self.get_me_calls = 0 + + @property + def username(self): + return self._cached + + async def get_me(self): + self.get_me_calls += 1 + self._cached = self._server + return SimpleNamespace(id=self.id, username=self._server) + + +def _reply_to_bot_message(text, *, entities=None, bot_username, bot_id=999): + """Group message replying to one of our messages. + + Telegram stamps the bot's CURRENT username on ``reply_to_message.from_user``, + which is how a rename becomes observable without an extra API call. + """ + message = _group_message(text, entities=entities) + message.reply_to_message = SimpleNamespace( + from_user=SimpleNamespace(id=bot_id, username=bot_username), + message_id=10, text="previous bot reply", caption=None, + ) + return message + + +def test_renamed_bot_still_routes_when_reply_reveals_new_handle(): + """A rename observed from an inbound update takes effect immediately.""" + adapter = _make_adapter(require_mention=True) + adapter._bot = _IdentityBot(cached="old_helper_bot", server="new_helper_bot") + text = "@new_helper_bot thanks!" + message = _reply_to_bot_message( + text, entities=_mention_entities(text, ["@new_helper_bot"]), + bot_username="new_helper_bot", + ) + + assert adapter._should_process_message(message) is True + assert adapter._current_bot_username() == "new_helper_bot" + # Learned from the update stream — no Bot API round-trip needed. + assert adapter._bot.get_me_calls == 0 + + +def test_stale_username_does_not_route_message_to_another_bot(): + """The exclusive-mention gate must not fire on our own (renamed) handle.""" + adapter = _make_adapter(require_mention=True, exclusive_bot_mentions=True) + adapter._bot = _IdentityBot(cached="old_helper_bot", server="new_helper_bot") + adapter._note_bot_username("new_helper_bot") + text = "@new_helper_bot what's the weather" + message = _group_message(text, entities=_mention_entities(text, ["@new_helper_bot"])) + + assert adapter._explicit_bot_mentions_exclude_self(message) is False + assert adapter._should_process_message(message) is True + + +def test_stale_username_schedules_background_identity_recheck(): + """A drop caused by a stale handle self-corrects via a TTL-guarded getMe.""" + async def _run(): + adapter = _make_adapter(require_mention=True, exclusive_bot_mentions=True) + adapter._bot = _IdentityBot(cached="old_helper_bot", server="new_helper_bot") + adapter._background_tasks = set() + text = "@new_helper_bot what's the weather" + message = _group_message(text, entities=_mention_entities(text, ["@new_helper_bot"])) + + # First message is lost — nothing has revealed the new handle yet. + assert adapter._should_process_message(message) is False + await asyncio.gather(*list(adapter._background_tasks)) + + assert adapter._bot.get_me_calls == 1 + assert adapter._current_bot_username() == "new_helper_bot" + # Recovered without a gateway restart. + assert adapter._should_process_message(message) is True + + asyncio.run(_run()) + + +def test_identity_recheck_is_rate_limited_in_multi_bot_groups(): + """Traffic legitimately aimed at other bots must not trigger a getMe storm.""" + async def _run(): + adapter = _make_adapter(require_mention=True, exclusive_bot_mentions=True) + adapter._bot = _IdentityBot(cached="hermes_bot") + adapter._background_tasks = set() + text = "@other_helper_bot please run it" + + for _ in range(25): + adapter._should_process_message( + _group_message(text, entities=_mention_entities(text, ["@other_helper_bot"])) + ) + await asyncio.gather(*list(adapter._background_tasks)) + + assert adapter._bot.get_me_calls <= 1 + + asyncio.run(_run()) + + +def test_bot_never_adopts_another_accounts_username(): + """Only a user id matching this bot may update our own handle.""" + adapter = _make_adapter(require_mention=True) + adapter._bot = _IdentityBot(cached="hermes_bot") + message = _group_message("hello") + message.from_user = SimpleNamespace(id=555, username="impostor_bot", full_name="Impostor", first_name="Impostor") + + adapter._observe_bot_identity_from_message(message) + + assert adapter._current_bot_username() == "hermes_bot" + + +def test_collectible_username_without_bot_suffix_is_recognised(): + """A Fragment handle (@jarvis) must still count as addressing this bot.""" + adapter = _make_adapter(require_mention=True, bot_username="jarvis") + text = "@jarvis hey" + message = _group_message(text, entities=_mention_entities(text, ["@jarvis"])) + + assert adapter._message_mentions_bot(message) is True + assert adapter._should_process_message(message) is True + + +def test_collectible_username_recognised_without_entities(): + """Entity-less client updates must also match a non-'bot' handle.""" + adapter = _make_adapter(require_mention=True, bot_username="jarvis") + message = _group_message("@jarvis hey", entities=[]) + + assert adapter._message_mentions_bot(message) is True + assert adapter._should_process_message(message) is True + + +def test_collectible_username_not_suppressed_by_other_bot_mention(): + """@jarvis + @other_bot in one message must still reach @jarvis.""" + adapter = _make_adapter( + require_mention=True, exclusive_bot_mentions=True, bot_username="jarvis", + ) + text = "@jarvis ask @other_helper_bot for the log" + message = _group_message( + text, entities=_mention_entities(text, ["@jarvis", "@other_helper_bot"]), + ) + + assert adapter._explicit_bot_mentions_exclude_self(message) is False + assert adapter._should_process_message(message) is True + + +def test_human_handles_still_do_not_act_as_routing_hints(): + """Widening self-matching must not make human @handles suppress this bot.""" + adapter = _make_adapter(require_mention=True, exclusive_bot_mentions=True) + text = "@alice can you check this" + message = _group_message(text, entities=_mention_entities(text, ["@alice"])) + + assert adapter._explicit_bot_mentions_exclude_self(message) is False + + +def test_messages_addressed_to_a_different_bot_are_still_suppressed(): + """The multi-bot exclusivity contract is preserved.""" + adapter = _make_adapter(require_mention=True, exclusive_bot_mentions=True) + text = "@other_helper_bot do it" + message = _group_message(text, entities=_mention_entities(text, ["@other_helper_bot"])) + + assert adapter._explicit_bot_mentions_exclude_self(message) is True + assert adapter._should_process_message(message) is False + + +def test_clean_bot_trigger_text_strips_the_current_handle(): + """Prefix stripping must follow a rename, not the stale cached handle.""" + adapter = _make_adapter(require_mention=True) + adapter._bot = _IdentityBot(cached="old_helper_bot", server="new_helper_bot") + adapter._note_bot_username("new_helper_bot") + + assert adapter._clean_bot_trigger_text("@new_helper_bot ship it") == "ship it" diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 1ea240fabf9..346a00456c3 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -538,6 +538,7 @@ Hermes Agent works in Telegram group chats with a few considerations: - `/command@botusername` (Telegram's bot-menu command form that includes the bot name) - matches for one of your configured regex wake words in `telegram.mention_patterns` - In groups with multiple Hermes bots, `telegram.exclusive_bot_mentions` keeps routing deterministic. When a message explicitly mentions one or more Telegram bot usernames, only the mentioned bot profiles process it; other Hermes bots ignore it before reply and wake-word fallbacks run. This is enabled by default. +- Renaming the bot's `@username` in BotFather is picked up automatically — Hermes follows the new handle for mention routing without a gateway restart. Collectible (Fragment) usernames that don't end in `bot` are supported too. - Use `telegram.ignored_threads` to keep Hermes silent in specific Telegram forum topics, even when the group would otherwise allow free responses or mention-triggered replies - If `telegram.require_mention` is left unset or false, Hermes keeps the previous open-group behavior and responds to normal group messages it can see