Files
hermes-agent/tests/gateway/test_dm_topics.py
ethernet 890bbbda1f Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts
#	apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts
#	apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts
#	apps/desktop/e2e/bot-mode-roster-localized.spec.ts
#	apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts
#	apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts
#	apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts
#	apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts
#	apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts
#	apps/desktop/e2e/bot-roster-user-sections.spec.ts
#	apps/desktop/e2e/bot-routines-pane-narrow.spec.ts
#	apps/desktop/e2e/bot-row-open-recent-session.spec.ts
#	apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts
#	apps/desktop/e2e/group-composer-auto-grow.spec.ts
#	apps/desktop/e2e/group-create-gate-remote-roster.spec.ts
#	apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts
#	apps/desktop/e2e/hosted-room-backend-continuity.spec.ts
#	apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts
#	apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts
#	apps/desktop/e2e/worktree-branch-status.spec.ts
#	apps/desktop/electron/backend-probes.test.ts
#	apps/desktop/electron/connection-apply.test.ts
#	apps/desktop/electron/desktop-electron-pin.test.ts
#	apps/desktop/electron/desktop-uninstall.test.ts
#	apps/desktop/electron/gateway-file-download-transport.test.ts
#	apps/desktop/electron/gateway-stop-before-update.test.ts
#	apps/desktop/electron/github-api-auth.test.ts
#	apps/desktop/electron/registry-primary-profile-scope.test.ts
#	apps/desktop/electron/update-api-check.test.ts
#	apps/desktop/electron/update-handoff-marker.test.ts
#	apps/desktop/electron/venv-blocker-scan.test.ts
#	apps/desktop/scripts/after-extract.test.mjs
#	apps/desktop/scripts/local-pack-publish.test.mjs
#	apps/desktop/scripts/tasks-scroll.test.mjs
#	apps/desktop/src/app/settings/model-settings.test.tsx
#	apps/desktop/src/app/updates-overlay.blockers.test.tsx
#	apps/desktop/src/components/desktop-install-overlay.test.tsx
#	apps/desktop/src/lib/update-copy.test.ts
#	scripts/ci/check_os_marker_fakes.py
#	tests-js/desktop-mac-usage-descriptions.test.ts
#	tests-js/node-engine-alignment.test.ts
#	tests/agent/lsp/test_install_and_lint_fixes.py
#	tests/agent/test_command_token_source.py
#	tests/agent/test_compression_boundary_hook.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/agent/test_custom_provider_ca_probes.py
#	tests/agent/test_endpoint_blackhole.py
#	tests/agent/test_estimator_parity.py
#	tests/agent/test_in_place_compaction.py
#	tests/agent/test_moa_loop_mode.py
#	tests/agent/test_model_metadata.py
#	tests/agent/test_skill_session_platform_gate.py
#	tests/agent/test_skill_utils.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/computer_use/test_doctor.py
#	tests/cron/test_codex_execution_paths.py
#	tests/cron/test_cron_bot_chat_delivery.py
#	tests/cron/test_cron_script.py
#	tests/cron/test_media_delivery_parity.py
#	tests/cron/test_misfire_catchup.py
#	tests/cron/test_parallel_pool.py
#	tests/cron/test_recurring_eagain_redispatch.py
#	tests/gateway/test_choice_picker.py
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_dingtalk.py
#	tests/gateway/test_feishu.py
#	tests/gateway/test_feishu_onboard.py
#	tests/gateway/test_gateway_shutdown.py
#	tests/gateway/test_matrix.py
#	tests/gateway/test_model_command_custom_providers.py
#	tests/gateway/test_reasoning_command.py
#	tests/gateway/test_runtime_footer.py
#	tests/gateway/test_session.py
#	tests/gateway/test_session_hygiene.py
#	tests/gateway/test_status.py
#	tests/gateway/test_teams.py
#	tests/gateway/test_turn_lease.py
#	tests/gateway/test_whatsapp_connect.py
#	tests/hermes_cli/test_approvals_command.py
#	tests/hermes_cli/test_auth_store_lock_concurrent.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_banner_git_state.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_compat_manifest_targets.py
#	tests/hermes_cli/test_computer_use_cli.py
#	tests/hermes_cli/test_cpr_local_leak.py
#	tests/hermes_cli/test_dashboard_auth_gate.py
#	tests/hermes_cli/test_dashboard_procs_kill_grace.py
#	tests/hermes_cli/test_desktop_lifecycle_windows_live.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_command_install.py
#	tests/hermes_cli/test_fleet_config_migration_windows_live.py
#	tests/hermes_cli/test_gateway.py
#	tests/hermes_cli/test_gateway_platform_gating.py
#	tests/hermes_cli/test_gateway_restart_loop.py
#	tests/hermes_cli/test_gateway_task_probe.py
#	tests/hermes_cli/test_gateway_wsl.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_install_cua_driver.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_command_exports.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_linux_desktop_entry.py
#	tests/hermes_cli/test_local_runtime.py
#	tests/hermes_cli/test_local_runtime_updates.py
#	tests/hermes_cli/test_managed_uv.py
#	tests/hermes_cli/test_mcp_reload_confirm_gate.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_npm_engine.py
#	tests/hermes_cli/test_personality_none.py
#	tests/hermes_cli/test_pet_toggle.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_plugin_event_bus.py
#	tests/hermes_cli/test_plugin_manifest_v2.py
#	tests/hermes_cli/test_plugin_packs.py
#	tests/hermes_cli/test_plugins_cmd.py
#	tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py
#	tests/hermes_cli/test_process_identity.py
#	tests/hermes_cli/test_profiles.py
#	tests/hermes_cli/test_profiles_sidebar_cache.py
#	tests/hermes_cli/test_pty_bridge.py
#	tests/hermes_cli/test_resolve_turn_limit.py
#	tests/hermes_cli/test_serve_runtime_inventory.py
#	tests/hermes_cli/test_session_vacuum_config.py
#	tests/hermes_cli/test_set_config_value.py
#	tests/hermes_cli/test_signal_handler_kanban_worker.py
#	tests/hermes_cli/test_slash_confirm_windows.py
#	tests/hermes_cli/test_stale_pid_guard.py
#	tests/hermes_cli/test_startup_fast_guards.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_telegram_managed_bot.py
#	tests/hermes_cli/test_tools_config.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_autostash.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_fetch_failure_classifier.py
#	tests/hermes_cli/test_update_fleet_probe_resume_token.py
#	tests/hermes_cli/test_update_handoff_backend_reap.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_host_obligation.py
#	tests/hermes_cli/test_update_import_guard.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_update_inventory.py
#	tests/hermes_cli/test_update_launchd_unloaded_gateway.py
#	tests/hermes_cli/test_update_missing_configured_deps.py
#	tests/hermes_cli/test_update_modified_notice.py
#	tests/hermes_cli/test_update_multiplex_migration_hook.py
#	tests/hermes_cli/test_update_no_gateway_restart.py
#	tests/hermes_cli/test_update_orphan_backend_reap.py
#	tests/hermes_cli/test_update_parked_branch_guard.py
#	tests/hermes_cli/test_update_post_pull_syntax_guard.py
#	tests/hermes_cli/test_update_receipt.py
#	tests/hermes_cli/test_update_self_lock.py
#	tests/hermes_cli/test_update_shim_fail_closed.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_update_sqlite_remediation.py
#	tests/hermes_cli/test_update_stale_dashboard.py
#	tests/hermes_cli/test_update_stale_virtualenv.py
#	tests/hermes_cli/test_update_venv_health.py
#	tests/hermes_cli/test_update_venv_ownership_preflight.py
#	tests/hermes_cli/test_update_wedged_gateway.py
#	tests/hermes_cli/test_update_yes_flag.py
#	tests/hermes_cli/test_update_zip_two_phase.py
#	tests/hermes_cli/test_urllib_security.py
#	tests/hermes_cli/test_ux_messages_auth_config.py
#	tests/hermes_cli/test_ux_messages_startup.py
#	tests/hermes_cli/test_venv_holder_classifier.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/hermes_cli/test_verify_core_dependencies.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_server_console_ws.py
#	tests/hermes_cli/test_web_server_ws_ping.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/hermes_state/test_fts_rebuild_admission.py
#	tests/hermes_state/test_hermes_state.py
#	tests/plugins/memory/test_memory_lazy_install.py
#	tests/plugins/test_google_meet_plugin.py
#	tests/plugins/test_langfuse_plugin.py
#	tests/plugins/test_security_guidance_plugin.py
#	tests/plugins/test_transform_llm_output_hook.py
#	tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_contributor_map.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/skills/test_competitor_news_monitor_skill.py
#	tests/skills/test_document_to_action_items_skill.py
#	tests/skills/test_google_workspace_setup.py
#	tests/skills/test_google_workspace_setup_deps.py
#	tests/skills/test_grounded_citations_skill.py
#	tests/skills/test_ip_as_logo_skill.py
#	tests/skills/test_live_dashboard_skill.py
#	tests/skills/test_mcp_oauth_remote_gateway_skill.py
#	tests/skills/test_office_document_skills.py
#	tests/skills/test_openclaw_migration.py
#	tests/skills/test_product_price_monitor_skill.py
#	tests/skills/test_scrollcraft_skill.py
#	tests/skills/test_setup_wizard_generator_skill.py
#	tests/skills/test_weekly_review_planning_skill.py
#	tests/test_engines_satisfiable.py
#	tests/test_fast_safe_load.py
#	tests/test_hermes_bootstrap.py
#	tests/test_hermes_constants.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_model_tools_async_bridge.py
#	tests/test_packaging_build_guard.py
#	tests/test_packaging_metadata.py
#	tests/test_yaml_indent_consistency.py
#	tests/tools/test_approval_timeout_overflow.py
#	tests/tools/test_base_environment.py
#	tests/tools/test_bot_mode_dm.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_hardening.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_clipboard.py
#	tests/tools/test_code_execution.py
#	tests/tools/test_code_execution_modes.py
#	tests/tools/test_code_execution_windows_env.py
#	tests/tools/test_computer_use.py
#	tests/tools/test_delegate_liveness_timeout.py
#	tests/tools/test_execute_code_approval_cluster.py
#	tests/tools/test_execution_flag_detection.py
#	tests/tools/test_fal_common.py
#	tests/tools/test_file_operations.py
#	tests/tools/test_file_tools.py
#	tests/tools/test_file_tools_cwd_resolution.py
#	tests/tools/test_file_tools_live.py
#	tests/tools/test_lazy_deps.py
#	tests/tools/test_lazy_deps_durable_target.py
#	tests/tools/test_lazy_deps_managed.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_local_tempdir.py
#	tests/tools/test_macos_protected_search.py
#	tests/tools/test_mcp_npx_cached_bin.py
#	tests/tools/test_oneshot_completion_linger.py
#	tests/tools/test_process_registry.py
#	tests/tools/test_read_file_schema_gating.py
#	tests/tools/test_skill_improvements.py
#	tests/tools/test_skills_sync.py
#	tests/tools/test_termux_api_detection.py
#	tests/tools/test_tirith_security.py
#	tests/tools/test_transcription_tools.py
#	tests/tools/test_tts_streaming.py
#	tests/tools/test_wake_word.py
#	tests/tui_gateway/test_compute_host_borrowed_lease.py
#	tests/tui_gateway/test_compute_host_turn_protocol.py
#	tests/tui_gateway/test_isolated_orphan_activity.py
#	tests/tui_gateway/test_protocol.py
#	tests/tui_gateway/test_slash_worker_profile_home.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	ui-tui/src/__tests__/terminalParity.test.ts
#	ui-tui/src/__tests__/termuxComposerLayout.test.ts
#	ui-tui/src/__tests__/textInputFastEcho.test.ts
2026-09-23 07:02:44 -04:00

473 lines
15 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_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("ruamel.yaml.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_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 ──