Activation reaches plugin discovery before the application dependencies exist. Give PM its own locked Python project and runtime so it can install or repair the application without importing that dependency tree. Keep PM outside the application workspace. A shared uv workspace resolves the application graph and cannot provide this isolation. Route mutations through an isolated worker and preserve transaction callbacks, cancellation, custom package registrations, and correlated receipts. Use the same runtime builder for source installs and packaged payloads. Keep offline wheelhouse support in that builder. Nix builds the independent PM lock as a separate derivation. Refuse lazy-disabled bootstrap before installing tools or dependencies. Move first-party YAML readers and writers to ruamel. Keep the application lock's transitive PyYAML requirements for third-party packages. Verification: - Focused canonical Python suite: 177 passed, 1 host-gated skip. - Electron backend probes: 12 passed. Electron typecheck passed. - Both uv locks, scoped lint, Bash syntax, and whitespace checks passed. - Cold activation, corrupt-app repair, offline staging, and relocation ran. - Built and exercised the Nix PM runtime and standalone YAML merge script. Six broader caller test files retain the same 24 failing test IDs as an archive of HEAD. The existing real-home guard blocks those tests before they can exercise the affected paths. No full-suite pass is claimed. Native Windows signing and full Bionic package execution remain unverified.
481 lines
16 KiB
Python
481 lines
16 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
|
|
|
|
|
|
# Use the shared, comprehensive telegram mock from conftest instead of a
|
|
# file-local one. The previous local installer differed from every other
|
|
# telegram test's stub in two ways — it registered a SEPARATE string-valued
|
|
# ``telegram.constants`` module (others register the root mock, so ParseMode
|
|
# members stay auto-generated MagicMock attributes) and its ``telegram.error``
|
|
# was a bare MagicMock (conftest defines real exception subclasses with PTB's
|
|
# hierarchy) — and it installed UNCONDITIONALLY (no real-library guard).
|
|
# Because it also force-reimported the adapter, the divergent stub leaked into
|
|
# sys.modules for the rest of the session: every later telegram test that
|
|
# asserts ParseMode repr or isinstance against telegram.error classes failed
|
|
# order-dependently in full runs while passing in isolation.
|
|
from tests.gateway.conftest import _ensure_telegram_mock # noqa: E402
|
|
|
|
_ensure_telegram_mock()
|
|
# Force reimport so the adapter binds to whatever sys.modules now holds
|
|
# (the shared mock, or the real library when it is installed) rather than a
|
|
# stub an earlier test file may have bound it to.
|
|
sys.modules.pop("plugins.platforms.telegram.adapter", None)
|
|
|
|
from plugins.platforms.telegram.adapter 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_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()
|
|
|
|
|
|
# ── _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_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
|
|
)
|
|
|
|
|
|
# ── _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 hermes_yaml as 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.safe_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
|
|
|
|
|
|
# ── _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 hermes_yaml as 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.safe_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("hermes_yaml.safe_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_hot_reloads_from_config(tmp_path):
|
|
"""Should find a topic added to config after startup (hot-reload)."""
|
|
import hermes_yaml as 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.safe_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_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.event 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.event 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"
|
|
|
|
|
|
# ── _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.event 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.event 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"
|
|
|
|
|
|
# ── _build_message_event: from_user=None fallback in DMs ──
|
|
|
|
|