Telegram group bot mention detection matching rules
Cluster 12545. Data as of
Nothing open on GitHub. Fix merged in #12848 on 20 Apr 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #12848. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #12848. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items all concern how the Telegram integration decides whether a group message mentions the bot. They describe replacing raw substring matching with word-boundary matching or requiring entity-based mentions for group gating, and one title appears twice.
Open issues0
Open PRs0
Fixes on record
-
#12848 fix(telegram): use word-boundary matching for bot mention detection
files:
gateway/platforms/telegram.py,tests/gateway/test_telegram_mention_boundaries.pyShow the diff
-
gateway/platforms/telegram.py@@ -2256,22 +2256,27 @@ def _message_mentions_bot(self, message: Message) -> bool: bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower() bot_id = getattr(self._bot, "id", None) + expected = f"@{bot_username}" if bot_username else None def _iter_sources(): yield getattr(message, "text", None) or "", getattr(message, "entities", None) or [] yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or [] + # Telegram parses mentions server-side and emits MessageEntity objects + # (type=mention for @username, type=text_mention for @FirstName targeting + # a user without a public username). Only those entities are authoritative — + # raw substring matches like "foo@hermes_bot.example" are not mentions + # (bug #12545). Entities also correctly handle @handles inside URLs, code + # blocks, and quoted text, where a regex scan would over-match. for source_text, entities in _iter_sources(): - if bot_username and f"@{bot_username}" in source_text.lower(): - return True for entity in entities: entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower() - if entity_type == "mention" and bot_username: + if entity_type == "mention" and expected: offset = int(getattr(entity, "offset", -1)) length = int(getattr(entity, "length", 0)) if offset < 0 or length <= 0: continue - if source_text[offset:offset + length].strip().lower() == f"@{bot_username}": + if source_text[offset:offset + length].strip().lower() == expected: return True elif entity_type == "text_mention": user = getattr(entity, "user", None) -
tests/gateway/test_telegram_mention_boundaries.py@@ -0,0 +1,185 @@ +"""Tests for Telegram bot mention detection (bug #12545). + +The old implementation used a naive substring check +(`f"@{bot_username}" in text.lower()`), which incorrectly matched partial +substrings like 'foo@hermes_bot.example'. + +Detection now relies entirely on the MessageEntity objects Telegram's server +emits for real mentions. A bare `@username` substring in message text without +a corresponding `MENTION` entity is NOT a mention — this correctly ignores +@handles that appear inside URLs, code blocks, email-like strings, or quoted +text, because Telegram's parser does not emit mention entities for any of +those contexts. +""" +from types import SimpleNamespace + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.telegram import TelegramAdapter + + +def _make_adapter(): + adapter = object.__new__(TelegramAdapter) + adapter.platform = Platform.TELEGRAM + adapter.config = PlatformConfig(enabled=True, token="***", extra={}) + adapter._bot = SimpleNamespace(id=999, username="hermes_bot") + return adapter + + +def _mention_entity(text, mention="@hermes_bot"): + """Build a MENTION entity pointing at a literal `@username` in `text`.""" + offset = text.index(mention) + return SimpleNamespace(type="mention", offset=offset, length=len(mention)) + + +def _text_mention_entity(offset, length, user_id): + """Build a TEXT_MENTION entity (used when the target user has no public @handle).""" + return SimpleNamespace( + type="text_mention", + offset=offset, + length=length, + user=SimpleNamespace(id=user_id), + ) + + +def _message(text=None, caption=None, entities=None, caption_entities=None): + return SimpleNamespace( + text=text, + caption=caption, + entities=entities or [], + caption_entities=caption_entities or [], + message_thread_id=None, + chat=SimpleNamespace(id=-100, type="group"), + reply_to_message=None, + ) + + +class TestRealMentionsAreDetected: + """A real Telegram mention always comes with a MENTION entity — detect those.""" + + def test_mention_at_start_of_message(self): + adapter = _make_adapter() + text = "@hermes_bot hello world" + msg = _message(text=text, entities=[_mention_entity(text)]) + assert adapter._message_mentions_bot(msg) is True + + def test_mention_mid_sentence(self): + adapter = _make_adapter() + text = "hey @hermes_bot, can you help?" + msg = _message(text=text, entities=[_mention_entity(text)]) + assert adapter._message_mentions_bot(msg) is True + + def test_mention_at_end_of_message(self): + adapter = _make_adapter() + text = "thanks for looking @hermes_bot" + msg = _message(text=text, entities=[_mention_entity(text)]) + assert adapter._message_mentions_bot(msg) is True + + def test_mention_in_caption(self): + adapter = _make_adapter() + caption = "photo for @hermes_bot" + msg = _message(caption=caption, caption_entities=[_mention_entity(caption)]) + assert adapter._message_mentions_bot(msg) is True + + def test_text_mention_entity_targets_bot(self): + """TEXT_MENTION is Telegram's entity type for @FirstName -> user without a public handle.""" + adapter = _make_adapter() + msg = _message(text="hey you", entities=[_text_mention_entity(4, 3, user_id=999)]) + assert adapter._message_mentions_bot(msg) is True + + +class TestSubstringFalsePositivesAreRejected: + """Bare `@bot_username` substrings without a MENTION entity must NOT match. + + These are all inputs where the OLD substring check returned True incorrectly. + A word-boundary regex would still over-match some of these (code blocks, + URLs). Entity-based detection handles them all correctly because Telegram's + parser does not emit mention entities for non-mention contexts. + """ + + def test_email_like_substring(self): + """bug #12545 exact repro: 'foo@hermes_bot.example'.""" + adapter = _make_adapter() + msg = _message(text="email me at foo@hermes_bot.example") + assert adapter._message_mentions_bot(msg) is False + + def test_hostname_substring(self): + adapter = _make_adapter() + msg = _message(text="contact user@hermes_bot.domain.com") + assert adapter._message_mentions_bot(msg) is False + + def test_superstring_username(self): + """`@hermes_botx` is a different username; Telegram would emit a mention + entity for `@hermes_botx`, not `@hermes_bot`.""" + adapter = _make_adapter() + msg = _message(text="@hermes_botx hello") + assert adapter._message_mentions_bot(msg) is False + + def test_underscore_suffix_substring(self): + adapter = _make_adapter() + msg = _message(text="see @hermes_bot_admin for help") + assert adapter._message_mentions_bot(msg) is False + + def test_substring_inside_url_without_entity(self): + """@handle inside a URL produces a URL entity, not a MENTION entity.""" + adapter = _make_adapter() + msg = _message(text="see https://example.com/@hermes_bot for details") + assert adapter._message_mentions_bot(msg) is False + + def test_substring_inside_code_block_without_entity(self): + """Telegram doesn't emit mention entities inside code/pre entities.""" + adapter = _make_adapter() + msg = _message(text="use the string `@hermes_bot` in config") + assert adapter._message_mentions_bot(msg) is False + + def test_plain_text_with_no_at_sign(self): + adapter = _make_adapter() + msg = _message(text="just a normal group message") + assert adapter._message_mentions_bot(msg) is False + + def test_email_substring_in_caption(self): + adapter = _make_adapter() + msg = _message(caption="foo@hermes_bot.example") + assert adapter._message_mentions_bot(msg) is False + + +class TestEntityEdgeCases: + """Malformed or mismatched entities should not crash or over-match.""" + + def test_mention_entity_for_different_username(self): + adapter = _make_adapter() + text = "@someone_else hi" + msg = _message(text=text, entities=[_mention_entity(text, mention="@someone_else")]) + assert adapter._message_mentions_bot(msg) is False + + def test_text_mention_entity_for_different_user(self): + adapter = _make_adapter() + msg = _message(text="hi there", entities=[_text_mention_entity(0, 2, user_id=12345)]) + assert adapter._message_mentions_bot(msg) is False + + def test_malformed_entity_with_negative_offset(self): + adapter = _make_adapter() + msg = _message(text="@hermes_bot hi", + entities=[SimpleNamespace(type="mention", offset=-1, length=11)]) + assert adapter._message_mentions_bot(msg) is False + + def test_malformed_entity_with_zero_length(self): + adapter = _make_adapter() + msg = _message(text="@hermes_bot hi", + entities=[SimpleNamespace(type="mention", offset=0, length=0)]) + assert adapter._message_mentions_bot(msg) is False + + +class TestCaseInsensitivity: + """Telegram usernames are case-insensitive; the slice-compare normalizes both sides.""" + + def test_uppercase_mention(self): + adapter = _make_adapter() + text = "hi @HERMES_BOT" + msg = _message(text=text, entities=[_mention_entity(text, mention="@HERMES_BOT")]) + assert adapter._message_mentions_bot(msg) is True + + def test_mixed_case_mention(self): + adapter = _make_adapter() + text = "hi @Hermes_Bot" + msg = _message(text=text, entities=[_mention_entity(text, mention="@Hermes_Bot")]) + assert adapter._message_mentions_bot(msg) is True
-
2 PRs closed without merging
From the close comments: done elsewhere 1 · merged via another change 1
Closed issues · 1
- Closed#12545telegram: group mention gating treats raw substrings as valid mentions
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #12848
- Closed issues
- 1
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 20 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.