mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-04 07:31:58 +00:00
Observe unmentioned Telegram group messages
This commit is contained in:
parent
c6a992e3e3
commit
a9db0e2c74
4 changed files with 388 additions and 6 deletions
|
|
@ -1,8 +1,11 @@
|
|||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from gateway.config import Platform, PlatformConfig, load_gateway_config
|
||||
from gateway.platforms.base import MessageType
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_adapter(
|
||||
|
|
@ -15,7 +18,9 @@ def _make_adapter(
|
|||
allow_from=None,
|
||||
group_allow_from=None,
|
||||
allowed_chats=None,
|
||||
group_allowed_chats=None,
|
||||
guest_mode=None,
|
||||
observe_unmentioned_group_messages=None,
|
||||
bot_username="hermes_bot",
|
||||
):
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
|
|
@ -49,8 +54,14 @@ def _make_adapter(
|
|||
# environment; production adapters without this explicit key still fall
|
||||
# back to the env var.
|
||||
extra["allowed_chats"] = []
|
||||
if group_allowed_chats is not None:
|
||||
extra["group_allowed_chats"] = group_allowed_chats
|
||||
else:
|
||||
extra["group_allowed_chats"] = []
|
||||
if guest_mode is not None:
|
||||
extra["guest_mode"] = guest_mode
|
||||
if observe_unmentioned_group_messages is not None:
|
||||
extra["observe_unmentioned_group_messages"] = observe_unmentioned_group_messages
|
||||
|
||||
adapter = object.__new__(TelegramAdapter)
|
||||
adapter.platform = Platform.TELEGRAM
|
||||
|
|
@ -60,7 +71,12 @@ def _make_adapter(
|
|||
adapter._pending_text_batches = {}
|
||||
adapter._pending_text_batch_tasks = {}
|
||||
adapter._text_batch_delay_seconds = 0.01
|
||||
adapter._text_batch_split_delay_seconds = 0.01
|
||||
adapter._mention_patterns = adapter._compile_mention_patterns()
|
||||
adapter._forum_lock = asyncio.Lock()
|
||||
adapter._forum_command_registered = set()
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
# Trigger-gating tests don't exercise the allowlist gate (added by
|
||||
# #23795 + #24468). Force-authorize all senders so the trigger logic
|
||||
# under test runs. Without this, every fake message hits the new
|
||||
|
|
@ -74,6 +90,7 @@ def _group_message(
|
|||
*,
|
||||
chat_id=-100,
|
||||
from_user_id=111,
|
||||
from_user_name="Alice Example",
|
||||
thread_id=None,
|
||||
reply_to_bot=False,
|
||||
entities=None,
|
||||
|
|
@ -82,29 +99,34 @@ def _group_message(
|
|||
):
|
||||
reply_to_message = None
|
||||
if reply_to_bot:
|
||||
reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999))
|
||||
reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999), message_id=10, text="previous bot reply", caption=None)
|
||||
return SimpleNamespace(
|
||||
message_id=42,
|
||||
text=text,
|
||||
caption=caption,
|
||||
entities=entities or [],
|
||||
caption_entities=caption_entities or [],
|
||||
message_thread_id=thread_id,
|
||||
chat=SimpleNamespace(id=chat_id, type="group"),
|
||||
from_user=SimpleNamespace(id=from_user_id),
|
||||
is_topic_message=thread_id is not None,
|
||||
chat=SimpleNamespace(id=chat_id, type="group", title="Test Group", is_forum=thread_id is not None),
|
||||
from_user=SimpleNamespace(id=from_user_id, full_name=from_user_name, first_name=from_user_name.split()[0]),
|
||||
reply_to_message=reply_to_message,
|
||||
date=None,
|
||||
)
|
||||
|
||||
|
||||
def _dm_message(text="hello", *, from_user_id=111):
|
||||
return SimpleNamespace(
|
||||
message_id=43,
|
||||
text=text,
|
||||
caption=None,
|
||||
entities=[],
|
||||
caption_entities=[],
|
||||
message_thread_id=None,
|
||||
chat=SimpleNamespace(id=from_user_id, type="private"),
|
||||
from_user=SimpleNamespace(id=from_user_id),
|
||||
chat=SimpleNamespace(id=from_user_id, type="private", full_name="Alice Example", title=None, is_forum=False),
|
||||
from_user=SimpleNamespace(id=from_user_id, full_name="Alice Example", first_name="Alice"),
|
||||
reply_to_message=None,
|
||||
date=None,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -134,6 +156,157 @@ def test_group_messages_can_be_opened_via_config():
|
|||
assert adapter._should_process_message(_group_message("hello everyone")) is True
|
||||
|
||||
|
||||
def test_unmentioned_group_messages_can_be_observed_without_dispatching():
|
||||
async def _run():
|
||||
adapter = _make_adapter(
|
||||
require_mention=True,
|
||||
allowed_chats=["-100"],
|
||||
group_allowed_chats=["-100"],
|
||||
observe_unmentioned_group_messages=True,
|
||||
)
|
||||
store = _FakeSessionStore()
|
||||
adapter._session_store = store
|
||||
update = SimpleNamespace(
|
||||
update_id=1001,
|
||||
message=_group_message("side chatter"),
|
||||
effective_message=None,
|
||||
)
|
||||
|
||||
await adapter._handle_text_message(update, SimpleNamespace())
|
||||
|
||||
adapter._message_handler.assert_not_awaited()
|
||||
assert len(store.messages) == 1
|
||||
session_id, message, skip_db = store.messages[0]
|
||||
assert session_id == "telegram-group-session"
|
||||
assert skip_db is False
|
||||
assert message["role"] == "user"
|
||||
assert message["content"] == "[Alice Example|111]\nside chatter"
|
||||
assert message["observed"] is True
|
||||
assert message["message_id"] == "42"
|
||||
assert store.sources[0].chat_id == "-100"
|
||||
assert store.sources[0].chat_type == "group"
|
||||
assert store.sources[0].user_id is None
|
||||
assert store.sources[0].user_name is None
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_observed_group_context_uses_shared_source_and_prompt_for_later_mentions():
|
||||
async def _run():
|
||||
adapter = _make_adapter(
|
||||
require_mention=True,
|
||||
allowed_chats=["-100"],
|
||||
group_allowed_chats=["-100"],
|
||||
observe_unmentioned_group_messages=True,
|
||||
)
|
||||
adapter._session_store = _FakeSessionStore()
|
||||
text = "@hermes_bot what did Alice say?"
|
||||
msg = _group_message(
|
||||
text,
|
||||
from_user_id=222,
|
||||
from_user_name="Bob Example",
|
||||
entities=[_mention_entity(text)],
|
||||
)
|
||||
event = adapter._build_message_event(msg, MessageType.TEXT, update_id=1003)
|
||||
event.text = adapter._clean_bot_trigger_text(event.text)
|
||||
event.channel_prompt = "Existing topic prompt"
|
||||
|
||||
event = adapter._apply_telegram_group_observe_attribution(event)
|
||||
|
||||
assert event.source.chat_id == "-100"
|
||||
assert event.source.chat_type == "group"
|
||||
assert event.source.user_id is None
|
||||
assert event.source.user_name is None
|
||||
assert event.text == "[Bob Example|222]\nwhat did Alice say?"
|
||||
assert "Existing topic prompt" in event.channel_prompt
|
||||
assert "observed Telegram group context" in event.channel_prompt
|
||||
assert "current new message" in event.channel_prompt
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_unmentioned_group_observe_requires_chat_allowlist_for_shared_context():
|
||||
async def _run():
|
||||
adapter = _make_adapter(
|
||||
require_mention=True,
|
||||
allowed_chats=["-100"],
|
||||
observe_unmentioned_group_messages=True,
|
||||
)
|
||||
store = _FakeSessionStore()
|
||||
adapter._session_store = store
|
||||
update = SimpleNamespace(
|
||||
update_id=1004,
|
||||
message=_group_message("side chatter"),
|
||||
effective_message=None,
|
||||
)
|
||||
|
||||
await adapter._handle_text_message(update, SimpleNamespace())
|
||||
|
||||
adapter._message_handler.assert_not_awaited()
|
||||
assert store.messages == []
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_shared_group_observe_source_is_authorized_by_group_allowed_chats(monkeypatch):
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-100",
|
||||
chat_type="group",
|
||||
user_id=None,
|
||||
user_name=None,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_CHATS", "-100")
|
||||
monkeypatch.delenv("TELEGRAM_ALLOWED_CHATS", raising=False)
|
||||
|
||||
assert runner._is_user_authorized(source) is True
|
||||
|
||||
|
||||
def test_unmentioned_group_observe_respects_chat_allowlist():
|
||||
async def _run():
|
||||
adapter = _make_adapter(
|
||||
require_mention=True,
|
||||
allowed_chats=["-200"],
|
||||
group_allowed_chats=["-200"],
|
||||
observe_unmentioned_group_messages=True,
|
||||
)
|
||||
store = _FakeSessionStore()
|
||||
adapter._session_store = store
|
||||
update = SimpleNamespace(
|
||||
update_id=1002,
|
||||
message=_group_message("side chatter", chat_id=-201),
|
||||
effective_message=None,
|
||||
)
|
||||
|
||||
await adapter._handle_text_message(update, SimpleNamespace())
|
||||
|
||||
adapter._message_handler.assert_not_awaited()
|
||||
assert store.messages == []
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
class _FakeSessionEntry:
|
||||
session_id = "telegram-group-session"
|
||||
|
||||
|
||||
class _FakeSessionStore:
|
||||
def __init__(self):
|
||||
self.sources = []
|
||||
self.messages = []
|
||||
|
||||
def get_or_create_session(self, source):
|
||||
self.sources.append(source)
|
||||
return _FakeSessionEntry()
|
||||
|
||||
def append_to_transcript(self, session_id, message, skip_db=False):
|
||||
self.messages.append((session_id, message, skip_db))
|
||||
|
||||
|
||||
def test_group_messages_can_require_direct_trigger_via_config():
|
||||
adapter = _make_adapter(require_mention=True)
|
||||
|
||||
|
|
@ -349,12 +522,15 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
|
|||
" require_mention: true\n"
|
||||
" guest_mode: true\n"
|
||||
" exclusive_bot_mentions: true\n"
|
||||
" observe_unmentioned_group_messages: true\n"
|
||||
" mention_patterns:\n"
|
||||
" - \"^\\\\s*chompy\\\\b\"\n"
|
||||
" free_response_chats:\n"
|
||||
" - \"-123\"\n"
|
||||
" allowed_chats:\n"
|
||||
" - \"-100\"\n"
|
||||
" group_allowed_chats:\n"
|
||||
" - \"-100\"\n"
|
||||
" allowed_topics:\n"
|
||||
" - 8\n",
|
||||
encoding="utf-8",
|
||||
|
|
@ -365,8 +541,10 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
|
|||
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_OBSERVE_UNMENTIONED_GROUP_MESSAGES", raising=False)
|
||||
monkeypatch.delenv("TELEGRAM_FREE_RESPONSE_CHATS", raising=False)
|
||||
monkeypatch.delenv("TELEGRAM_ALLOWED_CHATS", raising=False)
|
||||
monkeypatch.delenv("TELEGRAM_GROUP_ALLOWED_CHATS", raising=False)
|
||||
monkeypatch.delenv("TELEGRAM_ALLOWED_TOPICS", raising=False)
|
||||
|
||||
config = load_gateway_config()
|
||||
|
|
@ -374,17 +552,21 @@ 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_OBSERVE_UNMENTIONED_GROUP_MESSAGES"] == "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"
|
||||
assert __import__("os").environ["TELEGRAM_GROUP_ALLOWED_CHATS"] == "-100"
|
||||
assert __import__("os").environ["TELEGRAM_ALLOWED_TOPICS"] == "8"
|
||||
tg_cfg = config.platforms.get(Platform.TELEGRAM)
|
||||
assert tg_cfg is not None
|
||||
assert tg_cfg.extra.get("guest_mode") is True
|
||||
assert tg_cfg.extra.get("allowed_chats") == ["-100"]
|
||||
assert tg_cfg.extra.get("group_allowed_chats") == ["-100"]
|
||||
assert tg_cfg.extra.get("allowed_topics") == [8]
|
||||
assert tg_cfg.extra.get("exclusive_bot_mentions") is True
|
||||
assert tg_cfg.extra.get("observe_unmentioned_group_messages") is True
|
||||
|
||||
|
||||
def test_config_bridges_telegram_user_allowlists(monkeypatch, tmp_path):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue