mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
339d968689
commit
08130a26f6
3 changed files with 419 additions and 19 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue