{"repo":"NousResearch/hermes-agent","public_id":"12545","display_label":"Telegram group bot mention detection matching rules","summary":"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.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12848,"merged_at":"2026-04-20T07:10:23+00:00"},"resolution":{"state":"resolved","by":"pr-12848"},"freshness":{"state":"stale","last_verified_at":"2026-04-20T07:10:23+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":12545,"pr_number":12848,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-20T07:10:23+00:00"}],"members":[{"kind":"issue","number":12545,"title":"telegram: group mention gating treats raw substrings as valid mentions","url":null,"state":"closed"},{"kind":"pr","number":12637,"title":"fix(telegram): require entity mentions for group gating","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":12826,"title":"fix(telegram): use word-boundary matching for bot mention detection","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12848","number":12848}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":12848,"title":"fix(telegram): use word-boundary matching for bot mention detection","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/telegram.py","tests/gateway/test_telegram_mention_boundaries.py"],"fix_hunks":[{"file":"gateway/platforms/telegram.py","hunks":["@@ -2256,22 +2256,27 @@ def _message_mentions_bot(self, message: Message) -> bool:\n \n         bot_username = (getattr(self._bot, \"username\", None) or \"\").lstrip(\"@\").lower()\n         bot_id = getattr(self._bot, \"id\", None)\n+        expected = f\"@{bot_username}\" if bot_username else None\n \n         def _iter_sources():\n             yield getattr(message, \"text\", None) or \"\", getattr(message, \"entities\", None) or []\n             yield getattr(message, \"caption\", None) or \"\", getattr(message, \"caption_entities\", None) or []\n \n+        # Telegram parses mentions server-side and emits MessageEntity objects\n+        # (type=mention for @username, type=text_mention for @FirstName targeting\n+        # a user without a public username). Only those entities are authoritative —\n+        # raw substring matches like \"foo@hermes_bot.example\" are not mentions\n+        # (bug #12545). Entities also correctly handle @handles inside URLs, code\n+        # blocks, and quoted text, where a regex scan would over-match.\n         for source_text, entities in _iter_sources():\n-            if bot_username and f\"@{bot_username}\" in source_text.lower():\n-                return True\n             for entity in entities:\n                 entity_type = str(getattr(entity, \"type\", \"\")).split(\".\")[-1].lower()\n-                if entity_type == \"mention\" and bot_username:\n+                if entity_type == \"mention\" and expected:\n                     offset = int(getattr(entity, \"offset\", -1))\n                     length = int(getattr(entity, \"length\", 0))\n                     if offset < 0 or length <= 0:\n                         continue\n-                    if source_text[offset:offset + length].strip().lower() == f\"@{bot_username}\":\n+                    if source_text[offset:offset + length].strip().lower() == expected:\n                         return True\n                 elif entity_type == \"text_mention\":\n                     user = getattr(entity, \"user\", None)"]},{"file":"tests/gateway/test_telegram_mention_boundaries.py","hunks":["@@ -0,0 +1,185 @@\n+\"\"\"Tests for Telegram bot mention detection (bug #12545).\n+\n+The old implementation used a naive substring check\n+(`f\"@{bot_username}\" in text.lower()`), which incorrectly matched partial\n+substrings like 'foo@hermes_bot.example'.\n+\n+Detection now relies entirely on the MessageEntity objects Telegram's server\n+emits for real mentions. A bare `@username` substring in message text without\n+a corresponding `MENTION` entity is NOT a mention — this correctly ignores\n+@handles that appear inside URLs, code blocks, email-like strings, or quoted\n+text, because Telegram's parser does not emit mention entities for any of\n+those contexts.\n+\"\"\"\n+from types import SimpleNamespace\n+\n+from gateway.config import Platform, PlatformConfig\n+from gateway.platforms.telegram import TelegramAdapter\n+\n+\n+def _make_adapter():\n+    adapter = object.__new__(TelegramAdapter)\n+    adapter.platform = Platform.TELEGRAM\n+    adapter.config = PlatformConfig(enabled=True, token=\"***\", extra={})\n+    adapter._bot = SimpleNamespace(id=999, username=\"hermes_bot\")\n+    return adapter\n+\n+\n+def _mention_entity(text, mention=\"@hermes_bot\"):\n+    \"\"\"Build a MENTION entity pointing at a literal `@username` in `text`.\"\"\"\n+    offset = text.index(mention)\n+    return SimpleNamespace(type=\"mention\", offset=offset, length=len(mention))\n+\n+\n+def _text_mention_entity(offset, length, user_id):\n+    \"\"\"Build a TEXT_MENTION entity (used when the target user has no public @handle).\"\"\"\n+    return SimpleNamespace(\n+        type=\"text_mention\",\n+        offset=offset,\n+        length=length,\n+        user=SimpleNamespace(id=user_id),\n+    )\n+\n+\n+def _message(text=None, caption=None, entities=None, caption_entities=None):\n+    return SimpleNamespace(\n+        text=text,\n+        caption=caption,\n+        entities=entities or [],\n+        caption_entities=caption_entities or [],\n+        message_thread_id=None,\n+        chat=SimpleNamespace(id=-100, type=\"group\"),\n+        reply_to_message=None,\n+    )\n+\n+\n+class TestRealMentionsAreDetected:\n+    \"\"\"A real Telegram mention always comes with a MENTION entity — detect those.\"\"\"\n+\n+    def test_mention_at_start_of_message(self):\n+        adapter = _make_adapter()\n+        text = \"@hermes_bot hello world\"\n+        msg = _message(text=text, entities=[_mention_entity(text)])\n+        assert adapter._message_mentions_bot(msg) is True\n+\n+    def test_mention_mid_sentence(self):\n+        adapter = _make_adapter()\n+        text = \"hey @hermes_bot, can you help?\"\n+        msg = _message(text=text, entities=[_mention_entity(text)])\n+        assert adapter._message_mentions_bot(msg) is True\n+\n+    def test_mention_at_end_of_message(self):\n+        adapter = _make_adapter()\n+        text = \"thanks for looking @hermes_bot\"\n+        msg = _message(text=text, entities=[_mention_entity(text)])\n+        assert adapter._message_mentions_bot(msg) is True\n+\n+    def test_mention_in_caption(self):\n+        adapter = _make_adapter()\n+        caption = \"photo for @hermes_bot\"\n+        msg = _message(caption=caption, caption_entities=[_mention_entity(caption)])\n+        assert adapter._message_mentions_bot(msg) is True\n+\n+    def test_text_mention_entity_targets_bot(self):\n+        \"\"\"TEXT_MENTION is Telegram's entity type for @FirstName -> user without a public handle.\"\"\"\n+        adapter = _make_adapter()\n+        msg = _message(text=\"hey you\", entities=[_text_mention_entity(4, 3, user_id=999)])\n+        assert adapter._message_mentions_bot(msg) is True\n+\n+\n+class TestSubstringFalsePositivesAreRejected:\n+    \"\"\"Bare `@bot_username` substrings without a MENTION entity must NOT match.\n+\n+    These are all inputs where the OLD substring check returned True incorrectly.\n+    A word-boundary regex would still over-match some of these (code blocks,\n+    URLs). Entity-based detection handles them all correctly because Telegram's\n+    parser does not emit mention entities for non-mention contexts.\n+    \"\"\"\n+\n+    def test_email_like_substring(self):\n+        \"\"\"bug #12545 exact repro: 'foo@hermes_bot.example'.\"\"\"\n+        adapter = _make_adapter()\n+        msg = _message(text=\"email me at foo@hermes_bot.example\")\n+        assert adapter._message_mentions_bot(msg) is False\n+\n+    def test_hostname_substring(self):\n+        adapter = _make_adapter()\n+        msg = _message(text=\"contact user@hermes_bot.domain.com\")\n+        assert adapter._message_mentions_bot(msg) is False\n+\n+    def test_superstring_username(self):\n+        \"\"\"`@hermes_botx` is a different username; Telegram would emit a mention\n+        entity for `@hermes_botx`, not `@hermes_bot`.\"\"\"\n+        adapter = _make_adapter()\n+        msg = _message(text=\"@hermes_botx hello\")\n+        assert adapter._message_mentions_bot(msg) is False\n+\n+    def test_underscore_suffix_substring(self):\n+        adapter = _make_adapter()\n+        msg = _message(text=\"see @hermes_bot_admin for help\")\n+        assert adapter._message_mentions_bot(msg) is False\n+\n+    def test_substring_inside_url_without_entity(self):\n+        \"\"\"@handle inside a URL produces a URL entity, not a MENTION entity.\"\"\"\n+        adapter = _make_adapter()\n+        msg = _message(text=\"see https://example.com/@hermes_bot for details\")\n+        assert adapter._message_mentions_bot(msg) is False\n+\n+    def test_substring_inside_code_block_without_entity(self):\n+        \"\"\"Telegram doesn't emit mention entities inside code/pre entities.\"\"\"\n+        adapter = _make_adapter()\n+        msg = _message(text=\"use the string `@hermes_bot` in config\")\n+        assert adapter._message_mentions_bot(msg) is False\n+\n+    def test_plain_text_with_no_at_sign(self):\n+        adapter = _make_adapter()\n+        msg = _message(text=\"just a normal group message\")\n+        assert adapter._message_mentions_bot(msg) is False\n+\n+    def test_email_substring_in_caption(self):\n+        adapter = _make_adapter()\n+        msg = _message(caption=\"foo@hermes_bot.example\")\n+        assert adapter._message_mentions_bot(msg) is False\n+\n+\n+class TestEntityEdgeCases:\n+    \"\"\"Malformed or mismatched entities should not crash or over-match.\"\"\"\n+\n+    def test_mention_entity_for_different_username(self):\n+        adapter = _make_adapter()\n+        text = \"@someone_else hi\"\n+        msg = _message(text=text, entities=[_mention_entity(text, mention=\"@someone_else\")])\n+        assert adapter._message_mentions_bot(msg) is False\n+\n+    def test_text_mention_entity_for_different_user(self):\n+        adapter = _make_adapter()\n+        msg = _message(text=\"hi there\", entities=[_text_mention_entity(0, 2, user_id=12345)])\n+        assert adapter._message_mentions_bot(msg) is False\n+\n+    def test_malformed_entity_with_negative_offset(self):\n+        adapter = _make_adapter()\n+        msg = _message(text=\"@hermes_bot hi\",\n+                       entities=[SimpleNamespace(type=\"mention\", offset=-1, length=11)])\n+        assert adapter._message_mentions_bot(msg) is False\n+\n+    def test_malformed_entity_with_zero_length(self):\n+        adapter = _make_adapter()\n+        msg = _message(text=\"@hermes_bot hi\",\n+                       entities=[SimpleNamespace(type=\"mention\", offset=0, length=0)])\n+        assert adapter._message_mentions_bot(msg) is False\n+\n+\n+class TestCaseInsensitivity:\n+    \"\"\"Telegram usernames are case-insensitive; the slice-compare normalizes both sides.\"\"\"\n+\n+    def test_uppercase_mention(self):\n+        adapter = _make_adapter()\n+        text = \"hi @HERMES_BOT\"\n+        msg = _message(text=text, entities=[_mention_entity(text, mention=\"@HERMES_BOT\")])\n+        assert adapter._message_mentions_bot(msg) is True\n+\n+    def test_mixed_case_mention(self):\n+        adapter = _make_adapter()\n+        text = \"hi @Hermes_Bot\"\n+        msg = _message(text=text, entities=[_mention_entity(text, mention=\"@Hermes_Bot\")])\n+        assert adapter._message_mentions_bot(msg) is True\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}