mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
Remove unused imports (F401) and duplicate/shadowed import redefinitions (F811) across the codebase using ruff's safe autofixes. No behavioral changes -- imports only. - ~1400 safe autofixes applied across 644 files (net -1072 lines) - __init__.py re-exports preserved (excluded from F401 removal so public re-export surfaces stay intact) - Re-exports that are imported or monkeypatched by tests but look unused in their defining module are kept with explicit # noqa: F401 (gateway/run.py load_dotenv; run_agent re-exports from agent.message_sanitization, agent.context_compressor, agent.retry_utils, agent.prompt_builder, agent.process_bootstrap, agent.codex_responses_adapter) - Unsafe F841 (unused-variable) fixes deliberately skipped -- those can change behavior when the RHS has side effects - ruff lints remain disabled in pyproject.toml (only PLW1514 is selected); this is a one-time cleanup, not a config change Verification: - python -m compileall: clean - pytest --collect-only: all 27161 tests collect (zero import errors) - core entry points import clean (run_agent, model_tools, cli, toolsets, hermes_state, batch_runner, gateway) - static scan: every name any test imports directly from an edited module still resolves
904 lines
28 KiB
Python
904 lines
28 KiB
Python
"""Tests for Telegram DM Private Chat Topics (Bot API 9.4).
|
|
|
|
Covers:
|
|
- _setup_dm_topics: loading persisted thread_ids from config
|
|
- _setup_dm_topics: creating new topics via API when no thread_id
|
|
- _persist_dm_topic_thread_id: saving thread_id back to config.yaml
|
|
- _get_dm_topic_info: looking up topic config by thread_id
|
|
- _cache_dm_topic_from_message: caching thread_ids from incoming messages
|
|
- _build_message_event: DM topic resolution in message events
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from gateway.config import PlatformConfig
|
|
|
|
|
|
def _ensure_telegram_mock():
|
|
telegram_mod = MagicMock()
|
|
telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
|
|
|
|
# Register telegram.constants as a separate module mock so that
|
|
# ``from telegram.constants import ChatType`` resolves to our mock
|
|
# with string-valued members (not auto-generated MagicMocks).
|
|
constants_mod = MagicMock()
|
|
constants_mod.ParseMode.MARKDOWN_V2 = "MarkdownV2"
|
|
constants_mod.ChatType.GROUP = "group"
|
|
constants_mod.ChatType.SUPERGROUP = "supergroup"
|
|
constants_mod.ChatType.CHANNEL = "channel"
|
|
constants_mod.ChatType.PRIVATE = "private"
|
|
|
|
sys.modules["telegram"] = telegram_mod
|
|
sys.modules["telegram.ext"] = telegram_mod.ext
|
|
sys.modules["telegram.constants"] = constants_mod
|
|
sys.modules["telegram.request"] = telegram_mod.request
|
|
|
|
# Force reimport so the adapter picks up the mock ChatType.
|
|
sys.modules.pop("gateway.platforms.telegram", None)
|
|
|
|
|
|
_ensure_telegram_mock()
|
|
|
|
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
|
|
|
|
|
def _make_adapter(dm_topics_config=None, group_topics_config=None):
|
|
"""Create a TelegramAdapter with optional DM/group topics config."""
|
|
extra = {}
|
|
if dm_topics_config is not None:
|
|
extra["dm_topics"] = dm_topics_config
|
|
if group_topics_config is not None:
|
|
extra["group_topics"] = group_topics_config
|
|
config = PlatformConfig(enabled=True, token="***", extra=extra)
|
|
adapter = TelegramAdapter(config)
|
|
return adapter
|
|
|
|
|
|
# ── _setup_dm_topics: load persisted thread_ids ──
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_dm_topics_loads_persisted_thread_ids():
|
|
"""Topics with thread_id in config should be loaded into cache, not created."""
|
|
adapter = _make_adapter([
|
|
{
|
|
"chat_id": 111,
|
|
"topics": [
|
|
{"name": "General", "thread_id": 100},
|
|
{"name": "Work", "thread_id": 200},
|
|
],
|
|
}
|
|
])
|
|
adapter._bot = AsyncMock()
|
|
|
|
await adapter._setup_dm_topics()
|
|
|
|
# Both should be in cache
|
|
assert adapter._dm_topics["111:General"] == 100
|
|
assert adapter._dm_topics["111:Work"] == 200
|
|
# create_forum_topic should NOT have been called
|
|
adapter._bot.create_forum_topic.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_dm_topics_creates_when_no_thread_id():
|
|
"""Topics without thread_id should be created via API."""
|
|
adapter = _make_adapter([
|
|
{
|
|
"chat_id": 222,
|
|
"topics": [
|
|
{"name": "NewTopic", "icon_color": 7322096},
|
|
],
|
|
}
|
|
])
|
|
adapter._bot = AsyncMock()
|
|
mock_topic = SimpleNamespace(message_thread_id=999)
|
|
adapter._bot.create_forum_topic.return_value = mock_topic
|
|
|
|
# Mock the persist method so it doesn't touch the filesystem
|
|
adapter._persist_dm_topic_thread_id = MagicMock()
|
|
|
|
await adapter._setup_dm_topics()
|
|
|
|
# Should have been created
|
|
adapter._bot.create_forum_topic.assert_called_once_with(
|
|
chat_id=222, name="NewTopic", icon_color=7322096,
|
|
)
|
|
# Should be in cache
|
|
assert adapter._dm_topics["222:NewTopic"] == 999
|
|
# Should persist
|
|
adapter._persist_dm_topic_thread_id.assert_called_once_with(222, "NewTopic", 999)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_dm_topics_mixed_persisted_and_new():
|
|
"""Mix of persisted and new topics should work correctly."""
|
|
adapter = _make_adapter([
|
|
{
|
|
"chat_id": 333,
|
|
"topics": [
|
|
{"name": "Existing", "thread_id": 50},
|
|
{"name": "New", "icon_color": 123},
|
|
],
|
|
}
|
|
])
|
|
adapter._bot = AsyncMock()
|
|
mock_topic = SimpleNamespace(message_thread_id=777)
|
|
adapter._bot.create_forum_topic.return_value = mock_topic
|
|
adapter._persist_dm_topic_thread_id = MagicMock()
|
|
|
|
await adapter._setup_dm_topics()
|
|
|
|
# Existing loaded from config
|
|
assert adapter._dm_topics["333:Existing"] == 50
|
|
# New created via API
|
|
assert adapter._dm_topics["333:New"] == 777
|
|
# Only one API call (for "New")
|
|
adapter._bot.create_forum_topic.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_dm_topics_skips_empty_config():
|
|
"""Empty dm_topics config should be a no-op."""
|
|
adapter = _make_adapter([])
|
|
adapter._bot = AsyncMock()
|
|
|
|
await adapter._setup_dm_topics()
|
|
|
|
adapter._bot.create_forum_topic.assert_not_called()
|
|
assert adapter._dm_topics == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_dm_topics_no_config():
|
|
"""No dm_topics in config at all should be a no-op."""
|
|
adapter = _make_adapter()
|
|
adapter._bot = AsyncMock()
|
|
|
|
await adapter._setup_dm_topics()
|
|
|
|
adapter._bot.create_forum_topic.assert_not_called()
|
|
|
|
|
|
# ── _create_dm_topic: error handling ──
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_dm_topic_handles_duplicate_error():
|
|
"""Duplicate topic error should return None gracefully."""
|
|
adapter = _make_adapter()
|
|
adapter._bot = AsyncMock()
|
|
adapter._bot.create_forum_topic.side_effect = Exception("topic_name_duplicate")
|
|
|
|
result = await adapter._create_dm_topic(chat_id=111, name="General")
|
|
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_dm_topic_handles_generic_error():
|
|
"""Generic error should return None with warning."""
|
|
adapter = _make_adapter()
|
|
adapter._bot = AsyncMock()
|
|
adapter._bot.create_forum_topic.side_effect = Exception("some random error")
|
|
|
|
result = await adapter._create_dm_topic(chat_id=111, name="General")
|
|
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_dm_topic_returns_none_without_bot():
|
|
"""No bot instance should return None."""
|
|
adapter = _make_adapter()
|
|
adapter._bot = None
|
|
|
|
result = await adapter._create_dm_topic(chat_id=111, name="General")
|
|
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_dm_topic_creates_on_demand_and_persists():
|
|
"""Named delivery targets should create missing private DM topics on demand."""
|
|
adapter = _make_adapter()
|
|
adapter._bot = AsyncMock()
|
|
adapter._bot.create_forum_topic.return_value = SimpleNamespace(message_thread_id=444)
|
|
adapter._persist_dm_topic_thread_id = MagicMock()
|
|
|
|
result = await adapter.ensure_dm_topic("111", "On Demand")
|
|
|
|
assert result == "444"
|
|
adapter._bot.create_forum_topic.assert_called_once_with(
|
|
chat_id=111,
|
|
name="On Demand",
|
|
)
|
|
assert adapter._dm_topics["111:On Demand"] == 444
|
|
assert adapter._dm_topics_config == [
|
|
{"chat_id": 111, "topics": [{"name": "On Demand", "thread_id": 444}]}
|
|
]
|
|
adapter._persist_dm_topic_thread_id.assert_called_once_with(
|
|
111, "On Demand", 444, replace_existing=False
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_dm_topic_force_create_replaces_persisted_thread_id():
|
|
"""Refreshing a stale named topic should replace the cached persisted thread_id."""
|
|
adapter = _make_adapter()
|
|
bot = AsyncMock()
|
|
bot.create_forum_topic.return_value = SimpleNamespace(message_thread_id=777)
|
|
adapter._bot = bot
|
|
adapter._persist_dm_topic_thread_id = MagicMock()
|
|
adapter._dm_topics = {"111:General": 500}
|
|
adapter._dm_topics_config = [
|
|
{"chat_id": 111, "topics": [{"name": "General", "thread_id": 500}]}
|
|
]
|
|
|
|
result = await adapter.ensure_dm_topic("111", "General", force_create=True)
|
|
|
|
assert result == "777"
|
|
bot.create_forum_topic.assert_called_once_with(chat_id=111, name="General")
|
|
assert adapter._dm_topics["111:General"] == 777
|
|
assert adapter._dm_topics_config[0]["topics"][0]["thread_id"] == 777
|
|
adapter._persist_dm_topic_thread_id.assert_called_once_with(
|
|
111, "General", 777, replace_existing=True
|
|
)
|
|
|
|
|
|
# ── _persist_dm_topic_thread_id ──
|
|
|
|
|
|
def test_persist_dm_topic_thread_id_writes_config(tmp_path):
|
|
"""Should write thread_id into the correct topic in config.yaml."""
|
|
import yaml
|
|
|
|
config_data = {
|
|
"platforms": {
|
|
"telegram": {
|
|
"extra": {
|
|
"dm_topics": [
|
|
{
|
|
"chat_id": 111,
|
|
"topics": [
|
|
{"name": "General", "icon_color": 123},
|
|
{"name": "Work", "icon_color": 456},
|
|
],
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
config_file = tmp_path / ".hermes" / "config.yaml"
|
|
config_file.parent.mkdir(parents=True)
|
|
with open(config_file, "w") as f:
|
|
yaml.dump(config_data, f)
|
|
|
|
adapter = _make_adapter()
|
|
|
|
with patch.object(Path, "home", return_value=tmp_path), \
|
|
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
|
|
adapter._persist_dm_topic_thread_id(111, "General", 999)
|
|
|
|
with open(config_file) as f:
|
|
result = yaml.safe_load(f)
|
|
|
|
topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"]
|
|
assert topics[0]["thread_id"] == 999
|
|
assert "thread_id" not in topics[1] # "Work" should be untouched
|
|
|
|
|
|
def test_persist_dm_topic_thread_id_skips_if_already_set(tmp_path):
|
|
"""Should not overwrite an existing thread_id."""
|
|
import yaml
|
|
|
|
config_data = {
|
|
"platforms": {
|
|
"telegram": {
|
|
"extra": {
|
|
"dm_topics": [
|
|
{
|
|
"chat_id": 111,
|
|
"topics": [
|
|
{"name": "General", "icon_color": 123, "thread_id": 500},
|
|
],
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
config_file = tmp_path / ".hermes" / "config.yaml"
|
|
config_file.parent.mkdir(parents=True)
|
|
with open(config_file, "w") as f:
|
|
yaml.dump(config_data, f)
|
|
|
|
adapter = _make_adapter()
|
|
|
|
with patch.object(Path, "home", return_value=tmp_path):
|
|
adapter._persist_dm_topic_thread_id(111, "General", 999)
|
|
|
|
with open(config_file) as f:
|
|
result = yaml.safe_load(f)
|
|
|
|
topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"]
|
|
assert topics[0]["thread_id"] == 500 # unchanged
|
|
|
|
|
|
def test_persist_dm_topic_thread_id_replaces_existing_when_requested(tmp_path):
|
|
"""Forced refresh should overwrite a stale persisted thread_id."""
|
|
import yaml
|
|
|
|
config_data = {
|
|
"platforms": {
|
|
"telegram": {
|
|
"extra": {
|
|
"dm_topics": [
|
|
{
|
|
"chat_id": 111,
|
|
"topics": [
|
|
{"name": "General", "icon_color": 123, "thread_id": 500},
|
|
],
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
config_file = tmp_path / ".hermes" / "config.yaml"
|
|
config_file.parent.mkdir(parents=True)
|
|
with open(config_file, "w") as f:
|
|
yaml.dump(config_data, f)
|
|
|
|
adapter = _make_adapter()
|
|
|
|
with patch.object(Path, "home", return_value=tmp_path), \
|
|
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
|
|
adapter._persist_dm_topic_thread_id(111, "General", 999, replace_existing=True)
|
|
|
|
with open(config_file) as f:
|
|
result = yaml.safe_load(f)
|
|
|
|
topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"]
|
|
assert topics[0]["thread_id"] == 999
|
|
|
|
|
|
# ── _get_dm_topic_info ──
|
|
|
|
|
|
def test_persist_dm_topic_thread_id_preserves_config_on_write_failure(tmp_path):
|
|
"""Failed writes should leave the original config.yaml intact."""
|
|
import yaml
|
|
|
|
config_data = {
|
|
"platforms": {
|
|
"telegram": {
|
|
"extra": {
|
|
"dm_topics": [
|
|
{
|
|
"chat_id": 111,
|
|
"topics": [
|
|
{"name": "General", "icon_color": 123},
|
|
],
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
config_file = tmp_path / ".hermes" / "config.yaml"
|
|
config_file.parent.mkdir(parents=True)
|
|
original_text = yaml.dump(config_data)
|
|
config_file.write_text(original_text, encoding="utf-8")
|
|
|
|
adapter = _make_adapter()
|
|
|
|
def fail_dump(*args, **kwargs):
|
|
raise RuntimeError("boom")
|
|
|
|
with patch.object(Path, "home", return_value=tmp_path), \
|
|
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}), \
|
|
patch("yaml.dump", side_effect=fail_dump):
|
|
adapter._persist_dm_topic_thread_id(111, "General", 999)
|
|
|
|
assert config_file.read_text(encoding="utf-8") == original_text
|
|
result = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
|
topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"]
|
|
assert "thread_id" not in topics[0]
|
|
|
|
|
|
def test_get_dm_topic_info_finds_cached_topic():
|
|
"""Should return topic config when thread_id is in cache."""
|
|
adapter = _make_adapter([
|
|
{
|
|
"chat_id": 111,
|
|
"topics": [
|
|
{"name": "General", "skill": "my-skill"},
|
|
],
|
|
}
|
|
])
|
|
adapter._dm_topics["111:General"] = 100
|
|
|
|
result = adapter._get_dm_topic_info("111", "100")
|
|
|
|
assert result is not None
|
|
assert result["name"] == "General"
|
|
assert result["skill"] == "my-skill"
|
|
|
|
|
|
def test_get_dm_topic_info_returns_none_for_unknown():
|
|
"""Should return None for unknown thread_id."""
|
|
adapter = _make_adapter([
|
|
{
|
|
"chat_id": 111,
|
|
"topics": [{"name": "General"}],
|
|
}
|
|
])
|
|
# Mock reload to avoid filesystem access
|
|
adapter._reload_dm_topics_from_config = lambda: None
|
|
|
|
result = adapter._get_dm_topic_info("111", "999")
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_get_dm_topic_info_returns_none_without_config():
|
|
"""Should return None if no dm_topics config."""
|
|
adapter = _make_adapter()
|
|
adapter._reload_dm_topics_from_config = lambda: None
|
|
|
|
result = adapter._get_dm_topic_info("111", "100")
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_get_dm_topic_info_returns_none_for_none_thread():
|
|
"""Should return None if thread_id is None."""
|
|
adapter = _make_adapter([
|
|
{"chat_id": 111, "topics": [{"name": "General"}]}
|
|
])
|
|
|
|
result = adapter._get_dm_topic_info("111", None)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_get_dm_topic_info_hot_reloads_from_config(tmp_path):
|
|
"""Should find a topic added to config after startup (hot-reload)."""
|
|
import yaml
|
|
|
|
# Start with empty topics
|
|
adapter = _make_adapter([
|
|
{"chat_id": 111, "topics": []}
|
|
])
|
|
|
|
# Write config with a new topic + thread_id
|
|
config_data = {
|
|
"platforms": {
|
|
"telegram": {
|
|
"extra": {
|
|
"dm_topics": [
|
|
{
|
|
"chat_id": 111,
|
|
"topics": [
|
|
{"name": "NewProject", "thread_id": 555},
|
|
],
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
config_file = tmp_path / ".hermes" / "config.yaml"
|
|
config_file.parent.mkdir(parents=True)
|
|
with open(config_file, "w") as f:
|
|
yaml.dump(config_data, f)
|
|
|
|
with patch.object(Path, "home", return_value=tmp_path), \
|
|
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
|
|
result = adapter._get_dm_topic_info("111", "555")
|
|
|
|
assert result is not None
|
|
assert result["name"] == "NewProject"
|
|
# Should now be cached
|
|
assert adapter._dm_topics["111:NewProject"] == 555
|
|
|
|
|
|
# ── _cache_dm_topic_from_message ──
|
|
|
|
|
|
def test_cache_dm_topic_from_message():
|
|
"""Should cache a new topic mapping."""
|
|
adapter = _make_adapter()
|
|
|
|
adapter._cache_dm_topic_from_message("111", "100", "General")
|
|
|
|
assert adapter._dm_topics["111:General"] == 100
|
|
|
|
|
|
def test_cache_dm_topic_from_message_no_overwrite():
|
|
"""Should not overwrite an existing cached topic."""
|
|
adapter = _make_adapter()
|
|
adapter._dm_topics["111:General"] = 100
|
|
|
|
adapter._cache_dm_topic_from_message("111", "999", "General")
|
|
|
|
assert adapter._dm_topics["111:General"] == 100 # unchanged
|
|
|
|
|
|
# ── _build_message_event: auto_skill binding ──
|
|
|
|
|
|
def _make_mock_message(chat_id=111, chat_type="private", text="hello", thread_id=None,
|
|
user_id=42, user_name="Test User", forum_topic_created=None,
|
|
is_topic_message=None, is_forum=None):
|
|
"""Create a mock Telegram Message for _build_message_event tests."""
|
|
chat = SimpleNamespace(
|
|
id=chat_id,
|
|
type=chat_type,
|
|
title=None,
|
|
)
|
|
if is_forum is not None:
|
|
chat.is_forum = is_forum
|
|
# Add full_name attribute for DM chats
|
|
if not hasattr(chat, "full_name"):
|
|
chat.full_name = user_name
|
|
|
|
user = SimpleNamespace(
|
|
id=user_id,
|
|
full_name=user_name,
|
|
)
|
|
|
|
if is_topic_message is None:
|
|
is_topic_message = bool(thread_id) if chat_type == "private" else None
|
|
|
|
msg = SimpleNamespace(
|
|
chat=chat,
|
|
from_user=user,
|
|
text=text,
|
|
message_thread_id=thread_id,
|
|
is_topic_message=is_topic_message,
|
|
message_id=1001,
|
|
reply_to_message=None,
|
|
date=None,
|
|
forum_topic_created=forum_topic_created,
|
|
)
|
|
return msg
|
|
|
|
|
|
def test_build_message_event_sets_auto_skill():
|
|
"""When topic has a skill binding, auto_skill should be set on the event."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter([
|
|
{
|
|
"chat_id": 111,
|
|
"topics": [
|
|
{"name": "My Project", "skill": "accessibility-auditor", "thread_id": 100},
|
|
],
|
|
}
|
|
])
|
|
adapter._dm_topics["111:My Project"] = 100
|
|
|
|
msg = _make_mock_message(chat_id=111, thread_id=100, text="check this page")
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
assert event.auto_skill == "accessibility-auditor"
|
|
# chat_topic should be the clean topic name, no [skill: ...] suffix
|
|
assert event.source.chat_topic == "My Project"
|
|
|
|
|
|
def test_build_message_event_no_auto_skill_without_binding():
|
|
"""Topics without skill binding should have auto_skill=None."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter([
|
|
{
|
|
"chat_id": 111,
|
|
"topics": [
|
|
{"name": "General", "thread_id": 200},
|
|
],
|
|
}
|
|
])
|
|
adapter._dm_topics["111:General"] = 200
|
|
|
|
msg = _make_mock_message(chat_id=111, thread_id=200)
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
assert event.auto_skill is None
|
|
assert event.source.chat_topic == "General"
|
|
|
|
|
|
def test_build_message_event_no_auto_skill_without_thread():
|
|
"""Regular DM messages (no thread_id) should have auto_skill=None."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter()
|
|
msg = _make_mock_message(chat_id=111, thread_id=None)
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
assert event.auto_skill is None
|
|
|
|
|
|
def test_build_message_event_filters_non_topic_dm_thread_id():
|
|
"""A DM reply-thread id should not be persisted unless Telegram marks it as a topic message."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter()
|
|
msg = _make_mock_message(chat_id=111, thread_id=777, is_topic_message=False)
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
assert event.source.thread_id is None
|
|
assert event.source.chat_topic is None
|
|
assert event.auto_skill is None
|
|
|
|
|
|
def test_build_message_event_preserves_true_dm_topic_thread_id():
|
|
"""True DM topic messages should keep their thread id for routing."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter([
|
|
{
|
|
"chat_id": 111,
|
|
"topics": [
|
|
{"name": "General", "thread_id": 200},
|
|
],
|
|
}
|
|
])
|
|
adapter._dm_topics["111:General"] = 200
|
|
|
|
msg = _make_mock_message(chat_id=111, thread_id=200, is_topic_message=True)
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
assert event.source.thread_id == "200"
|
|
assert event.source.chat_topic == "General"
|
|
|
|
|
|
# ── _build_message_event: group_topics skill binding ──
|
|
|
|
# The telegram mock sets sys.modules["telegram.constants"] = telegram_mod (root mock),
|
|
# so `from telegram.constants import ChatType` in telegram.py resolves to
|
|
# telegram_mod.ChatType — not telegram_mod.constants.ChatType. We must use
|
|
# the same ChatType object the production code sees so equality checks work.
|
|
from telegram.constants import ChatType as _ChatType # noqa: E402
|
|
|
|
|
|
def test_group_topic_skill_binding():
|
|
"""Group topic with skill config should set auto_skill on the event."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter(group_topics_config=[
|
|
{
|
|
"chat_id": -1001234567890,
|
|
"topics": [
|
|
{"name": "Engineering", "thread_id": 5, "skill": "software-development"},
|
|
{"name": "Sales", "thread_id": 12, "skill": "sales-framework"},
|
|
],
|
|
}
|
|
])
|
|
|
|
msg = _make_mock_message(
|
|
chat_id=-1001234567890,
|
|
chat_type=_ChatType.SUPERGROUP,
|
|
thread_id=5,
|
|
text="hello",
|
|
is_topic_message=True,
|
|
is_forum=True,
|
|
)
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
assert event.auto_skill == "software-development"
|
|
assert event.source.chat_topic == "Engineering"
|
|
|
|
|
|
def test_group_topic_skill_binding_second_topic():
|
|
"""A different thread_id in the same group should resolve its own skill."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter(group_topics_config=[
|
|
{
|
|
"chat_id": -1001234567890,
|
|
"topics": [
|
|
{"name": "Engineering", "thread_id": 5, "skill": "software-development"},
|
|
{"name": "Sales", "thread_id": 12, "skill": "sales-framework"},
|
|
],
|
|
}
|
|
])
|
|
|
|
msg = _make_mock_message(
|
|
chat_id=-1001234567890,
|
|
chat_type=_ChatType.SUPERGROUP,
|
|
thread_id=12,
|
|
text="deal update",
|
|
is_topic_message=True,
|
|
is_forum=True,
|
|
)
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
assert event.auto_skill == "sales-framework"
|
|
assert event.source.chat_topic == "Sales"
|
|
|
|
|
|
def test_group_topic_no_skill_binding():
|
|
"""Group topic without a skill key should have auto_skill=None but set chat_topic."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter(group_topics_config=[
|
|
{
|
|
"chat_id": -1001234567890,
|
|
"topics": [
|
|
{"name": "General", "thread_id": 1},
|
|
],
|
|
}
|
|
])
|
|
|
|
msg = _make_mock_message(
|
|
chat_id=-1001234567890,
|
|
chat_type=_ChatType.SUPERGROUP,
|
|
thread_id=1,
|
|
text="hey",
|
|
is_topic_message=True,
|
|
is_forum=True,
|
|
)
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
assert event.auto_skill is None
|
|
assert event.source.chat_topic == "General"
|
|
|
|
|
|
def test_group_topic_unmapped_thread_id():
|
|
"""Thread ID not in config should fall through — no skill, no topic name."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter(group_topics_config=[
|
|
{
|
|
"chat_id": -1001234567890,
|
|
"topics": [
|
|
{"name": "Engineering", "thread_id": 5, "skill": "software-development"},
|
|
],
|
|
}
|
|
])
|
|
|
|
msg = _make_mock_message(
|
|
chat_id=-1001234567890,
|
|
chat_type=_ChatType.SUPERGROUP,
|
|
thread_id=999,
|
|
text="random",
|
|
is_topic_message=True,
|
|
is_forum=True,
|
|
)
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
assert event.auto_skill is None
|
|
assert event.source.chat_topic is None
|
|
|
|
|
|
def test_group_topic_unmapped_chat_id():
|
|
"""Chat ID not in group_topics config should fall through silently."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter(group_topics_config=[
|
|
{
|
|
"chat_id": -1001234567890,
|
|
"topics": [
|
|
{"name": "Engineering", "thread_id": 5, "skill": "software-development"},
|
|
],
|
|
}
|
|
])
|
|
|
|
msg = _make_mock_message(
|
|
chat_id=-1009999999999,
|
|
chat_type=_ChatType.SUPERGROUP,
|
|
thread_id=5,
|
|
text="wrong group",
|
|
is_topic_message=True,
|
|
is_forum=True,
|
|
)
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
assert event.auto_skill is None
|
|
assert event.source.chat_topic is None
|
|
|
|
|
|
def test_group_topic_no_config():
|
|
"""No group_topics config at all should be fine — no skill, no topic."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter() # no group_topics_config
|
|
|
|
msg = _make_mock_message(
|
|
chat_id=-1001234567890, chat_type=_ChatType.GROUP, thread_id=5, text="hi"
|
|
)
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
assert event.auto_skill is None
|
|
assert event.source.chat_topic is None
|
|
|
|
|
|
def test_group_topic_chat_id_int_string_coercion():
|
|
"""chat_id as string in config should match integer chat.id via str() coercion."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter(group_topics_config=[
|
|
{
|
|
"chat_id": "-1001234567890", # string, not int
|
|
"topics": [
|
|
{"name": "Dev", "thread_id": "7", "skill": "hermes-agent-dev"},
|
|
],
|
|
}
|
|
])
|
|
|
|
msg = _make_mock_message(
|
|
chat_id=-1001234567890,
|
|
chat_type=_ChatType.SUPERGROUP,
|
|
thread_id=7,
|
|
text="test",
|
|
is_topic_message=True,
|
|
is_forum=True,
|
|
)
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
assert event.auto_skill == "hermes-agent-dev"
|
|
assert event.source.chat_topic == "Dev"
|
|
|
|
|
|
# ── _build_message_event: from_user=None fallback in DMs ──
|
|
|
|
|
|
def test_build_message_event_dm_from_user_none_falls_back_to_chat_id():
|
|
"""When from_user is None in a DM, user_id should fall back to chat.id."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter()
|
|
msg = _make_mock_message(chat_id=12345, user_id=42, user_name="Alice")
|
|
# Simulate from_user being None (edge case on fresh restart / forwarded msg)
|
|
msg.from_user = None
|
|
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
# Should fall back to chat.id since chat_type is "dm"
|
|
assert event.source.user_id == "12345"
|
|
assert event.source.user_name == "Alice" # falls back to chat.full_name
|
|
|
|
|
|
def test_build_message_event_group_from_user_none_stays_none():
|
|
"""When from_user is None in a group, user_id should remain None."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter()
|
|
msg = _make_mock_message(
|
|
chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP,
|
|
user_id=42, user_name="Alice"
|
|
)
|
|
msg.from_user = None
|
|
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
# Groups should NOT fall back — anonymous senders stay None
|
|
assert event.source.user_id is None
|
|
assert event.source.user_name is None
|
|
|
|
|
|
def test_build_message_event_dm_from_user_present_uses_user():
|
|
"""When from_user is present in a DM, it should be used (no fallback)."""
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter()
|
|
msg = _make_mock_message(chat_id=12345, user_id=99999, user_name="Bob")
|
|
|
|
event = adapter._build_message_event(msg, MessageType.TEXT)
|
|
|
|
# Normal case — from_user is used directly
|
|
assert event.source.user_id == "99999"
|
|
assert event.source.user_name == "Bob"
|