Route Telegram multi-bot mentions exclusively

This commit is contained in:
William Chen 2026-05-12 15:12:35 -07:00 committed by Teknium
parent bb8e9ea83a
commit ce4d857021
5 changed files with 162 additions and 2 deletions

View file

@ -836,6 +836,8 @@ def load_gateway_config() -> GatewayConfig:
bridged["free_response_channels"] = platform_cfg["free_response_channels"]
if "mention_patterns" in platform_cfg:
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
if "exclusive_bot_mentions" in platform_cfg:
bridged["exclusive_bot_mentions"] = platform_cfg["exclusive_bot_mentions"]
if "dm_policy" in platform_cfg:
bridged["dm_policy"] = platform_cfg["dm_policy"]
if "allow_from" in platform_cfg:
@ -1018,6 +1020,8 @@ def load_gateway_config() -> GatewayConfig:
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"])
if "exclusive_bot_mentions" in telegram_cfg and not os.getenv("TELEGRAM_EXCLUSIVE_BOT_MENTIONS"):
os.environ["TELEGRAM_EXCLUSIVE_BOT_MENTIONS"] = str(telegram_cfg["exclusive_bot_mentions"]).lower()
if "guest_mode" in telegram_cfg and not os.getenv("TELEGRAM_GUEST_MODE"):
os.environ["TELEGRAM_GUEST_MODE"] = str(telegram_cfg["guest_mode"]).lower()
frc = telegram_cfg.get("free_response_chats")

View file

@ -4149,6 +4149,15 @@ class TelegramAdapter(BasePlatformAdapter):
return bool(configured)
return os.getenv("TELEGRAM_GUEST_MODE", "false").lower() in {"true", "1", "yes", "on"}
def _telegram_exclusive_bot_mentions(self) -> bool:
"""Return whether explicit @...bot mentions exclusively route group messages."""
configured = self.config.extra.get("exclusive_bot_mentions")
if configured is not None:
if isinstance(configured, str):
return configured.lower() in {"true", "1", "yes", "on"}
return bool(configured)
return os.getenv("TELEGRAM_EXCLUSIVE_BOT_MENTIONS", "true").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:
@ -4259,6 +4268,42 @@ class TelegramAdapter(BasePlatformAdapter):
reply_user = getattr(message.reply_to_message, "from_user", None)
return bool(reply_user and getattr(reply_user, "id", None) == getattr(self._bot, "id", None))
@staticmethod
def _extract_bot_mention_usernames(message: Message) -> set[str]:
"""Extract explicit Telegram bot usernames mentioned in text/captions.
Telegram bot usernames are 5-32 characters and must end in "bot".
Entity mentions are authoritative. The raw-text fallback is intentionally narrow so
entity-less mobile/client variants still work without treating email
addresses or arbitrary substrings as bot mentions.
"""
mentioned_bot_usernames: set[str] = set()
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():
for entity in entities:
entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
if entity_type != "mention":
continue
offset = int(getattr(entity, "offset", -1))
length = int(getattr(entity, "length", 0))
if offset < 0 or length <= 0:
continue
handle = source_text[offset:offset + length].strip().lstrip("@").lower()
if re.fullmatch(r"[a-z0-9_]{2,29}bot", handle, re.IGNORECASE):
mentioned_bot_usernames.add(handle)
for raw_text in (getattr(message, "text", None), getattr(message, "caption", None)):
if not raw_text:
continue
for match in re.finditer(r"(?i)(?<![A-Za-z0-9_])@([A-Za-z0-9_]{2,29}bot)\b", raw_text):
mentioned_bot_usernames.add(match.group(1).lower())
return mentioned_bot_usernames
def _message_mentions_bot(self, message: Message) -> bool:
if not self._bot:
return False
@ -4273,7 +4318,7 @@ class TelegramAdapter(BasePlatformAdapter):
# 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 —
# a user without a public username). 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.
@ -4311,8 +4356,34 @@ class TelegramAdapter(BasePlatformAdapter):
continue
if command_text[at_index:].strip().lower() == expected:
return True
if bot_username and re.fullmatch(r"[a-z0-9_]{2,29}bot", bot_username, re.IGNORECASE):
return bot_username in self._extract_bot_mention_usernames(message)
return False
def _explicit_bot_mentions_exclude_self(self, message: Message) -> bool:
"""Return True when explicit bot handles target other bots, not this one.
Telegram groups can contain several Hermes bot profiles. A message like
``@bot3 hi @bot4`` must not wake ``@bot1`` through reply/wake-word
fallbacks. Treat explicit bot-handle mentions as an exclusive routing
hint: if at least one @...bot username is present and none matches this
adapter's own bot username, this adapter should ignore the message.
MessageEntity values are preferred, but some Telegram clients expose
selected bot handles as plain text in group messages. The raw-text
fallback is intentionally limited to usernames ending in "bot", which
Telegram requires for bot accounts.
"""
if not self._bot:
return False
bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower()
if not bot_username:
return False
mentioned_bot_usernames = self._extract_bot_mention_usernames(message)
return bool(mentioned_bot_usernames) and bot_username not in mentioned_bot_usernames
def _message_matches_mention_patterns(self, message: Message) -> bool:
if not self._mention_patterns:
return False
@ -4393,6 +4464,9 @@ class TelegramAdapter(BasePlatformAdapter):
chat_id_str = str(getattr(getattr(message, "chat", None), "id", ""))
if self._telegram_exclusive_bot_mentions() and self._explicit_bot_mentions_exclude_self(message):
return False
# Resolve guest-mode mention bypass once so _message_mentions_bot
# is not called redundantly in the normal flow below.
guest_mention = self._is_guest_mention(message)

View file

@ -9,12 +9,14 @@ def _make_adapter(
require_mention=None,
free_response_chats=None,
mention_patterns=None,
exclusive_bot_mentions=None,
ignored_threads=None,
allowed_topics=None,
allow_from=None,
group_allow_from=None,
allowed_chats=None,
guest_mode=None,
bot_username="hermes_bot",
):
from gateway.platforms.telegram import TelegramAdapter
@ -25,6 +27,8 @@ def _make_adapter(
extra["free_response_chats"] = free_response_chats
if mention_patterns is not None:
extra["mention_patterns"] = mention_patterns
if exclusive_bot_mentions is not None:
extra["exclusive_bot_mentions"] = exclusive_bot_mentions
if ignored_threads is not None:
extra["ignored_threads"] = ignored_threads
if allowed_topics is not None:
@ -51,7 +55,7 @@ def _make_adapter(
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._bot = SimpleNamespace(id=999, username=bot_username)
adapter._message_handler = AsyncMock()
adapter._pending_text_batches = {}
adapter._pending_text_batch_tasks = {}
@ -109,6 +113,10 @@ def _mention_entity(text, mention="@hermes_bot"):
return SimpleNamespace(type="mention", offset=offset, length=len(mention))
def _mention_entities(text, mentions):
return [_mention_entity(text, mention) for mention in mentions]
def _bot_command_entity(text, command):
"""Entity Telegram emits for a ``/cmd`` or ``/cmd@botname`` token.
@ -167,6 +175,51 @@ def test_group_messages_can_require_direct_trigger_via_config():
assert adapter_no_mention._should_process_message(_group_message("/status"), is_command=True) is True
def test_explicit_multi_bot_mentions_route_only_to_named_bots():
text = "@research_bot @ops_bot hi"
entities = _mention_entities(text, ["@research_bot", "@ops_bot"])
default_bot = _make_adapter(require_mention=True, bot_username="default_bot")
research_bot = _make_adapter(require_mention=True, bot_username="research_bot")
ops_bot = _make_adapter(require_mention=True, bot_username="ops_bot")
assert default_bot._should_process_message(_group_message(text, reply_to_bot=True, entities=entities)) is False
assert research_bot._should_process_message(_group_message(text, entities=entities)) is True
assert ops_bot._should_process_message(_group_message(text, entities=entities)) is True
def test_entityless_multi_bot_mentions_still_route_exclusively():
text = "@research_bot @ops_bot hi"
default_bot = _make_adapter(require_mention=True, bot_username="default_bot")
research_bot = _make_adapter(require_mention=True, bot_username="research_bot")
ops_bot = _make_adapter(require_mention=True, bot_username="ops_bot")
assert default_bot._should_process_message(_group_message(text, reply_to_bot=True)) is False
assert research_bot._should_process_message(_group_message(text)) is True
assert ops_bot._should_process_message(_group_message(text)) is True
def test_raw_bot_mention_fallback_does_not_match_email_or_substring():
adapter = _make_adapter(require_mention=True, bot_username="hermes_bot")
assert adapter._should_process_message(_group_message("email ops@hermes_bot.example")) is False
assert adapter._should_process_message(_group_message("prefix@hermes_bot hi")) is False
assert adapter._should_process_message(_group_message("hi @hermes_bot")) is True
def test_exclusive_bot_mentions_can_be_disabled_for_legacy_groups():
adapter = _make_adapter(
require_mention=True,
exclusive_bot_mentions=False,
bot_username="default_bot",
)
assert adapter._should_process_message(
_group_message("@research_bot hi", reply_to_bot=True)
) is True
def test_free_response_chats_bypass_mention_requirement():
adapter = _make_adapter(require_mention=True, free_response_chats=["-200"])
@ -274,6 +327,7 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
"telegram:\n"
" require_mention: true\n"
" guest_mode: true\n"
" exclusive_bot_mentions: true\n"
" mention_patterns:\n"
" - \"^\\\\s*chompy\\\\b\"\n"
" free_response_chats:\n"
@ -288,6 +342,7 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False)
monkeypatch.delenv("TELEGRAM_MENTION_PATTERNS", raising=False)
monkeypatch.delenv("TELEGRAM_EXCLUSIVE_BOT_MENTIONS", raising=False)
monkeypatch.delenv("TELEGRAM_GUEST_MODE", raising=False)
monkeypatch.delenv("TELEGRAM_FREE_RESPONSE_CHATS", raising=False)
monkeypatch.delenv("TELEGRAM_ALLOWED_CHATS", raising=False)
@ -298,6 +353,7 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
assert config is not None
assert __import__("os").environ["TELEGRAM_REQUIRE_MENTION"] == "true"
assert __import__("os").environ["TELEGRAM_GUEST_MODE"] == "true"
assert __import__("os").environ["TELEGRAM_EXCLUSIVE_BOT_MENTIONS"] == "true"
assert json.loads(__import__("os").environ["TELEGRAM_MENTION_PATTERNS"]) == [r"^\s*chompy\b"]
assert __import__("os").environ["TELEGRAM_FREE_RESPONSE_CHATS"] == "-123"
assert __import__("os").environ["TELEGRAM_ALLOWED_CHATS"] == "-100"
@ -307,6 +363,7 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
assert tg_cfg.extra.get("guest_mode") is True
assert tg_cfg.extra.get("allowed_chats") == ["-100"]
assert tg_cfg.extra.get("allowed_topics") == [8]
assert tg_cfg.extra.get("exclusive_bot_mentions") is True
def test_config_bridges_telegram_user_allowlists(monkeypatch, tmp_path):

View file

@ -253,6 +253,9 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
| `TELEGRAM_WEBHOOK_PORT` | Local listen port for webhook server (default: `8443`) |
| `TELEGRAM_WEBHOOK_SECRET` | Secret token Telegram echoes back in each update for verification. **Required whenever `TELEGRAM_WEBHOOK_URL` is set** — the gateway refuses to start without it (GHSA-3vpc-7q5r-276h). Generate with `openssl rand -hex 32`. |
| `TELEGRAM_REACTIONS` | Enable emoji reactions on messages during processing (default: `false`) |
| `TELEGRAM_REQUIRE_MENTION` | Require an explicit trigger before responding in Telegram groups. Equivalent to `telegram.require_mention` in `config.yaml`. |
| `TELEGRAM_MENTION_PATTERNS` | JSON array, newline-separated list, or comma-separated list of regex wake-word patterns accepted when Telegram group mention gating is enabled. Equivalent to `telegram.mention_patterns`. |
| `TELEGRAM_EXCLUSIVE_BOT_MENTIONS` | When enabled, explicit `@...bot` mentions in Telegram groups route only to the mentioned bot usernames before reply or wake-word fallbacks run. Default: `true`. Equivalent to `telegram.exclusive_bot_mentions`. |
| `TELEGRAM_REPLY_TO_MODE` | Reply-reference behavior: `off`, `first` (default), or `all`. Matches the Discord pattern. |
| `TELEGRAM_IGNORED_THREADS` | Comma-separated Telegram forum topic/thread IDs where the bot never responds |
| `TELEGRAM_PROXY` | Proxy URL for Telegram connections — overrides `HTTPS_PROXY`. Supports `http://`, `https://`, `socks5://` |

View file

@ -307,9 +307,27 @@ Hermes Agent works in Telegram group chats with a few considerations:
- `@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`
- In groups with multiple Hermes bots, `telegram.exclusive_bot_mentions` keeps routing deterministic. When a message explicitly mentions one or more Telegram bot usernames, only the mentioned bot profiles process it; other Hermes bots ignore it before reply and wake-word fallbacks run. This is enabled by default.
- 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
### Multiple Hermes bots in one group
If you run several Hermes profiles in the same Telegram group, create one Telegram bot token per profile and start one gateway per profile. Do not reuse the same bot token in multiple running gateways; Telegram will reject concurrent polling for the same token.
Recommended group config:
```yaml
telegram:
require_mention: true
exclusive_bot_mentions: true
mention_patterns: []
```
With this setup, a group message like `@research_bot @ops_bot summarize this` is processed by `research_bot` and `ops_bot` only. Other Hermes bots in the group stay silent, even if the message is a reply to one of their earlier messages or would otherwise match a shared wake word.
Set `exclusive_bot_mentions: false` only for legacy groups where explicit mentions should not override reply and wake-word triggers.
### Troubleshooting: works in DMs but not groups
If the bot responds in a private chat but stays silent in a group, check these
@ -327,6 +345,9 @@ gates in order:
4. **Mention filters:** if `telegram.require_mention: true` is set, normal
group chatter is ignored unless the message is a slash command, reply to the
bot, `@botusername` mention, or configured `mention_patterns` match.
5. **Multi-bot routing:** if a group contains several bots, make sure each
Hermes profile uses a unique bot token and keep `exclusive_bot_mentions`
enabled unless you intentionally want legacy shared-trigger behavior.
Negative chat IDs are normal for Telegram groups and supergroups. If you use
chat-scoped authorization, put those IDs in `TELEGRAM_GROUP_ALLOWED_CHATS`, not
@ -339,6 +360,7 @@ Add this to `~/.hermes/config.yaml`:
```yaml
telegram:
require_mention: true
exclusive_bot_mentions: true
mention_patterns:
- "^\\s*chompy\\b"
ignored_threads: