{"repo":"NousResearch/hermes-agent","public_id":"3979","display_label":"Telegram/Slack mention gating and bot filtering","summary":"These issues and PRs address how require_mention rules are enforced in Telegram group chats and Slack group DMs, including slash command handling, bot-originated message filtering, and a proposed TELEGRAM_ALLOW_BOTS configuration option.","subsystem":"gateway","counts":{"members":19,"open_issues":2,"open_issues_distinct":2,"open_prs":3,"closed_issues":4,"merged_prs":4,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":3870},"resolution":{"state":"partially_resolved","by":"pr-13438,pr-16379,pr-19429"},"freshness":{"state":"verified","last_verified_at":"2026-05-03T23:59:46+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.08654970760233917},"fix_edges":[{"issue_number":3979,"pr_number":3870,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":3979,"pr_number":19429,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":6033,"pr_number":13438,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":15415,"pr_number":16379,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":3979,"title":"Telegram: require_mention ignored in groups — bot responds to every message","url":null,"state":"closed"},{"kind":"issue","number":6033,"title":"Telegram: slash commands bypass require_mention causing duplicate responses in multi-bot groups","url":null,"state":"closed"},{"kind":"issue","number":7294,"title":"Slack: apply require_mention gating to group DMs (mpim)","url":null,"state":"closed"},{"kind":"issue","number":13083,"title":"[Feature] Add TELEGRAM_ALLOW_BOTS support (similar to DISCORD_ALLOW_BOTS)","url":null,"state":"open"},{"kind":"issue","number":15415,"title":"Telegram bot-menu slash commands (`/cmd@botname`) dropped in groups with require_mention=true","url":null,"state":"closed"},{"kind":"issue","number":54370,"title":"bug(gateway): require_mention gate bypassed when message arrives during active group session","url":null,"state":"open"},{"kind":"pr","number":3870,"title":"feat(telegram): add group mention gating and regex triggers","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/platforms/telegram.py","tests/gateway/test_telegram_group_gating.py","website/docs/user-guide/messaging/telegram.md"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -515,6 +515,10 @@ def load_gateway_config() -> GatewayConfig:\n                     )\n                 if \"reply_prefix\" in platform_cfg:\n                     bridged[\"reply_prefix\"] = platform_cfg[\"reply_prefix\"]\n+                if \"require_mention\" in platform_cfg:\n+                    bridged[\"require_mention\"] = platform_cfg[\"require_mention\"]\n+                if \"mention_patterns\" in platform_cfg:\n+                    bridged[\"mention_patterns\"] = platform_cfg[\"mention_patterns\"]\n                 if not bridged:\n                     continue\n                 plat_data = platforms_data.setdefault(plat.value, {})","@@ -539,6 +543,20 @@ def load_gateway_config() -> GatewayConfig:\n                     os.environ[\"DISCORD_FREE_RESPONSE_CHANNELS\"] = str(frc)\n                 if \"auto_thread\" in discord_cfg and not os.getenv(\"DISCORD_AUTO_THREAD\"):\n                     os.environ[\"DISCORD_AUTO_THREAD\"] = str(discord_cfg[\"auto_thread\"]).lower()\n+\n+            # Telegram settings → env vars (env vars take precedence)\n+            telegram_cfg = yaml_cfg.get(\"telegram\", {})\n+            if isinstance(telegram_cfg, dict):\n+                if \"require_mention\" in telegram_cfg and not os.getenv(\"TELEGRAM_REQUIRE_MENTION\"):\n+                    os.environ[\"TELEGRAM_REQUIRE_MENTION\"] = str(telegram_cfg[\"require_mention\"]).lower()\n+                if \"mention_patterns\" in telegram_cfg and not os.getenv(\"TELEGRAM_MENTION_PATTERNS\"):\n+                    import json as _json\n+                    os.environ[\"TELEGRAM_MENTION_PATTERNS\"] = _json.dumps(telegram_cfg[\"mention_patterns\"])\n+                frc = telegram_cfg.get(\"free_response_chats\")\n+                if frc is not None and not os.getenv(\"TELEGRAM_FREE_RESPONSE_CHATS\"):\n+                    if isinstance(frc, list):\n+                        frc = \",\".join(str(v) for v in frc)\n+                    os.environ[\"TELEGRAM_FREE_RESPONSE_CHATS\"] = str(frc)\n     except Exception as e:\n         logger.warning(\n             \"Failed to process config.yaml — falling back to .env / gateway.json values. \""]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -8,6 +8,7 @@\n \"\"\"\n \n import asyncio\n+import json\n import logging\n import os\n import re","@@ -122,6 +123,7 @@ def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.TELEGRAM)\n         self._app: Optional[Application] = None\n         self._bot: Optional[Bot] = None\n+        self._mention_patterns = self._compile_mention_patterns()\n         self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'\n         # Buffer rapid/album photo updates so Telegram image bursts are handled\n         # as a single MessageEvent instead of self-interrupting multiple turns.","@@ -1325,6 +1327,148 @@ def _esc_bare(m, _seg=_seg):\n \n         return text\n     \n+    # ── Group mention gating ──────────────────────────────────────────────\n+\n+    def _telegram_require_mention(self) -> bool:\n+        \"\"\"Return whether group chats should require an explicit bot trigger.\"\"\"\n+        configured = self.config.extra.get(\"require_mention\")\n+        if configured is not None:\n+            if isinstance(configured, str):\n+                return configured.lower() in (\"true\", \"1\", \"yes\", \"on\")\n+            return bool(configured)\n+        return os.getenv(\"TELEGRAM_REQUIRE_MENTION\", \"false\").lower() in (\"true\", \"1\", \"yes\", \"on\")\n+\n+    def _telegram_free_response_chats(self) -> set[str]:\n+        raw = self.config.extra.get(\"free_response_chats\")\n+        if raw is None:\n+            raw = os.getenv(\"TELEGRAM_FREE_RESPONSE_CHATS\", \"\")\n+        if isinstance(raw, list):\n+            return {str(part).strip() for part in raw if str(part).strip()}\n+        return {part.strip() for part in str(raw).split(\",\") if part.strip()}\n+\n+    def _compile_mention_patterns(self) -> List[re.Pattern]:\n+        \"\"\"Compile optional regex wake-word patterns for group triggers.\"\"\"\n+        patterns = self.config.extra.get(\"mention_patterns\")\n+        if patterns is None:\n+            raw = os.getenv(\"TELEGRAM_MENTION_PATTERNS\", \"\").strip()\n+            if raw:\n+                try:\n+                    loaded = json.loads(raw)\n+                except Exception:\n+                    loaded = [part.strip() for part in raw.splitlines() if part.strip()]\n+                    if not loaded:\n+                        loaded = [part.strip() for part in raw.split(\",\") if part.strip()]\n+                patterns = loaded\n+\n+        if patterns is None:\n+            return []\n+        if isinstance(patterns, str):\n+            patterns = [patterns]\n+        if not isinstance(patterns, list):\n+            logger.warning(\n+                \"[%s] telegram mention_patterns must be a list or string; got %s\",\n+                self.name,\n+                type(patterns).__name__,\n+            )\n+            return []\n+\n+        compiled: List[re.Pattern] = []\n+        for pattern in patterns:\n+            if not isinstance(pattern, str) or not pattern.strip():\n+                continue\n+            try:\n+                compiled.append(re.compile(pattern, re.IGNORECASE))\n+            except re.error as exc:\n+                logger.warning(\"[%s] Invalid Telegram mention pattern %r: %s\", self.name, pattern, exc)\n+        if compiled:\n+            logger.info(\"[%s] Loaded %d Telegram mention pattern(s)\", self.name, len(compiled))\n+        return compiled\n+\n+    def _is_group_chat(self, message: Message) -> bool:\n+        chat = getattr(message, \"chat\", None)\n+        if not chat:\n+            return False\n+        chat_type = str(getattr(chat, \"type\", \"\")).split(\".\")[-1].lower()\n+        return chat_type in (\"group\", \"supergroup\")\n+\n+    def _is_reply_to_bot(self, message: Message) -> bool:\n+        if not self._bot or not getattr(message, \"reply_to_message\", None):\n+            return False\n+        reply_user = getattr(message.reply_to_message, \"from_user\", None)\n+        return bool(reply_user and getattr(reply_user, \"id\", None) == getattr(self._bot, \"id\", None))\n+\n+    def _message_mentions_bot(self, message: Message) -> bool:\n+        if not self._bot:\n+            return False\n+\n+        bot_username = (getattr(self._bot, \"username\", None) or \"\").lstrip(\"@\").lower()\n+        bot_id = getattr(self._bot, \"id\", 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+        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+                    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+                        return True\n+                elif entity_type == \"text_mention\":\n+                    user = getattr(entity, \"user\", None)\n+                    if user and getattr(user, \"id\", None) == bot_id:\n+                        return True\n+        return False\n+\n+    def _message_matches_mention_patterns(self, message: Message) -> bool:\n+        if not self._mention_patterns:\n+            return False\n+        for candidate in (getattr(message, \"text\", None), getattr(message, \"caption\", None)):\n+            if not candidate:\n+                continue\n+            for pattern in self._mention_patterns:\n+                if pattern.search(candidate):\n+                    return True\n+        return False\n+\n+    def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]:\n+        if not text or not self._bot or not getattr(self._bot, \"username\", None):\n+            return text\n+        username = re.escape(self._bot.username)\n+        cleaned = re.sub(rf\"(?i)@{username}\\b[,:\\-]*\\s*\", \"\", text).strip()\n+        return cleaned or text\n+\n+    def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool:\n+        \"\"\"Apply Telegram group trigger rules.\n+\n+        DMs remain unrestricted. Group/supergroup messages are accepted when:\n+        - the chat is explicitly allowlisted in ``free_response_chats``\n+        - ``require_mention`` is disabled\n+        - the message is a command\n+        - the message replies to the bot\n+        - the bot is @mentioned\n+        - the text/caption matches a configured regex wake-word pattern\n+        \"\"\"\n+        if not self._is_group_chat(message):\n+            return True\n+        if str(getattr(getattr(message, \"chat\", None), \"id\", \"\")) in self._telegram_free_response_chats():\n+            return True\n+        if not self._telegram_require_mention():\n+            return True\n+        if is_command:\n+            return True\n+        if self._is_reply_to_bot(message):\n+            return True\n+        if self._message_mentions_bot(message):\n+            return True\n+        return self._message_matches_mention_patterns(message)\n+\n     async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:\n         \"\"\"Handle incoming text messages.\n ","@@ -1334,14 +1478,19 @@ async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAU\n         \"\"\"\n         if not update.message or not update.message.text:\n             return\n+        if not self._should_process_message(update.message):\n+            return\n \n         event = self._build_message_event(update.message, MessageType.TEXT)\n+        event.text = self._clean_bot_trigger_text(event.text)\n         self._enqueue_text_event(event)\n     \n     async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:\n         \"\"\"Handle incoming command messages.\"\"\"\n         if not update.message or not update.message.text:\n             return\n+        if not self._should_process_message(update.message, is_command=True):\n+            return\n         \n         event = self._build_message_event(update.message, MessageType.COMMAND)\n         await self.handle_message(event)","@@ -1350,6 +1499,8 @@ async def _handle_location_message(self, update: Update, context: ContextTypes.D\n         \"\"\"Handle incoming location/venue pin messages.\"\"\"\n         if not update.message:\n             return\n+        if not self._should_process_message(update.message):\n+            return\n \n         msg = update.message\n         venue = getattr(msg, \"venue\", None)","@@ -1493,6 +1644,8 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA\n         \"\"\"Handle incoming media messages, downloading images to local cache.\"\"\"\n         if not update.message:\n             return\n+        if not self._should_process_message(update.message):\n+            return\n         \n         msg = update.message\n         ","@@ -1516,7 +1669,7 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA\n         \n         # Add caption as text\n         if msg.caption:\n-            event.text = msg.caption\n+            event.text = self._clean_bot_trigger_text(msg.caption)\n         \n         # Handle stickers: describe via vision tool with caching\n         if msg.sticker:"]},{"file":"tests/gateway/test_telegram_group_gating.py","hunks":["@@ -0,0 +1,110 @@\n+import json\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock\n+\n+from gateway.config import Platform, PlatformConfig, load_gateway_config\n+\n+\n+def _make_adapter(require_mention=None, free_response_chats=None, mention_patterns=None):\n+    from gateway.platforms.telegram import TelegramAdapter\n+\n+    extra = {}\n+    if require_mention is not None:\n+        extra[\"require_mention\"] = require_mention\n+    if free_response_chats is not None:\n+        extra[\"free_response_chats\"] = free_response_chats\n+    if mention_patterns is not None:\n+        extra[\"mention_patterns\"] = mention_patterns\n+\n+    adapter = object.__new__(TelegramAdapter)\n+    adapter.platform = Platform.TELEGRAM\n+    adapter.config = PlatformConfig(enabled=True, token=\"***\", extra=extra)\n+    adapter._bot = SimpleNamespace(id=999, username=\"hermes_bot\")\n+    adapter._message_handler = AsyncMock()\n+    adapter._pending_text_batches = {}\n+    adapter._pending_text_batch_tasks = {}\n+    adapter._text_batch_delay_seconds = 0.01\n+    adapter._mention_patterns = adapter._compile_mention_patterns()\n+    return adapter\n+\n+\n+def _group_message(text=\"hello\", *, chat_id=-100, reply_to_bot=False, entities=None, caption=None, caption_entities=None):\n+    reply_to_message = None\n+    if reply_to_bot:\n+        reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999))\n+    return SimpleNamespace(\n+        text=text,\n+        caption=caption,\n+        entities=entities or [],\n+        caption_entities=caption_entities or [],\n+        chat=SimpleNamespace(id=chat_id, type=\"group\"),\n+        reply_to_message=reply_to_message,\n+    )\n+\n+\n+def _mention_entity(text, mention=\"@hermes_bot\"):\n+    offset = text.index(mention)\n+    return SimpleNamespace(type=\"mention\", offset=offset, length=len(mention))\n+\n+\n+def test_group_messages_can_be_opened_via_config():\n+    adapter = _make_adapter(require_mention=False)\n+\n+    assert adapter._should_process_message(_group_message(\"hello everyone\")) is True\n+\n+\n+def test_group_messages_can_require_direct_trigger_via_config():\n+    adapter = _make_adapter(require_mention=True)\n+\n+    assert adapter._should_process_message(_group_message(\"hello everyone\")) is False\n+    assert adapter._should_process_message(_group_message(\"hi @hermes_bot\", entities=[_mention_entity(\"hi @hermes_bot\")])) is True\n+    assert adapter._should_process_message(_group_message(\"replying\", reply_to_bot=True)) is True\n+    assert adapter._should_process_message(_group_message(\"/status\"), is_command=True) is True\n+\n+\n+def test_free_response_chats_bypass_mention_requirement():\n+    adapter = _make_adapter(require_mention=True, free_response_chats=[\"-200\"])\n+\n+    assert adapter._should_process_message(_group_message(\"hello everyone\", chat_id=-200)) is True\n+    assert adapter._should_process_message(_group_message(\"hello everyone\", chat_id=-201)) is False\n+\n+\n+def test_regex_mention_patterns_allow_custom_wake_words():\n+    adapter = _make_adapter(require_mention=True, mention_patterns=[r\"^\\s*chompy\\b\"])\n+\n+    assert adapter._should_process_message(_group_message(\"chompy status\")) is True\n+    assert adapter._should_process_message(_group_message(\"   chompy help\")) is True\n+    assert adapter._should_process_message(_group_message(\"hey chompy\")) is False\n+\n+\n+def test_invalid_regex_patterns_are_ignored():\n+    adapter = _make_adapter(require_mention=True, mention_patterns=[r\"(\", r\"^\\s*chompy\\b\"])\n+\n+    assert adapter._should_process_message(_group_message(\"chompy status\")) is True\n+    assert adapter._should_process_message(_group_message(\"hello everyone\")) is False\n+\n+\n+def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+    (hermes_home / \"config.yaml\").write_text(\n+        \"telegram:\\n\"\n+        \"  require_mention: true\\n\"\n+        \"  mention_patterns:\\n\"\n+        \"    - \\\"^\\\\\\\\s*chompy\\\\\\\\b\\\"\\n\"\n+        \"  free_response_chats:\\n\"\n+        \"    - \\\"-123\\\"\\n\",\n+        encoding=\"utf-8\",\n+    )\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+    monkeypatch.delenv(\"TELEGRAM_REQUIRE_MENTION\", raising=False)\n+    monkeypatch.delenv(\"TELEGRAM_MENTION_PATTERNS\", raising=False)\n+    monkeypatch.delenv(\"TELEGRAM_FREE_RESPONSE_CHATS\", raising=False)\n+\n+    config = load_gateway_config()\n+\n+    assert config is not None\n+    assert __import__(\"os\").environ[\"TELEGRAM_REQUIRE_MENTION\"] == \"true\"\n+    assert json.loads(__import__(\"os\").environ[\"TELEGRAM_MENTION_PATTERNS\"]) == [r\"^\\s*chompy\\b\"]\n+    assert __import__(\"os\").environ[\"TELEGRAM_FREE_RESPONSE_CHATS\"] == \"-123\""]},{"file":"website/docs/user-guide/messaging/telegram.md","hunks":["@@ -161,9 +161,35 @@ Configure the TTS provider in your `config.yaml` under the `tts.provider` key.\n Hermes Agent works in Telegram group chats with a few considerations:\n \n - **Privacy mode** determines what messages the bot can see (see [Step 3](#step-3-privacy-mode-critical-for-groups))\n-- When privacy mode is on, **@mention the bot** (e.g., `@my_hermes_bot what's the weather?`) or **reply to its messages** to interact\n-- When privacy mode is off (or bot is admin), the bot sees all messages and can participate naturally\n - `TELEGRAM_ALLOWED_USERS` still applies — only authorized users can trigger the bot, even in groups\n+- You can keep the bot from responding to ordinary group chatter with `telegram.require_mention: true`\n+- With `telegram.require_mention: true`, group messages are accepted when they are:\n+  - slash commands\n+  - replies to one of the bot's messages\n+  - `@botusername` mentions\n+  - matches for one of your configured regex wake words in `telegram.mention_patterns`\n+- 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\n+\n+### Example group trigger configuration\n+\n+Add this to `~/.hermes/config.yaml`:\n+\n+```yaml\n+telegram:\n+  require_mention: true\n+  mention_patterns:\n+    - \"^\\\\s*chompy\\\\b\"\n+```\n+\n+This example allows all the usual direct triggers plus messages that begin with `chompy`, even if they do not use an `@mention`.\n+\n+### Notes on `mention_patterns`\n+\n+- Patterns use Python regular expressions\n+- Matching is case-insensitive\n+- Patterns are checked against both text messages and media captions\n+- Invalid regex patterns are ignored with a warning in the gateway logs rather than crashing the bot\n+- If you want a pattern to match only at the start of a message, anchor it with `^`\n \n ## Private Chat Topics (Bot API 9.4)\n \n"]}]},{"kind":"pr","number":4019,"title":"fix(telegram): enforce require_mention in group chats","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3870","number":3870}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":6036,"title":"fix: slash commands now respect require_mention in Telegram groups","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":13331,"title":"fix(telegram): add allow-bots gate","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":13438,"title":"fix(telegram): slash commands respect require_mention in groups (salvage of #6036)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/telegram.py","scripts/release.py","tests/gateway/test_telegram_group_gating.py"],"fix_hunks":[{"file":"gateway/platforms/telegram.py","hunks":["@@ -2333,10 +2333,16 @@ def _should_process_message(self, message: Message, *, is_command: bool = False)\n         DMs remain unrestricted. Group/supergroup messages are accepted when:\n         - the chat is explicitly allowlisted in ``free_response_chats``\n         - ``require_mention`` is disabled\n-        - the message is a command\n         - the message replies to the bot\n         - the bot is @mentioned\n         - the text/caption matches a configured regex wake-word pattern\n+\n+        When ``require_mention`` is enabled, slash commands are not given\n+        special treatment — they must pass the same mention/reply checks\n+        as any other group message.  Users can still trigger commands via\n+        the Telegram bot menu (``/command@botname``) or by explicitly\n+        mentioning the bot (``@botname /command``), both of which are\n+        recognised as mentions by :meth:`_message_mentions_bot`.\n         \"\"\"\n         if not self._is_group_chat(message):\n             return True","@@ -2351,8 +2357,6 @@ def _should_process_message(self, message: Message, *, is_command: bool = False)\n             return True\n         if not self._telegram_require_mention():\n             return True\n-        if is_command:\n-            return True\n         if self._is_reply_to_bot(message):\n             return True\n         if self._message_mentions_bot(message):"]},{"file":"scripts/release.py","hunks":["@@ -326,6 +326,7 @@\n     \"[redacted:email]\": \"aniruddhaadak80\",\n     \"[redacted:email]\": \"jerilynzheng\",\n     \"[redacted:email]\": \"Aslaaen\",\n+    \"[redacted:email]\": \"pinion05\",\n }\n \n "]},{"file":"tests/gateway/test_telegram_group_gating.py","hunks":["@@ -71,7 +71,17 @@ def test_group_messages_can_require_direct_trigger_via_config():\n     assert adapter._should_process_message(_group_message(\"hello everyone\")) is False\n     assert adapter._should_process_message(_group_message(\"hi @hermes_bot\", entities=[_mention_entity(\"hi @hermes_bot\")])) is True\n     assert adapter._should_process_message(_group_message(\"replying\", reply_to_bot=True)) is True\n-    assert adapter._should_process_message(_group_message(\"/status\"), is_command=True) is True\n+    # Commands must also respect require_mention when it is enabled\n+    assert adapter._should_process_message(_group_message(\"/status\"), is_command=True) is False\n+    # But commands with @mention still pass (Telegram emits a MENTION entity\n+    # for /cmd@botname — the bot menu and python-telegram-bot's CommandHandler\n+    # rely on this same mechanism)\n+    assert adapter._should_process_message(\n+        _group_message(\"/status@hermes_bot\", entities=[_mention_entity(\"/status@hermes_bot\")])\n+    ) is True\n+    # And commands still pass unconditionally when require_mention is disabled\n+    adapter_no_mention = _make_adapter(require_mention=False)\n+    assert adapter_no_mention._should_process_message(_group_message(\"/status\"), is_command=True) is True\n \n \n def test_free_response_chats_bypass_mention_requirement():\n"]}]},{"kind":"pr","number":14975,"title":"fix(telegram): gate bot-originated group messages","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"736ffb3bc","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":15417,"title":"fix(telegram): accept /cmd@botname from bot menu in groups","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3ff3dfb5a","number":0},{"kind":"pr","value":"#16379","number":16379},{"kind":"pr","value":"#15415","number":15415}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":16379,"title":"fix(telegram): accept /cmd@botname from bot menu in groups","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/telegram.py","tests/gateway/test_telegram_group_gating.py"],"fix_hunks":[{"file":"gateway/platforms/telegram.py","hunks":["@@ -2353,6 +2353,26 @@ def _iter_sources():\n                     user = getattr(entity, \"user\", None)\n                     if user and getattr(user, \"id\", None) == bot_id:\n                         return True\n+                elif entity_type == \"bot_command\" and expected:\n+                    # Telegram's official group-disambiguation form for slash\n+                    # commands (``/cmd@botname``) is emitted as a single\n+                    # ``bot_command`` entity covering the whole span — there\n+                    # is no accompanying ``mention`` entity. Treat it as a\n+                    # direct address to this bot when the ``@botname`` suffix\n+                    # matches. This is the form Telegram's own command menu\n+                    # autocomplete produces in groups, so dropping it at the\n+                    # mention gate would break /new, /reset, /help, ... for\n+                    # every group that has ``require_mention`` enabled (#15415).\n+                    offset = int(getattr(entity, \"offset\", -1))\n+                    length = int(getattr(entity, \"length\", 0))\n+                    if offset < 0 or length <= 0:\n+                        continue\n+                    command_text = source_text[offset:offset + length]\n+                    at_index = command_text.find(\"@\")\n+                    if at_index < 0:\n+                        continue\n+                    if command_text[at_index:].strip().lower() == expected:\n+                        return True\n         return False\n \n     def _message_matches_mention_patterns(self, message: Message) -> bool:"]},{"file":"tests/gateway/test_telegram_group_gating.py","hunks":["@@ -59,6 +59,17 @@ def _mention_entity(text, mention=\"@hermes_bot\"):\n     return SimpleNamespace(type=\"mention\", offset=offset, length=len(mention))\n \n \n+def _bot_command_entity(text, command):\n+    \"\"\"Entity Telegram emits for a ``/cmd`` or ``/cmd@botname`` token.\n+\n+    Telegram parses slash commands server-side. For ``/cmd@botname`` the\n+    client does NOT emit a separate ``mention`` entity — the whole span\n+    is a single ``bot_command`` entity.\n+    \"\"\"\n+    offset = text.index(command)\n+    return SimpleNamespace(type=\"bot_command\", offset=offset, length=len(command))\n+\n+\n def test_group_messages_can_be_opened_via_config():\n     adapter = _make_adapter(require_mention=False)\n ","@@ -73,12 +84,34 @@ def test_group_messages_can_require_direct_trigger_via_config():\n     assert adapter._should_process_message(_group_message(\"replying\", reply_to_bot=True)) is True\n     # Commands must also respect require_mention when it is enabled\n     assert adapter._should_process_message(_group_message(\"/status\"), is_command=True) is False\n-    # But commands with @mention still pass (Telegram emits a MENTION entity\n-    # for /cmd@botname — the bot menu and python-telegram-bot's CommandHandler\n-    # rely on this same mechanism)\n+    # Telegram's group command menu sends ``/cmd@botname`` as a single\n+    # ``bot_command`` entity spanning the whole token (no separate mention\n+    # entity). We must accept it so the menu works when require_mention is on.\n     assert adapter._should_process_message(\n-        _group_message(\"/status@hermes_bot\", entities=[_mention_entity(\"/status@hermes_bot\")])\n+        _group_message(\n+            \"/status@hermes_bot\",\n+            entities=[_bot_command_entity(\"/status@hermes_bot\", \"/status@hermes_bot\")],\n+        ),\n+        is_command=True,\n     ) is True\n+    # A bot_command entity addressed at a different bot must not satisfy\n+    # the mention gate — Telegram groups can host multiple bots that\n+    # register the same command name.\n+    assert adapter._should_process_message(\n+        _group_message(\n+            \"/status@other_bot\",\n+            entities=[_bot_command_entity(\"/status@other_bot\", \"/status@other_bot\")],\n+        ),\n+        is_command=True,\n+    ) is False\n+    # Bare ``/status`` (no @botname) must still be dropped in groups with\n+    # require_mention=True — Telegram delivers it only when the bot's\n+    # privacy mode is off, and even then we should not respond unless the\n+    # user explicitly addressed the bot.\n+    assert adapter._should_process_message(\n+        _group_message(\"/status\", entities=[_bot_command_entity(\"/status\", \"/status\")]),\n+        is_command=True,\n+    ) is False\n     # And commands still pass unconditionally when require_mention is disabled\n     adapter_no_mention = _make_adapter(require_mention=False)\n     assert adapter_no_mention._should_process_message(_group_message(\"/status\"), is_command=True) is True\n"]}]},{"kind":"pr","number":19317,"title":"fix(gateway): bridge top-level require_mention to Telegram config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19429","number":19429}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":19429,"title":"fix(gateway): bridge top-level require_mention to Telegram config","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","tests/gateway/test_telegram_group_gating.py","website/docs/user-guide/messaging/telegram.md"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -846,11 +846,25 @@ def load_gateway_config() -> GatewayConfig:\n                         if yaml_key in allow_mentions_cfg and not os.getenv(env_key):\n                             os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()\n \n+            # Bridge top-level require_mention to Telegram when the telegram: section\n+            # does not already provide one.  Users often write \"require_mention: true\"\n+            # at the top level alongside group_sessions_per_user, expecting it to work\n+            # the same way (#3979).\n+            _tl_require_mention = yaml_cfg.get(\"require_mention\")\n+            if _tl_require_mention is not None:\n+                _tg_section = yaml_cfg.get(\"telegram\") or {}\n+                if \"require_mention\" not in _tg_section:\n+                    _tg_plat = platforms_data.setdefault(Platform.TELEGRAM.value, {})\n+                    _tg_extra = _tg_plat.setdefault(\"extra\", {})\n+                    _tg_extra.setdefault(\"require_mention\", _tl_require_mention)\n+\n             # Telegram settings → env vars (env vars take precedence)\n             telegram_cfg = yaml_cfg.get(\"telegram\", {})\n             if isinstance(telegram_cfg, dict):\n-                if \"require_mention\" in telegram_cfg and not os.getenv(\"TELEGRAM_REQUIRE_MENTION\"):\n-                    os.environ[\"TELEGRAM_REQUIRE_MENTION\"] = str(telegram_cfg[\"require_mention\"]).lower()\n+                # Prefer telegram.require_mention; fall back to the top-level shorthand.\n+                _effective_rm = telegram_cfg.get(\"require_mention\", yaml_cfg.get(\"require_mention\"))\n+                if _effective_rm is not None and not os.getenv(\"TELEGRAM_REQUIRE_MENTION\"):\n+                    os.environ[\"TELEGRAM_REQUIRE_MENTION\"] = str(_effective_rm).lower()\n                 if \"mention_patterns\" in telegram_cfg and not os.getenv(\"TELEGRAM_MENTION_PATTERNS\"):\n                     os.environ[\"TELEGRAM_MENTION_PATTERNS\"] = json.dumps(telegram_cfg[\"mention_patterns\"])\n                 frc = telegram_cfg.get(\"free_response_chats\")"]},{"file":"tests/gateway/test_telegram_group_gating.py","hunks":["@@ -261,6 +261,57 @@ def test_group_allow_from_is_enforced_by_gateway_authorization_not_trigger_gate(\n     assert adapter._should_process_message(_group_message(\"hello\", from_user_id=333)) is True\n \n \n+def test_top_level_require_mention_bridges_to_telegram(monkeypatch, tmp_path):\n+    \"\"\"require_mention at the config.yaml top level (alongside group_sessions_per_user)\n+    must behave identically to telegram.require_mention: true (#3979).\n+    \"\"\"\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+    # Intentionally no \"telegram:\" section — keys are at the top level.\n+    (hermes_home / \"config.yaml\").write_text(\n+        \"require_mention: true\\n\"\n+        \"group_sessions_per_user: true\\n\",\n+        encoding=\"utf-8\",\n+    )\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+    monkeypatch.delenv(\"TELEGRAM_REQUIRE_MENTION\", raising=False)\n+\n+    config = load_gateway_config()\n+\n+    assert config is not None\n+    assert __import__(\"os\").environ.get(\"TELEGRAM_REQUIRE_MENTION\") == \"true\"\n+\n+    # The adapter's extra dict must also carry the setting so that\n+    # _telegram_require_mention() works even without the env var.\n+    tg_cfg = config.platforms.get(__import__(\"gateway.config\", fromlist=[\"Platform\"]).Platform.TELEGRAM)\n+    if tg_cfg is not None:\n+        assert tg_cfg.extra.get(\"require_mention\") is True\n+\n+\n+def test_top_level_require_mention_does_not_override_telegram_section(monkeypatch, tmp_path):\n+    \"\"\"When telegram.require_mention is explicitly set, top-level require_mention\n+    must not override it (platform-specific config takes precedence).\n+    \"\"\"\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+    (hermes_home / \"config.yaml\").write_text(\n+        \"require_mention: true\\n\"\n+        \"telegram:\\n\"\n+        \"  require_mention: false\\n\",\n+        encoding=\"utf-8\",\n+    )\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+    monkeypatch.delenv(\"TELEGRAM_REQUIRE_MENTION\", raising=False)\n+\n+    config = load_gateway_config()\n+\n+    assert config is not None\n+    # The telegram-specific \"false\" must win over the top-level \"true\".\n+    assert __import__(\"os\").environ.get(\"TELEGRAM_REQUIRE_MENTION\") == \"false\"\n+\n+\n def test_config_bridges_telegram_ignored_threads(monkeypatch, tmp_path):\n     hermes_home = tmp_path / \".hermes\"\n     hermes_home.mkdir()"]},{"file":"website/docs/user-guide/messaging/telegram.md","hunks":["@@ -293,9 +293,9 @@ Hermes Agent works in Telegram group chats with a few considerations:\n - `TELEGRAM_ALLOWED_USERS` still applies — only authorized users can trigger the bot, even in groups\n - You can keep the bot from responding to ordinary group chatter with `telegram.require_mention: true`\n - With `telegram.require_mention: true`, group messages are accepted when they are:\n-  - slash commands\n   - replies to one of the bot's messages\n   - `@botusername` mentions\n+  - `/command@botusername` (Telegram's bot-menu command form that includes the bot name)\n   - matches for one of your configured regex wake words in `telegram.mention_patterns`\n - 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\n - 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\n"]}]},{"kind":"pr","number":54374,"title":"feat(telegram): add TELEGRAM_ALLOW_BOTS support (none/mentions/all)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"fc70d023d82b93778a337f741da05335550e7bb7","number":0},{"kind":"pr","value":"#54370","number":54370}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":54709,"title":"fix(telegram): gate active-session dispatch by mention rules","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54370","number":54370},{"kind":"pr","value":"#54374","number":54374},{"kind":"pr","value":"#54374","number":54374}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":115252,"title":"fix(telegram): drop no-mention group msgs during active session","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#54709","number":54709},{"kind":"pr","value":"#54709","number":54709}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}