mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-26 06:01:49 +00:00
feat(gateway): add Telegram guest mention mode
This commit is contained in:
parent
369cee018d
commit
55f518e521
6 changed files with 168 additions and 25 deletions
|
|
@ -657,6 +657,10 @@ platform_toolsets:
|
||||||
# platforms:
|
# platforms:
|
||||||
# telegram:
|
# telegram:
|
||||||
# reply_to_mode: "first" # off | first | all
|
# reply_to_mode: "first" # off | first | all
|
||||||
|
# # guest_mode lets explicit @mentions from non-allowlisted groups through.
|
||||||
|
# # Default false; ordinary messages, replies, and regex wake words stay blocked.
|
||||||
|
# guest_mode: false
|
||||||
|
# # allowed_chats: ["-1001234567890"]
|
||||||
# extra:
|
# extra:
|
||||||
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
|
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -896,6 +896,8 @@ def load_gateway_config() -> GatewayConfig:
|
||||||
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(_effective_rm).lower()
|
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(_effective_rm).lower()
|
||||||
if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
|
if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
|
||||||
os.environ["TELEGRAM_MENTION_PATTERNS"] = json.dumps(telegram_cfg["mention_patterns"])
|
os.environ["TELEGRAM_MENTION_PATTERNS"] = json.dumps(telegram_cfg["mention_patterns"])
|
||||||
|
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")
|
frc = telegram_cfg.get("free_response_chats")
|
||||||
if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
|
if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
|
||||||
if isinstance(frc, list):
|
if isinstance(frc, list):
|
||||||
|
|
@ -941,16 +943,17 @@ def load_gateway_config() -> GatewayConfig:
|
||||||
if isinstance(group_allowed_chats, list):
|
if isinstance(group_allowed_chats, list):
|
||||||
group_allowed_chats = ",".join(str(v) for v in group_allowed_chats)
|
group_allowed_chats = ",".join(str(v) for v in group_allowed_chats)
|
||||||
os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats)
|
os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats)
|
||||||
if "disable_link_previews" in telegram_cfg:
|
for _telegram_extra_key in ("guest_mode", "disable_link_previews"):
|
||||||
plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {})
|
if _telegram_extra_key in telegram_cfg:
|
||||||
if not isinstance(plat_data, dict):
|
plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {})
|
||||||
plat_data = {}
|
if not isinstance(plat_data, dict):
|
||||||
platforms_data[Platform.TELEGRAM.value] = plat_data
|
plat_data = {}
|
||||||
extra = plat_data.setdefault("extra", {})
|
platforms_data[Platform.TELEGRAM.value] = plat_data
|
||||||
if not isinstance(extra, dict):
|
extra = plat_data.setdefault("extra", {})
|
||||||
extra = {}
|
if not isinstance(extra, dict):
|
||||||
plat_data["extra"] = extra
|
extra = {}
|
||||||
extra["disable_link_previews"] = telegram_cfg["disable_link_previews"]
|
plat_data["extra"] = extra
|
||||||
|
extra[_telegram_extra_key] = telegram_cfg[_telegram_extra_key]
|
||||||
|
|
||||||
whatsapp_cfg = yaml_cfg.get("whatsapp", {})
|
whatsapp_cfg = yaml_cfg.get("whatsapp", {})
|
||||||
if isinstance(whatsapp_cfg, dict):
|
if isinstance(whatsapp_cfg, dict):
|
||||||
|
|
|
||||||
|
|
@ -3127,6 +3127,15 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||||
return bool(configured)
|
return bool(configured)
|
||||||
return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")
|
return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")
|
||||||
|
|
||||||
|
def _telegram_guest_mode(self) -> bool:
|
||||||
|
"""Return whether non-allowlisted groups may trigger via direct @mention."""
|
||||||
|
configured = self.config.extra.get("guest_mode")
|
||||||
|
if configured is not None:
|
||||||
|
if isinstance(configured, str):
|
||||||
|
return configured.lower() in ("true", "1", "yes", "on")
|
||||||
|
return bool(configured)
|
||||||
|
return os.getenv("TELEGRAM_GUEST_MODE", "false").lower() in ("true", "1", "yes", "on")
|
||||||
|
|
||||||
def _telegram_free_response_chats(self) -> set[str]:
|
def _telegram_free_response_chats(self) -> set[str]:
|
||||||
raw = self.config.extra.get("free_response_chats")
|
raw = self.config.extra.get("free_response_chats")
|
||||||
if raw is None:
|
if raw is None:
|
||||||
|
|
@ -3286,6 +3295,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _is_guest_mention(self, message: Message) -> bool:
|
||||||
|
"""Return True for the narrow guest-mode bypass: group + explicit bot mention."""
|
||||||
|
return (
|
||||||
|
self._telegram_guest_mode()
|
||||||
|
and self._is_group_chat(message)
|
||||||
|
and self._message_mentions_bot(message)
|
||||||
|
)
|
||||||
|
|
||||||
def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]:
|
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):
|
if not text or not self._bot or not getattr(self._bot, "username", None):
|
||||||
return text
|
return text
|
||||||
|
|
@ -3297,16 +3314,18 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||||
"""Apply Telegram group trigger rules.
|
"""Apply Telegram group trigger rules.
|
||||||
|
|
||||||
DMs remain unrestricted. Group/supergroup messages are accepted when:
|
DMs remain unrestricted. Group/supergroup messages are accepted when:
|
||||||
- the chat passes the ``allowed_chats`` whitelist (when set)
|
- the chat passes the ``allowed_chats`` whitelist (when set), or
|
||||||
|
``guest_mode`` is enabled and the bot is explicitly mentioned
|
||||||
- the chat is explicitly allowlisted in ``free_response_chats``
|
- the chat is explicitly allowlisted in ``free_response_chats``
|
||||||
- ``require_mention`` is disabled
|
- ``require_mention`` is disabled
|
||||||
- the message replies to the bot
|
- the message replies to the bot
|
||||||
- the bot is @mentioned
|
- the bot is @mentioned
|
||||||
- the text/caption matches a configured regex wake-word pattern
|
- the text/caption matches a configured regex wake-word pattern
|
||||||
|
|
||||||
When ``allowed_chats`` is non-empty, it acts as a hard gate — messages
|
When ``allowed_chats`` is non-empty, it remains a hard gate except for
|
||||||
from any chat not in the list are ignored regardless of the other
|
the narrow ``guest_mode`` bypass: group/supergroup messages that
|
||||||
rules. When ``require_mention`` is enabled, slash commands are not given
|
explicitly @mention this bot. Replies and regex wake words do not bypass
|
||||||
|
``allowed_chats``. When ``require_mention`` is enabled, slash commands are not given
|
||||||
special treatment — they must pass the same mention/reply checks
|
special treatment — they must pass the same mention/reply checks
|
||||||
as any other group message. Users can still trigger commands via
|
as any other group message. Users can still trigger commands via
|
||||||
the Telegram bot menu (``/command@botname``) or by explicitly
|
the Telegram bot menu (``/command@botname``) or by explicitly
|
||||||
|
|
@ -3315,14 +3334,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||||
"""
|
"""
|
||||||
if not self._is_group_chat(message):
|
if not self._is_group_chat(message):
|
||||||
return True
|
return True
|
||||||
# allowed_chats check (whitelist — must pass before other gating).
|
|
||||||
# When set, group messages from chats NOT in this whitelist are
|
|
||||||
# silently ignored, even if @mentioned. DMs are already excluded above.
|
|
||||||
allowed = self._telegram_allowed_chats()
|
|
||||||
if allowed:
|
|
||||||
chat_id_str = str(getattr(getattr(message, "chat", None), "id", ""))
|
|
||||||
if chat_id_str not in allowed:
|
|
||||||
return False
|
|
||||||
thread_id = getattr(message, "message_thread_id", None)
|
thread_id = getattr(message, "message_thread_id", None)
|
||||||
if thread_id is not None:
|
if thread_id is not None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -3330,7 +3342,19 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||||
return False
|
return False
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
logger.warning("[%s] Ignoring non-numeric Telegram message_thread_id: %r", self.name, thread_id)
|
logger.warning("[%s] Ignoring non-numeric Telegram message_thread_id: %r", self.name, thread_id)
|
||||||
if str(getattr(getattr(message, "chat", None), "id", "")) in self._telegram_free_response_chats():
|
|
||||||
|
chat_id_str = str(getattr(getattr(message, "chat", None), "id", ""))
|
||||||
|
guest_mention = self._is_guest_mention(message)
|
||||||
|
|
||||||
|
# allowed_chats check (whitelist). When set, group messages from chats
|
||||||
|
# outside the whitelist are ignored unless guest_mode permits this
|
||||||
|
# exact message as an explicit direct mention. DMs are excluded above.
|
||||||
|
allowed = self._telegram_allowed_chats()
|
||||||
|
if allowed and chat_id_str not in allowed:
|
||||||
|
return guest_mention
|
||||||
|
if guest_mention:
|
||||||
|
return True
|
||||||
|
if chat_id_str in self._telegram_free_response_chats():
|
||||||
return True
|
return True
|
||||||
if not self._telegram_require_mention():
|
if not self._telegram_require_mention():
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,10 @@ from gateway.config import Platform, PlatformConfig
|
||||||
# Telegram
|
# Telegram
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _make_telegram_adapter(*, allowed_chats=None, require_mention=None):
|
def _make_telegram_adapter(*, allowed_chats=None, require_mention=None, guest_mode=False):
|
||||||
from gateway.platforms.telegram import TelegramAdapter
|
from gateway.platforms.telegram import TelegramAdapter
|
||||||
|
|
||||||
extra = {}
|
extra = {"guest_mode": guest_mode}
|
||||||
if allowed_chats is not None:
|
if allowed_chats is not None:
|
||||||
extra["allowed_chats"] = allowed_chats
|
extra["allowed_chats"] = allowed_chats
|
||||||
if require_mention is not None:
|
if require_mention is not None:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ or corrupt user-visible content.
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -757,3 +758,72 @@ class TestEditMessageStreamingSafety:
|
||||||
"message_id": 456,
|
"message_id": 456,
|
||||||
"text": "final **bold**",
|
"text": "final **bold**",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Telegram guest mention gating
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _guest_test_adapter(*, guest_mode=True, require_mention=True, allowed_chats=None):
|
||||||
|
config = PlatformConfig(
|
||||||
|
enabled=True,
|
||||||
|
token="fake-token",
|
||||||
|
extra={
|
||||||
|
"guest_mode": guest_mode,
|
||||||
|
"require_mention": require_mention,
|
||||||
|
"allowed_chats": allowed_chats or ["-100200"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
adapter = object.__new__(TelegramAdapter)
|
||||||
|
adapter.config = config
|
||||||
|
adapter._bot = SimpleNamespace(id=999, username="hermes_bot")
|
||||||
|
adapter._mention_patterns = adapter._compile_mention_patterns()
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
def _guest_group_message(text, *, chat_id=-100201, entities=None, reply_to_bot=False):
|
||||||
|
reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999)) if reply_to_bot else None
|
||||||
|
return SimpleNamespace(
|
||||||
|
text=text,
|
||||||
|
caption=None,
|
||||||
|
entities=entities or [],
|
||||||
|
caption_entities=[],
|
||||||
|
message_thread_id=None,
|
||||||
|
chat=SimpleNamespace(id=chat_id, type="group"),
|
||||||
|
from_user=SimpleNamespace(id=111),
|
||||||
|
reply_to_message=reply_to_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _guest_mention_entity(text, mention="@hermes_bot"):
|
||||||
|
return SimpleNamespace(type="mention", offset=text.index(mention), length=len(mention))
|
||||||
|
|
||||||
|
|
||||||
|
class TestTelegramGuestMentionGating:
|
||||||
|
def test_guest_mode_allows_explicit_mention_outside_allowed_chats(self):
|
||||||
|
adapter = _guest_test_adapter(guest_mode=True, allowed_chats=["-100200"])
|
||||||
|
text = "please help @hermes_bot"
|
||||||
|
message = _guest_group_message(
|
||||||
|
text,
|
||||||
|
chat_id=-100201,
|
||||||
|
entities=[_guest_mention_entity(text)],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert adapter._should_process_message(message) is True
|
||||||
|
|
||||||
|
def test_guest_mode_does_not_allow_reply_outside_allowed_chats(self):
|
||||||
|
adapter = _guest_test_adapter(guest_mode=True, allowed_chats=["-100200"])
|
||||||
|
message = _guest_group_message("replying without mention", chat_id=-100201, reply_to_bot=True)
|
||||||
|
|
||||||
|
assert adapter._should_process_message(message) is False
|
||||||
|
|
||||||
|
def test_guest_mode_disabled_keeps_allowed_chats_as_hard_gate_for_mentions(self):
|
||||||
|
adapter = _guest_test_adapter(guest_mode=False, allowed_chats=["-100200"])
|
||||||
|
text = "please help @hermes_bot"
|
||||||
|
message = _guest_group_message(
|
||||||
|
text,
|
||||||
|
chat_id=-100201,
|
||||||
|
entities=[_guest_mention_entity(text)],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert adapter._should_process_message(message) is False
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ def _make_adapter(
|
||||||
ignored_threads=None,
|
ignored_threads=None,
|
||||||
allow_from=None,
|
allow_from=None,
|
||||||
group_allow_from=None,
|
group_allow_from=None,
|
||||||
|
allowed_chats=None,
|
||||||
|
guest_mode=None,
|
||||||
):
|
):
|
||||||
from gateway.platforms.telegram import TelegramAdapter
|
from gateway.platforms.telegram import TelegramAdapter
|
||||||
|
|
||||||
|
|
@ -28,6 +30,10 @@ def _make_adapter(
|
||||||
extra["allow_from"] = allow_from
|
extra["allow_from"] = allow_from
|
||||||
if group_allow_from is not None:
|
if group_allow_from is not None:
|
||||||
extra["group_allow_from"] = group_allow_from
|
extra["group_allow_from"] = group_allow_from
|
||||||
|
if allowed_chats is not None:
|
||||||
|
extra["allowed_chats"] = allowed_chats
|
||||||
|
if guest_mode is not None:
|
||||||
|
extra["guest_mode"] = guest_mode
|
||||||
|
|
||||||
adapter = object.__new__(TelegramAdapter)
|
adapter = object.__new__(TelegramAdapter)
|
||||||
adapter.platform = Platform.TELEGRAM
|
adapter.platform = Platform.TELEGRAM
|
||||||
|
|
@ -150,6 +156,36 @@ def test_free_response_chats_bypass_mention_requirement():
|
||||||
assert adapter._should_process_message(_group_message("hello everyone", chat_id=-201)) is False
|
assert adapter._should_process_message(_group_message("hello everyone", chat_id=-201)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_guest_mode_allows_only_direct_mentions_outside_allowed_chats():
|
||||||
|
adapter = _make_adapter(
|
||||||
|
require_mention=True,
|
||||||
|
allowed_chats=["-200"],
|
||||||
|
guest_mode=True,
|
||||||
|
mention_patterns=[r"^\s*chompy\b"],
|
||||||
|
)
|
||||||
|
|
||||||
|
mentioned = _group_message(
|
||||||
|
"hi @hermes_bot",
|
||||||
|
chat_id=-201,
|
||||||
|
entities=[_mention_entity("hi @hermes_bot")],
|
||||||
|
)
|
||||||
|
assert adapter._should_process_message(mentioned) is True
|
||||||
|
assert adapter._should_process_message(_group_message("reply", chat_id=-201, reply_to_bot=True)) is False
|
||||||
|
assert adapter._should_process_message(_group_message("chompy status", chat_id=-201)) is False
|
||||||
|
assert adapter._should_process_message(_group_message("hello", chat_id=-201)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_guest_mode_defaults_to_false_for_allowed_chat_bypass():
|
||||||
|
adapter = _make_adapter(require_mention=True, allowed_chats=["-200"], guest_mode=False)
|
||||||
|
|
||||||
|
mentioned = _group_message(
|
||||||
|
"hi @hermes_bot",
|
||||||
|
chat_id=-201,
|
||||||
|
entities=[_mention_entity("hi @hermes_bot")],
|
||||||
|
)
|
||||||
|
assert adapter._should_process_message(mentioned) is False
|
||||||
|
|
||||||
|
|
||||||
def test_ignored_threads_drop_group_messages_before_other_gates():
|
def test_ignored_threads_drop_group_messages_before_other_gates():
|
||||||
adapter = _make_adapter(require_mention=False, free_response_chats=["-200"], ignored_threads=[31, "42"])
|
adapter = _make_adapter(require_mention=False, free_response_chats=["-200"], ignored_threads=[31, "42"])
|
||||||
|
|
||||||
|
|
@ -179,6 +215,7 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
|
||||||
(hermes_home / "config.yaml").write_text(
|
(hermes_home / "config.yaml").write_text(
|
||||||
"telegram:\n"
|
"telegram:\n"
|
||||||
" require_mention: true\n"
|
" require_mention: true\n"
|
||||||
|
" guest_mode: true\n"
|
||||||
" mention_patterns:\n"
|
" mention_patterns:\n"
|
||||||
" - \"^\\\\s*chompy\\\\b\"\n"
|
" - \"^\\\\s*chompy\\\\b\"\n"
|
||||||
" free_response_chats:\n"
|
" free_response_chats:\n"
|
||||||
|
|
@ -189,14 +226,19 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
|
||||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||||
monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False)
|
monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False)
|
||||||
monkeypatch.delenv("TELEGRAM_MENTION_PATTERNS", raising=False)
|
monkeypatch.delenv("TELEGRAM_MENTION_PATTERNS", raising=False)
|
||||||
|
monkeypatch.delenv("TELEGRAM_GUEST_MODE", raising=False)
|
||||||
monkeypatch.delenv("TELEGRAM_FREE_RESPONSE_CHATS", raising=False)
|
monkeypatch.delenv("TELEGRAM_FREE_RESPONSE_CHATS", raising=False)
|
||||||
|
|
||||||
config = load_gateway_config()
|
config = load_gateway_config()
|
||||||
|
|
||||||
assert config is not None
|
assert config is not None
|
||||||
assert __import__("os").environ["TELEGRAM_REQUIRE_MENTION"] == "true"
|
assert __import__("os").environ["TELEGRAM_REQUIRE_MENTION"] == "true"
|
||||||
|
assert __import__("os").environ["TELEGRAM_GUEST_MODE"] == "true"
|
||||||
assert json.loads(__import__("os").environ["TELEGRAM_MENTION_PATTERNS"]) == [r"^\s*chompy\b"]
|
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_FREE_RESPONSE_CHATS"] == "-123"
|
||||||
|
tg_cfg = config.platforms.get(Platform.TELEGRAM)
|
||||||
|
assert tg_cfg is not None
|
||||||
|
assert tg_cfg.extra.get("guest_mode") is True
|
||||||
|
|
||||||
|
|
||||||
def test_config_bridges_telegram_user_allowlists(monkeypatch, tmp_path):
|
def test_config_bridges_telegram_user_allowlists(monkeypatch, tmp_path):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue