fix(telegram): follow @username renames and support non-"bot" handles

Renaming a Telegram bot's @username in BotFather silently stopped the
gateway from answering in groups.

PTB caches getMe() in Bot._bot_user and only rewrites it inside get_me(),
so after a rename the adapter kept comparing mentions against the OLD
handle. The exclusive-mention gate then saw the new @handle, failed to
match itself, and concluded the message was addressed to a different bot
— dropping it before the reply and wake-word fallbacks could run. Native
replies to the bot were discarded too. Polling mode recovered on the next
90s heartbeat; webhook mode never calls get_me() again, so it stayed dead
until restart.

Separately, the bot-handle pattern assumed every bot username ends in
"bot". Collectible (Fragment) usernames can be assigned to bots and drop
that suffix (@jarvis, @pic), so such a bot could not recognise its own
handle in the entity-less fallback and was suppressed by any message that
also named another bot.

- Route every mention comparison through _current_bot_username(), which
  prefers the last observed handle over PTB's cache.
- Learn the live handle from inbound updates: Telegram stamps the current
  username on our own messages and on reply_to_message. Guarded by user
  id, so another account's handle is never adopted.
- Re-check identity out of band (TTL-bounded, one getMe per 5 min) when
  the exclusive gate is about to drop a message — the exact stale-handle
  symptom — so the mistake self-corrects instead of persisting.
- Refresh identity in webhook mode via a dedicated low-frequency loop,
  cancelled on the same teardown fence as the heartbeat.
- Match our own handle by identity rather than shape. Foreign handles keep
  the deliberate "...bot" narrowing so human @handles still never act as
  routing hints (the intent behind ce4d857021).

Validation: 11 regression tests; sabotage runs confirm each behavioral
test fails with the fix reverted. 157 tests green across the Telegram
gating, reconnect, and topic-mode suites.
This commit is contained in:
teknium1 2026-07-26 08:12:36 -07:00 committed by Teknium
parent 339d968689
commit 08130a26f6
3 changed files with 419 additions and 19 deletions

View file

@ -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)(?<![A-Za-z0-9_`/])@([A-Za-z0-9_]{2,29}bot)\b", raw_text):
mentioned_bot_usernames.add(match.group(1).lower())
for match in re.finditer(r"(?i)(?<![A-Za-z0-9_`/])@([A-Za-z0-9_]{2,31})\b", raw_text):
handle = match.group(1).lower()
if _is_bot_handle(handle):
mentioned_bot_usernames.add(handle)
return mentioned_bot_usernames
@ -7810,7 +7981,7 @@ class TelegramAdapter(BasePlatformAdapter):
if not self._bot:
return False
bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower()
bot_username = self._current_bot_username()
bot_id = getattr(self._bot, "id", None)
expected = f"@{bot_username}" if bot_username else None
@ -7858,10 +8029,37 @@ class TelegramAdapter(BasePlatformAdapter):
continue
if command_text[at_index:].strip().lower() == expected:
return True
if bot_username and re.fullmatch(r"[a-z0-9_]{2,29}bot", bot_username, re.IGNORECASE):
return bot_username in self._extract_bot_mention_usernames(message)
if bot_username:
return bot_username in self._extract_bot_mention_usernames(message, bot_username)
return False
def _schedule_bot_identity_recheck(self) -> None:
"""Fire a TTL-guarded identity refresh in the background.
Called when routing is about to discard a message 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

View file

@ -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"

View file

@ -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