mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
Route Telegram multi-bot mentions exclusively
This commit is contained in:
parent
bb8e9ea83a
commit
ce4d857021
5 changed files with 162 additions and 2 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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://` |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue