Telegram/Slack mention gating and bot filtering

NousResearch/hermes-agent · cluster 3979 · gateway

Data as of

5 open items. Fix merged in #3870. Check it covers your case.

The short answer

2open issues
3open PRs
19items grouped
Known?
Yes. 19 related items grouped here, 4 issues already closed.
Fix event
A fix was merged to main in PR #3870. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #13438, #16379, #19429. That is separate from the fix event.
Still open
2 issues and 3 PRs.

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.

Open issues2

  • Issue#13083[Feature] Add TELEGRAM_ALLOW_BOTS support (similar to DISCORD_ALLOW_BOTS)
  • Issue#54370bug(gateway): require_mention gate bypassed when message arrives during active group session

Open PRs3

  • PR#13331fix(telegram): add allow-bots gate
  • PR#14975fix(telegram): gate bot-originated group messages
  • PR#115252fix(telegram): drop no-mention group msgs during active session

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #3870 feat(telegram): add group mention gating and regex triggers

    merged to main

    files: gateway/config.py, gateway/platforms/telegram.py, tests/gateway/test_telegram_group_gating.py, website/docs/user-guide/messaging/telegram.md

    Show the diff
    • gateway/config.py
      @@ -515,6 +515,10 @@ def load_gateway_config() -> GatewayConfig:
                           )
                       if "reply_prefix" in platform_cfg:
                           bridged["reply_prefix"] = platform_cfg["reply_prefix"]
      +                if "require_mention" in platform_cfg:
      +                    bridged["require_mention"] = platform_cfg["require_mention"]
      +                if "mention_patterns" in platform_cfg:
      +                    bridged["mention_patterns"] = platform_cfg["mention_patterns"]
                       if not bridged:
                           continue
                       plat_data = platforms_data.setdefault(plat.value, {})
      @@ -539,6 +543,20 @@ def load_gateway_config() -> GatewayConfig:
                           os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
                       if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
                           os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
      +
      +            # Telegram settings → env vars (env vars take precedence)
      +            telegram_cfg = yaml_cfg.get("telegram", {})
      +            if isinstance(telegram_cfg, dict):
      +                if "require_mention" in telegram_cfg and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
      +                    os.environ["TELEGRAM_REQUIRE_MENTION"] = str(telegram_cfg["require_mention"]).lower()
      +                if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
      +                    import json as _json
      +                    os.environ["TELEGRAM_MENTION_PATTERNS"] = _json.dumps(telegram_cfg["mention_patterns"])
      +                frc = telegram_cfg.get("free_response_chats")
      +                if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
      +                    if isinstance(frc, list):
      +                        frc = ",".join(str(v) for v in frc)
      +                    os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
           except Exception as e:
               logger.warning(
                   "Failed to process config.yaml — falling back to .env / gateway.json values. "
    • gateway/platforms/telegram.py
      @@ -8,6 +8,7 @@
       """
       
       import asyncio
      +import json
       import logging
       import os
       import re
      @@ -122,6 +123,7 @@ def __init__(self, config: PlatformConfig):
               super().__init__(config, Platform.TELEGRAM)
               self._app: Optional[Application] = None
               self._bot: Optional[Bot] = None
      +        self._mention_patterns = self._compile_mention_patterns()
               self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
               # Buffer rapid/album photo updates so Telegram image bursts are handled
               # as a single MessageEvent instead of self-interrupting multiple turns.
      @@ -1325,6 +1327,148 @@ def _esc_bare(m, _seg=_seg):
       
               return text
           
      +    # ── Group mention gating ──────────────────────────────────────────────
      +
      +    def _telegram_require_mention(self) -> bool:
      +        """Return whether group chats should require an explicit bot trigger."""
      +        configured = self.config.extra.get("require_mention")
      +        if configured is not None:
      +            if isinstance(configured, str):
      +                return configured.lower() in ("true", "1", "yes", "on")
      +            return bool(configured)
      +        return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")
      +
      +    def _telegram_free_response_chats(self) -> set[str]:
      +        raw = self.config.extra.get("free_response_chats")
      +        if raw is None:
      +            raw = os.getenv("TELEGRAM_FREE_RESPONSE_CHATS", "")
      +        if isinstance(raw, list):
      +            return {str(part).strip() for part in raw if str(part).strip()}
      +        return {part.strip() for part in str(raw).split(",") if part.strip()}
      +
      +    def _compile_mention_patterns(self) -> List[re.Pattern]:
      +        """Compile optional regex wake-word patterns for group triggers."""
      +        patterns = self.config.extra.get("mention_patterns")
      +        if patterns is None:
      +            raw = os.getenv("TELEGRAM_MENTION_PATTERNS", "").strip()
      +            if raw:
      +                try:
      +                    loaded = json.loads(raw)
      +                except Exception:
      +                    loaded = [part.strip() for part in raw.splitlines() if part.strip()]
      +                    if not loaded:
      +                        loaded = [part.strip() for part in raw.split(",") if part.strip()]
      +                patterns = loaded
      +
      +        if patterns is None:
      +            return []
      +        if isinstance(patterns, str):
      +            patterns = [patterns]
      +        if not isinstance(patterns, list):
      +            logger.warning(
      +                "[%s] telegram mention_patterns must be a list or string; got %s",
      +                self.name,
      +                type(patterns).__name__,
      +            )
      +            return []
      +
      +        compiled: List[re.Pattern] = []
      +        for pattern in patterns:
      +            if not isinstance(pattern, str) or not pattern.strip():
      +                continue
      +            try:
      +                compiled.append(re.compile(pattern, re.IGNORECASE))
      +            except re.error as exc:
      +                logger.warning("[%s] Invalid Telegram mention pattern %r: %s", self.name, pattern, exc)
      +        if compiled:
      +            logger.info("[%s] Loaded %d Telegram mention pattern(s)", self.name, len(compiled))
      +        return compiled
      +
      +    def _is_group_chat(self, message: Message) -> bool:
      +        chat = getattr(message, "chat", None)
      +        if not chat:
      +            return False
      +        chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower()
      +        return chat_type in ("group", "supergroup")
      +
      +    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))
      +
      +    def _message_mentions_bot(self, message: Message) -> bool:
      +        if not self._bot:
      +            return False
      +
      +        bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower()
      +        bot_id = getattr(self._bot, "id", 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 []
      +
      +        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:
      +                    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}":
      +                        return True
      +                elif entity_type == "text_mention":
      +                    user = getattr(entity, "user", None)
      +                    if user and getattr(user, "id", None) == bot_id:
      +                        return True
      +        return False
      +
      +    def _message_matches_mention_patterns(self, message: Message) -> bool:
      +        if not self._mention_patterns:
      +            return False
      +        for candidate in (getattr(message, "text", None), getattr(message, "caption", None)):
      +            if not candidate:
      +                continue
      +            for pattern in self._mention_patterns:
      +                if pattern.search(candidate):
      +                    return True
      +        return False
      +
      +    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):
      +            return text
      +        username = re.escape(self._bot.username)
      +        cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip()
      +        return cleaned or text
      +
      +    def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool:
      +        """Apply Telegram group trigger rules.
      +
      +        DMs remain unrestricted. Group/supergroup messages are accepted when:
      +        - the chat is explicitly allowlisted in ``free_response_chats``
      +        - ``require_mention`` is disabled
      +        - the message is a command
      +        - the message replies to the bot
      +        - the bot is @mentioned
      +        - the text/caption matches a configured regex wake-word pattern
      +        """
      +        if not self._is_group_chat(message):
      +            return True
      +        if str(getattr(getattr(message, "chat", None), "id", "")) in self._telegram_free_response_chats():
      +            return True
      +        if not self._telegram_require_mention():
      +            return True
      +        if is_command:
      +            return True
      +        if self._is_reply_to_bot(message):
      +            return True
      +        if self._message_mentions_bot(message):
      +            return True
      +        return self._message_matches_mention_patterns(message)
      +
           async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
               """Handle incoming text messages.
       
      @@ -1334,14 +1478,19 @@ async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAU
               """
               if not update.message or not update.message.text:
                   return
      +        if not self._should_process_message(update.message):
      +            return
       
               event = self._build_message_event(update.message, MessageType.TEXT)
      +        event.text = self._clean_bot_trigger_text(event.text)
               self._enqueue_text_event(event)
           
           async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
               """Handle incoming command messages."""
               if not update.message or not update.message.text:
                   return
      +        if not self._should_process_message(update.message, is_command=True):
      +            return
               
               event = self._build_message_event(update.message, MessageType.COMMAND)
               await self.handle_message(event)
      @@ -1350,6 +1499,8 @@ async def _handle_location_message(self, update: Update, context: ContextTypes.D
               """Handle incoming location/venue pin messages."""
               if not update.message:
                   return
      +        if not self._should_process_message(update.message):
      +            return
       
               msg = update.message
               venue = getattr(msg, "venue", None)
      @@ -1493,6 +1644,8 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
               """Handle incoming media messages, downloading images to local cache."""
               if not update.message:
                   return
      +        if not self._should_process_message(update.message):
      +            return
               
               msg = update.message
               
      @@ -1516,7 +1669,7 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
               
               # Add caption as text
               if msg.caption:
      -            event.text = msg.caption
      +            event.text = self._clean_bot_trigger_text(msg.caption)
               
               # Handle stickers: describe via vision tool with caching
               if msg.sticker:
    • tests/gateway/test_telegram_group_gating.py
      @@ -0,0 +1,110 @@
      +import json
      +from types import SimpleNamespace
      +from unittest.mock import AsyncMock
      +
      +from gateway.config import Platform, PlatformConfig, load_gateway_config
      +
      +
      +def _make_adapter(require_mention=None, free_response_chats=None, mention_patterns=None):
      +    from gateway.platforms.telegram import TelegramAdapter
      +
      +    extra = {}
      +    if require_mention is not None:
      +        extra["require_mention"] = require_mention
      +    if free_response_chats is not None:
      +        extra["free_response_chats"] = free_response_chats
      +    if mention_patterns is not None:
      +        extra["mention_patterns"] = mention_patterns
      +
      +    adapter = object.__new__(TelegramAdapter)
      +    adapter.platform = Platform.TELEGRAM
      +    adapter.config = PlatformConfig(enabled=True, token="***", extra=extra)
      +    adapter._bot = SimpleNamespace(id=999, username="hermes_bot")
      +    adapter._message_handler = AsyncMock()
      +    adapter._pending_text_batches = {}
      +    adapter._pending_text_batch_tasks = {}
      +    adapter._text_batch_delay_seconds = 0.01
      +    adapter._mention_patterns = adapter._compile_mention_patterns()
      +    return adapter
      +
      +
      +def _group_message(text="hello", *, chat_id=-100, reply_to_bot=False, entities=None, caption=None, caption_entities=None):
      +    reply_to_message = None
      +    if reply_to_bot:
      +        reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999))
      +    return SimpleNamespace(
      +        text=text,
      +        caption=caption,
      +        entities=entities or [],
      +        caption_entities=caption_entities or [],
      +        chat=SimpleNamespace(id=chat_id, type="group"),
      +        reply_to_message=reply_to_message,
      +    )
      +
      +
      +def _mention_entity(text, mention="@hermes_bot"):
      +    offset = text.index(mention)
      +    return SimpleNamespace(type="mention", offset=offset, length=len(mention))
      +
      +
      +def test_group_messages_can_be_opened_via_config():
      +    adapter = _make_adapter(require_mention=False)
      +
      +    assert adapter._should_process_message(_group_message("hello everyone")) is True
      +
      +
      +def test_group_messages_can_require_direct_trigger_via_config():
      +    adapter = _make_adapter(require_mention=True)
      +
      +    assert adapter._should_process_message(_group_message("hello everyone")) is False
      +    assert adapter._should_process_message(_group_message("hi @hermes_bot", entities=[_mention_entity("hi @hermes_bot")])) is True
      +    assert adapter._should_process_message(_group_message("replying", reply_to_bot=True)) is True
      +    assert adapter._should_process_message(_group_message("/status"), is_command=True) is True
      +
      +
      +def test_free_response_chats_bypass_mention_requirement():
      +    adapter = _make_adapter(require_mention=True, free_response_chats=["-200"])
      +
      +    assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200)) is True
      +    assert adapter._should_process_message(_group_message("hello everyone", chat_id=-201)) is False
      +
      +
      +def test_regex_mention_patterns_allow_custom_wake_words():
      +    adapter = _make_adapter(require_mention=True, mention_patterns=[r"^\s*chompy\b"])
      +
      +    assert adapter._should_process_message(_group_message("chompy status")) is True
      +    assert adapter._should_process_message(_group_message("   chompy help")) is True
      +    assert adapter._should_process_message(_group_message("hey chompy")) is False
      +
      +
      +def test_invalid_regex_patterns_are_ignored():
      +    adapter = _make_adapter(require_mention=True, mention_patterns=[r"(", r"^\s*chompy\b"])
      +
      +    assert adapter._should_process_message(_group_message("chompy status")) is True
      +    assert adapter._should_process_message(_group_message("hello everyone")) is False
      +
      +
      +def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
      +    hermes_home = tmp_path / ".hermes"
      +    hermes_home.mkdir()
      +    (hermes_home / "config.yaml").write_text(
      +        "telegram:\n"
      +        "  require_mention: true\n"
      +        "  mention_patterns:\n"
      +        "    - \"^\\\\s*chompy\\\\b\"\n"
      +        "  free_response_chats:\n"
      +        "    - \"-123\"\n",
      +        encoding="utf-8",
      +    )
      +
      +    monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +    monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False)
      +    monkeypatch.delenv("TELEGRAM_MENTION_PATTERNS", raising=False)
      +    monkeypatch.delenv("TELEGRAM_FREE_RESPONSE_CHATS", raising=False)
      +
      +    config = load_gateway_config()
      +
      +    assert config is not None
      +    assert __import__("os").environ["TELEGRAM_REQUIRE_MENTION"] == "true"
      +    assert json.loads(__import__("os").environ["TELEGRAM_MENTION_PATTERNS"]) == [r"^\s*chompy\b"]
      +    assert __import__("os").environ["TELEGRAM_FREE_RESPONSE_CHATS"] == "-123"
    • website/docs/user-guide/messaging/telegram.md
      @@ -161,9 +161,35 @@ Configure the TTS provider in your `config.yaml` under the `tts.provider` key.
       Hermes Agent works in Telegram group chats with a few considerations:
       
       - **Privacy mode** determines what messages the bot can see (see [Step 3](#step-3-privacy-mode-critical-for-groups))
      -- When privacy mode is on, **@mention the bot** (e.g., `@my_hermes_bot what's the weather?`) or **reply to its messages** to interact
      -- When privacy mode is off (or bot is admin), the bot sees all messages and can participate naturally
       - `TELEGRAM_ALLOWED_USERS` still applies — only authorized users can trigger the bot, even in groups
      +- You can keep the bot from responding to ordinary group chatter with `telegram.require_mention: true`
      +- With `telegram.require_mention: true`, group messages are accepted when they are:
      +  - slash commands
      +  - replies to one of the bot's messages
      +  - `@botusername` mentions
      +  - matches for one of your configured regex wake words in `telegram.mention_patterns`
      +- 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
      +
      +### Example group trigger configuration
      +
      +Add this to `~/.hermes/config.yaml`:
      +
      +```yaml
      +telegram:
      +  require_mention: true
      +  mention_patterns:
      +    - "^\\s*chompy\\b"
      +```
      +
      +This example allows all the usual direct triggers plus messages that begin with `chompy`, even if they do not use an `@mention`.
      +
      +### Notes on `mention_patterns`
      +
      +- Patterns use Python regular expressions
      +- Matching is case-insensitive
      +- Patterns are checked against both text messages and media captions
      +- Invalid regex patterns are ignored with a warning in the gateway logs rather than crashing the bot
      +- If you want a pattern to match only at the start of a message, anchor it with `^`
       
       ## Private Chat Topics (Bot API 9.4)
       
  • #13438 fix(telegram): slash commands respect require_mention in groups (salvage of #6036)

    merged to main

    files: gateway/platforms/telegram.py, scripts/release.py, tests/gateway/test_telegram_group_gating.py

    Show the diff
    • gateway/platforms/telegram.py
      @@ -2333,10 +2333,16 @@ def _should_process_message(self, message: Message, *, is_command: bool = False)
               DMs remain unrestricted. Group/supergroup messages are accepted when:
               - the chat is explicitly allowlisted in ``free_response_chats``
               - ``require_mention`` is disabled
      -        - the message is a command
               - the message replies to the bot
               - the bot is @mentioned
               - the text/caption matches a configured regex wake-word pattern
      +
      +        When ``require_mention`` is enabled, slash commands are not given
      +        special treatment — they must pass the same mention/reply checks
      +        as any other group message.  Users can still trigger commands via
      +        the Telegram bot menu (``/command@botname``) or by explicitly
      +        mentioning the bot (``@botname /command``), both of which are
      +        recognised as mentions by :meth:`_message_mentions_bot`.
               """
               if not self._is_group_chat(message):
                   return True
      @@ -2351,8 +2357,6 @@ def _should_process_message(self, message: Message, *, is_command: bool = False)
                   return True
               if not self._telegram_require_mention():
                   return True
      -        if is_command:
      -            return True
               if self._is_reply_to_bot(message):
                   return True
               if self._message_mentions_bot(message):
    • scripts/release.py
      @@ -326,6 +326,7 @@
           "[redacted:email]": "aniruddhaadak80",
           "[redacted:email]": "jerilynzheng",
           "[redacted:email]": "Aslaaen",
      +    "[redacted:email]": "pinion05",
       }
       
       
    • tests/gateway/test_telegram_group_gating.py
      @@ -71,7 +71,17 @@ def test_group_messages_can_require_direct_trigger_via_config():
           assert adapter._should_process_message(_group_message("hello everyone")) is False
           assert adapter._should_process_message(_group_message("hi @hermes_bot", entities=[_mention_entity("hi @hermes_bot")])) is True
           assert adapter._should_process_message(_group_message("replying", reply_to_bot=True)) is True
      -    assert adapter._should_process_message(_group_message("/status"), is_command=True) is True
      +    # Commands must also respect require_mention when it is enabled
      +    assert adapter._should_process_message(_group_message("/status"), is_command=True) is False
      +    # But commands with @mention still pass (Telegram emits a MENTION entity
      +    # for /cmd@botname — the bot menu and python-telegram-bot's CommandHandler
      +    # rely on this same mechanism)
      +    assert adapter._should_process_message(
      +        _group_message("/status@hermes_bot", entities=[_mention_entity("/status@hermes_bot")])
      +    ) is True
      +    # And commands still pass unconditionally when require_mention is disabled
      +    adapter_no_mention = _make_adapter(require_mention=False)
      +    assert adapter_no_mention._should_process_message(_group_message("/status"), is_command=True) is True
       
       
       def test_free_response_chats_bypass_mention_requirement():
  • #16379 fix(telegram): accept /cmd@botname from bot menu in groups

    merged to main

    files: gateway/platforms/telegram.py, tests/gateway/test_telegram_group_gating.py

    Show the diff
    • gateway/platforms/telegram.py
      @@ -2353,6 +2353,26 @@ def _iter_sources():
                           user = getattr(entity, "user", None)
                           if user and getattr(user, "id", None) == bot_id:
                               return True
      +                elif entity_type == "bot_command" and expected:
      +                    # Telegram's official group-disambiguation form for slash
      +                    # commands (``/cmd@botname``) is emitted as a single
      +                    # ``bot_command`` entity covering the whole span — there
      +                    # is no accompanying ``mention`` entity. Treat it as a
      +                    # direct address to this bot when the ``@botname`` suffix
      +                    # matches. This is the form Telegram's own command menu
      +                    # autocomplete produces in groups, so dropping it at the
      +                    # mention gate would break /new, /reset, /help, ... for
      +                    # every group that has ``require_mention`` enabled (#15415).
      +                    offset = int(getattr(entity, "offset", -1))
      +                    length = int(getattr(entity, "length", 0))
      +                    if offset < 0 or length <= 0:
      +                        continue
      +                    command_text = source_text[offset:offset + length]
      +                    at_index = command_text.find("@")
      +                    if at_index < 0:
      +                        continue
      +                    if command_text[at_index:].strip().lower() == expected:
      +                        return True
               return False
       
           def _message_matches_mention_patterns(self, message: Message) -> bool:
    • tests/gateway/test_telegram_group_gating.py
      @@ -59,6 +59,17 @@ def _mention_entity(text, mention="@hermes_bot"):
           return SimpleNamespace(type="mention", offset=offset, length=len(mention))
       
       
      +def _bot_command_entity(text, command):
      +    """Entity Telegram emits for a ``/cmd`` or ``/cmd@botname`` token.
      +
      +    Telegram parses slash commands server-side. For ``/cmd@botname`` the
      +    client does NOT emit a separate ``mention`` entity — the whole span
      +    is a single ``bot_command`` entity.
      +    """
      +    offset = text.index(command)
      +    return SimpleNamespace(type="bot_command", offset=offset, length=len(command))
      +
      +
       def test_group_messages_can_be_opened_via_config():
           adapter = _make_adapter(require_mention=False)
       
      @@ -73,12 +84,34 @@ def test_group_messages_can_require_direct_trigger_via_config():
           assert adapter._should_process_message(_group_message("replying", reply_to_bot=True)) is True
           # Commands must also respect require_mention when it is enabled
           assert adapter._should_process_message(_group_message("/status"), is_command=True) is False
      -    # But commands with @mention still pass (Telegram emits a MENTION entity
      -    # for /cmd@botname — the bot menu and python-telegram-bot's CommandHandler
      -    # rely on this same mechanism)
      +    # Telegram's group command menu sends ``/cmd@botname`` as a single
      +    # ``bot_command`` entity spanning the whole token (no separate mention
      +    # entity). We must accept it so the menu works when require_mention is on.
           assert adapter._should_process_message(
      -        _group_message("/status@hermes_bot", entities=[_mention_entity("/status@hermes_bot")])
      +        _group_message(
      +            "/status@hermes_bot",
      +            entities=[_bot_command_entity("/status@hermes_bot", "/status@hermes_bot")],
      +        ),
      +        is_command=True,
           ) is True
      +    # A bot_command entity addressed at a different bot must not satisfy
      +    # the mention gate — Telegram groups can host multiple bots that
      +    # register the same command name.
      +    assert adapter._should_process_message(
      +        _group_message(
      +            "/status@other_bot",
      +            entities=[_bot_command_entity("/status@other_bot", "/status@other_bot")],
      +        ),
      +        is_command=True,
      +    ) is False
      +    # Bare ``/status`` (no @botname) must still be dropped in groups with
      +    # require_mention=True — Telegram delivers it only when the bot's
      +    # privacy mode is off, and even then we should not respond unless the
      +    # user explicitly addressed the bot.
      +    assert adapter._should_process_message(
      +        _group_message("/status", entities=[_bot_command_entity("/status", "/status")]),
      +        is_command=True,
      +    ) is False
           # And commands still pass unconditionally when require_mention is disabled
           adapter_no_mention = _make_adapter(require_mention=False)
           assert adapter_no_mention._should_process_message(_group_message("/status"), is_command=True) is True
  • #19429 fix(gateway): bridge top-level require_mention to Telegram config

    merged to main

    files: gateway/config.py, tests/gateway/test_telegram_group_gating.py, website/docs/user-guide/messaging/telegram.md

    Show the diff
    • gateway/config.py
      @@ -846,11 +846,25 @@ def load_gateway_config() -> GatewayConfig:
                               if yaml_key in allow_mentions_cfg and not os.getenv(env_key):
                                   os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()
       
      +            # Bridge top-level require_mention to Telegram when the telegram: section
      +            # does not already provide one.  Users often write "require_mention: true"
      +            # at the top level alongside group_sessions_per_user, expecting it to work
      +            # the same way (#3979).
      +            _tl_require_mention = yaml_cfg.get("require_mention")
      +            if _tl_require_mention is not None:
      +                _tg_section = yaml_cfg.get("telegram") or {}
      +                if "require_mention" not in _tg_section:
      +                    _tg_plat = platforms_data.setdefault(Platform.TELEGRAM.value, {})
      +                    _tg_extra = _tg_plat.setdefault("extra", {})
      +                    _tg_extra.setdefault("require_mention", _tl_require_mention)
      +
                   # Telegram settings → env vars (env vars take precedence)
                   telegram_cfg = yaml_cfg.get("telegram", {})
                   if isinstance(telegram_cfg, dict):
      -                if "require_mention" in telegram_cfg and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
      -                    os.environ["TELEGRAM_REQUIRE_MENTION"] = str(telegram_cfg["require_mention"]).lower()
      +                # Prefer telegram.require_mention; fall back to the top-level shorthand.
      +                _effective_rm = telegram_cfg.get("require_mention", yaml_cfg.get("require_mention"))
      +                if _effective_rm is not None and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
      +                    os.environ["TELEGRAM_REQUIRE_MENTION"] = str(_effective_rm).lower()
                       if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
                           os.environ["TELEGRAM_MENTION_PATTERNS"] = json.dumps(telegram_cfg["mention_patterns"])
                       frc = telegram_cfg.get("free_response_chats")
    • tests/gateway/test_telegram_group_gating.py
      @@ -261,6 +261,57 @@ def test_group_allow_from_is_enforced_by_gateway_authorization_not_trigger_gate(
           assert adapter._should_process_message(_group_message("hello", from_user_id=333)) is True
       
       
      +def test_top_level_require_mention_bridges_to_telegram(monkeypatch, tmp_path):
      +    """require_mention at the config.yaml top level (alongside group_sessions_per_user)
      +    must behave identically to telegram.require_mention: true (#3979).
      +    """
      +    hermes_home = tmp_path / ".hermes"
      +    hermes_home.mkdir()
      +    # Intentionally no "telegram:" section — keys are at the top level.
      +    (hermes_home / "config.yaml").write_text(
      +        "require_mention: true\n"
      +        "group_sessions_per_user: true\n",
      +        encoding="utf-8",
      +    )
      +
      +    monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +    monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False)
      +
      +    config = load_gateway_config()
      +
      +    assert config is not None
      +    assert __import__("os").environ.get("TELEGRAM_REQUIRE_MENTION") == "true"
      +
      +    # The adapter's extra dict must also carry the setting so that
      +    # _telegram_require_mention() works even without the env var.
      +    tg_cfg = config.platforms.get(__import__("gateway.config", fromlist=["Platform"]).Platform.TELEGRAM)
      +    if tg_cfg is not None:
      +        assert tg_cfg.extra.get("require_mention") is True
      +
      +
      +def test_top_level_require_mention_does_not_override_telegram_section(monkeypatch, tmp_path):
      +    """When telegram.require_mention is explicitly set, top-level require_mention
      +    must not override it (platform-specific config takes precedence).
      +    """
      +    hermes_home = tmp_path / ".hermes"
      +    hermes_home.mkdir()
      +    (hermes_home / "config.yaml").write_text(
      +        "require_mention: true\n"
      +        "telegram:\n"
      +        "  require_mention: false\n",
      +        encoding="utf-8",
      +    )
      +
      +    monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +    monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False)
      +
      +    config = load_gateway_config()
      +
      +    assert config is not None
      +    # The telegram-specific "false" must win over the top-level "true".
      +    assert __import__("os").environ.get("TELEGRAM_REQUIRE_MENTION") == "false"
      +
      +
       def test_config_bridges_telegram_ignored_threads(monkeypatch, tmp_path):
           hermes_home = tmp_path / ".hermes"
           hermes_home.mkdir()
    • website/docs/user-guide/messaging/telegram.md
      @@ -293,9 +293,9 @@ Hermes Agent works in Telegram group chats with a few considerations:
       - `TELEGRAM_ALLOWED_USERS` still applies — only authorized users can trigger the bot, even in groups
       - You can keep the bot from responding to ordinary group chatter with `telegram.require_mention: true`
       - With `telegram.require_mention: true`, group messages are accepted when they are:
      -  - slash commands
         - replies to one of the bot's messages
         - `@botusername` mentions
      +  - `/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`
       - 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

6 PRs closed without merging

From the close comments: done elsewhere 2 · merged via another change 2 · closed without comment 1 · superseded by another PR 1

  • #4019 fix(telegram): enforce require_mention in group chats · done elsewhere → #3870
  • #6036 fix: slash commands now respect require_mention in Telegram groups · closed without comment
  • #15417 fix(telegram): accept /cmd@botname from bot menu in groups · merged via another change → 3ff3dfb, #16379, #15415
  • #19317 fix(gateway): bridge top-level require_mention to Telegram config · merged via another change → #19429
  • #54374 feat(telegram): add TELEGRAM_ALLOW_BOTS support (none/mentions/all) · done elsewhere → fc70d02, #54370
Show 1 more
  • #54709 fix(telegram): gate active-session dispatch by mention rules · superseded by another PR → #54370, #54374
Closed issues · 4
  • Closed#3979Telegram: require_mention ignored in groups — bot responds to every message
  • Closed#6033Telegram: slash commands bypass require_mention causing duplicate responses in multi-bot groups
  • Closed#7294Slack: apply require_mention gating to group DMs (mpim)
  • Closed#15415Telegram bot-menu slash commands (`/cmd@botname`) dropped in groups with require_mention=true
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.09: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means 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 Lab

At a glance

Subsystem
gateway
Merged PRs
#3870, #13438, #16379, #19429
Closed issues
4
Attempts
6 PRs closed without merging
Fix link checked
3 May 2026

For your agent

The same facts, typed.