test_dm_topics.py
1 """Tests for Telegram DM Private Chat Topics (Bot API 9.4). 2 3 Covers: 4 - _setup_dm_topics: loading persisted thread_ids from config 5 - _setup_dm_topics: creating new topics via API when no thread_id 6 - _persist_dm_topic_thread_id: saving thread_id back to config.yaml 7 - _get_dm_topic_info: looking up topic config by thread_id 8 - _cache_dm_topic_from_message: caching thread_ids from incoming messages 9 - _build_message_event: DM topic resolution in message events 10 """ 11 12 import asyncio 13 import os 14 import sys 15 from pathlib import Path 16 from types import SimpleNamespace 17 from unittest.mock import AsyncMock, MagicMock, patch, mock_open 18 19 import pytest 20 21 from gateway.config import PlatformConfig 22 23 24 def _ensure_telegram_mock(): 25 if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): 26 return 27 28 telegram_mod = MagicMock() 29 telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None) 30 telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" 31 telegram_mod.constants.ChatType.GROUP = "group" 32 telegram_mod.constants.ChatType.SUPERGROUP = "supergroup" 33 telegram_mod.constants.ChatType.CHANNEL = "channel" 34 telegram_mod.constants.ChatType.PRIVATE = "private" 35 36 for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): 37 sys.modules.setdefault(name, telegram_mod) 38 39 40 _ensure_telegram_mock() 41 42 from gateway.platforms.telegram import TelegramAdapter # noqa: E402 43 44 45 def _make_adapter(dm_topics_config=None, group_topics_config=None): 46 """Create a TelegramAdapter with optional DM/group topics config.""" 47 extra = {} 48 if dm_topics_config is not None: 49 extra["dm_topics"] = dm_topics_config 50 if group_topics_config is not None: 51 extra["group_topics"] = group_topics_config 52 config = PlatformConfig(enabled=True, token="***", extra=extra) 53 adapter = TelegramAdapter(config) 54 return adapter 55 56 57 # ── _setup_dm_topics: load persisted thread_ids ── 58 59 60 @pytest.mark.asyncio 61 async def test_setup_dm_topics_loads_persisted_thread_ids(): 62 """Topics with thread_id in config should be loaded into cache, not created.""" 63 adapter = _make_adapter([ 64 { 65 "chat_id": 111, 66 "topics": [ 67 {"name": "General", "thread_id": 100}, 68 {"name": "Work", "thread_id": 200}, 69 ], 70 } 71 ]) 72 adapter._bot = AsyncMock() 73 74 await adapter._setup_dm_topics() 75 76 # Both should be in cache 77 assert adapter._dm_topics["111:General"] == 100 78 assert adapter._dm_topics["111:Work"] == 200 79 # create_forum_topic should NOT have been called 80 adapter._bot.create_forum_topic.assert_not_called() 81 82 83 @pytest.mark.asyncio 84 async def test_setup_dm_topics_creates_when_no_thread_id(): 85 """Topics without thread_id should be created via API.""" 86 adapter = _make_adapter([ 87 { 88 "chat_id": 222, 89 "topics": [ 90 {"name": "NewTopic", "icon_color": 7322096}, 91 ], 92 } 93 ]) 94 adapter._bot = AsyncMock() 95 mock_topic = SimpleNamespace(message_thread_id=999) 96 adapter._bot.create_forum_topic.return_value = mock_topic 97 98 # Mock the persist method so it doesn't touch the filesystem 99 adapter._persist_dm_topic_thread_id = MagicMock() 100 101 await adapter._setup_dm_topics() 102 103 # Should have been created 104 adapter._bot.create_forum_topic.assert_called_once_with( 105 chat_id=222, name="NewTopic", icon_color=7322096, 106 ) 107 # Should be in cache 108 assert adapter._dm_topics["222:NewTopic"] == 999 109 # Should persist 110 adapter._persist_dm_topic_thread_id.assert_called_once_with(222, "NewTopic", 999) 111 112 113 @pytest.mark.asyncio 114 async def test_setup_dm_topics_mixed_persisted_and_new(): 115 """Mix of persisted and new topics should work correctly.""" 116 adapter = _make_adapter([ 117 { 118 "chat_id": 333, 119 "topics": [ 120 {"name": "Existing", "thread_id": 50}, 121 {"name": "New", "icon_color": 123}, 122 ], 123 } 124 ]) 125 adapter._bot = AsyncMock() 126 mock_topic = SimpleNamespace(message_thread_id=777) 127 adapter._bot.create_forum_topic.return_value = mock_topic 128 adapter._persist_dm_topic_thread_id = MagicMock() 129 130 await adapter._setup_dm_topics() 131 132 # Existing loaded from config 133 assert adapter._dm_topics["333:Existing"] == 50 134 # New created via API 135 assert adapter._dm_topics["333:New"] == 777 136 # Only one API call (for "New") 137 adapter._bot.create_forum_topic.assert_called_once() 138 139 140 @pytest.mark.asyncio 141 async def test_setup_dm_topics_skips_empty_config(): 142 """Empty dm_topics config should be a no-op.""" 143 adapter = _make_adapter([]) 144 adapter._bot = AsyncMock() 145 146 await adapter._setup_dm_topics() 147 148 adapter._bot.create_forum_topic.assert_not_called() 149 assert adapter._dm_topics == {} 150 151 152 @pytest.mark.asyncio 153 async def test_setup_dm_topics_no_config(): 154 """No dm_topics in config at all should be a no-op.""" 155 adapter = _make_adapter() 156 adapter._bot = AsyncMock() 157 158 await adapter._setup_dm_topics() 159 160 adapter._bot.create_forum_topic.assert_not_called() 161 162 163 # ── _create_dm_topic: error handling ── 164 165 166 @pytest.mark.asyncio 167 async def test_create_dm_topic_handles_duplicate_error(): 168 """Duplicate topic error should return None gracefully.""" 169 adapter = _make_adapter() 170 adapter._bot = AsyncMock() 171 adapter._bot.create_forum_topic.side_effect = Exception("topic_name_duplicate") 172 173 result = await adapter._create_dm_topic(chat_id=111, name="General") 174 175 assert result is None 176 177 178 @pytest.mark.asyncio 179 async def test_create_dm_topic_handles_generic_error(): 180 """Generic error should return None with warning.""" 181 adapter = _make_adapter() 182 adapter._bot = AsyncMock() 183 adapter._bot.create_forum_topic.side_effect = Exception("some random error") 184 185 result = await adapter._create_dm_topic(chat_id=111, name="General") 186 187 assert result is None 188 189 190 @pytest.mark.asyncio 191 async def test_create_dm_topic_returns_none_without_bot(): 192 """No bot instance should return None.""" 193 adapter = _make_adapter() 194 adapter._bot = None 195 196 result = await adapter._create_dm_topic(chat_id=111, name="General") 197 198 assert result is None 199 200 201 # ── _persist_dm_topic_thread_id ── 202 203 204 def test_persist_dm_topic_thread_id_writes_config(tmp_path): 205 """Should write thread_id into the correct topic in config.yaml.""" 206 import yaml 207 208 config_data = { 209 "platforms": { 210 "telegram": { 211 "extra": { 212 "dm_topics": [ 213 { 214 "chat_id": 111, 215 "topics": [ 216 {"name": "General", "icon_color": 123}, 217 {"name": "Work", "icon_color": 456}, 218 ], 219 } 220 ] 221 } 222 } 223 } 224 } 225 226 config_file = tmp_path / ".hermes" / "config.yaml" 227 config_file.parent.mkdir(parents=True) 228 with open(config_file, "w") as f: 229 yaml.dump(config_data, f) 230 231 adapter = _make_adapter() 232 233 with patch.object(Path, "home", return_value=tmp_path), \ 234 patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}): 235 adapter._persist_dm_topic_thread_id(111, "General", 999) 236 237 with open(config_file) as f: 238 result = yaml.safe_load(f) 239 240 topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"] 241 assert topics[0]["thread_id"] == 999 242 assert "thread_id" not in topics[1] # "Work" should be untouched 243 244 245 def test_persist_dm_topic_thread_id_skips_if_already_set(tmp_path): 246 """Should not overwrite an existing thread_id.""" 247 import yaml 248 249 config_data = { 250 "platforms": { 251 "telegram": { 252 "extra": { 253 "dm_topics": [ 254 { 255 "chat_id": 111, 256 "topics": [ 257 {"name": "General", "icon_color": 123, "thread_id": 500}, 258 ], 259 } 260 ] 261 } 262 } 263 } 264 } 265 266 config_file = tmp_path / ".hermes" / "config.yaml" 267 config_file.parent.mkdir(parents=True) 268 with open(config_file, "w") as f: 269 yaml.dump(config_data, f) 270 271 adapter = _make_adapter() 272 273 with patch.object(Path, "home", return_value=tmp_path): 274 adapter._persist_dm_topic_thread_id(111, "General", 999) 275 276 with open(config_file) as f: 277 result = yaml.safe_load(f) 278 279 topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"] 280 assert topics[0]["thread_id"] == 500 # unchanged 281 282 283 # ── _get_dm_topic_info ── 284 285 286 def test_persist_dm_topic_thread_id_preserves_config_on_write_failure(tmp_path): 287 """Failed writes should leave the original config.yaml intact.""" 288 import yaml 289 290 config_data = { 291 "platforms": { 292 "telegram": { 293 "extra": { 294 "dm_topics": [ 295 { 296 "chat_id": 111, 297 "topics": [ 298 {"name": "General", "icon_color": 123}, 299 ], 300 } 301 ] 302 } 303 } 304 } 305 } 306 307 config_file = tmp_path / ".hermes" / "config.yaml" 308 config_file.parent.mkdir(parents=True) 309 original_text = yaml.dump(config_data) 310 config_file.write_text(original_text, encoding="utf-8") 311 312 adapter = _make_adapter() 313 314 def fail_dump(*args, **kwargs): 315 raise RuntimeError("boom") 316 317 with patch.object(Path, "home", return_value=tmp_path), \ 318 patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}), \ 319 patch("yaml.dump", side_effect=fail_dump): 320 adapter._persist_dm_topic_thread_id(111, "General", 999) 321 322 assert config_file.read_text(encoding="utf-8") == original_text 323 result = yaml.safe_load(config_file.read_text(encoding="utf-8")) 324 topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"] 325 assert "thread_id" not in topics[0] 326 327 328 def test_get_dm_topic_info_finds_cached_topic(): 329 """Should return topic config when thread_id is in cache.""" 330 adapter = _make_adapter([ 331 { 332 "chat_id": 111, 333 "topics": [ 334 {"name": "General", "skill": "my-skill"}, 335 ], 336 } 337 ]) 338 adapter._dm_topics["111:General"] = 100 339 340 result = adapter._get_dm_topic_info("111", "100") 341 342 assert result is not None 343 assert result["name"] == "General" 344 assert result["skill"] == "my-skill" 345 346 347 def test_get_dm_topic_info_returns_none_for_unknown(): 348 """Should return None for unknown thread_id.""" 349 adapter = _make_adapter([ 350 { 351 "chat_id": 111, 352 "topics": [{"name": "General"}], 353 } 354 ]) 355 # Mock reload to avoid filesystem access 356 adapter._reload_dm_topics_from_config = lambda: None 357 358 result = adapter._get_dm_topic_info("111", "999") 359 360 assert result is None 361 362 363 def test_get_dm_topic_info_returns_none_without_config(): 364 """Should return None if no dm_topics config.""" 365 adapter = _make_adapter() 366 adapter._reload_dm_topics_from_config = lambda: None 367 368 result = adapter._get_dm_topic_info("111", "100") 369 370 assert result is None 371 372 373 def test_get_dm_topic_info_returns_none_for_none_thread(): 374 """Should return None if thread_id is None.""" 375 adapter = _make_adapter([ 376 {"chat_id": 111, "topics": [{"name": "General"}]} 377 ]) 378 379 result = adapter._get_dm_topic_info("111", None) 380 381 assert result is None 382 383 384 def test_get_dm_topic_info_hot_reloads_from_config(tmp_path): 385 """Should find a topic added to config after startup (hot-reload).""" 386 import yaml 387 388 # Start with empty topics 389 adapter = _make_adapter([ 390 {"chat_id": 111, "topics": []} 391 ]) 392 393 # Write config with a new topic + thread_id 394 config_data = { 395 "platforms": { 396 "telegram": { 397 "extra": { 398 "dm_topics": [ 399 { 400 "chat_id": 111, 401 "topics": [ 402 {"name": "NewProject", "thread_id": 555}, 403 ], 404 } 405 ] 406 } 407 } 408 } 409 } 410 config_file = tmp_path / ".hermes" / "config.yaml" 411 config_file.parent.mkdir(parents=True) 412 with open(config_file, "w") as f: 413 yaml.dump(config_data, f) 414 415 with patch.object(Path, "home", return_value=tmp_path), \ 416 patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}): 417 result = adapter._get_dm_topic_info("111", "555") 418 419 assert result is not None 420 assert result["name"] == "NewProject" 421 # Should now be cached 422 assert adapter._dm_topics["111:NewProject"] == 555 423 424 425 # ── _cache_dm_topic_from_message ── 426 427 428 def test_cache_dm_topic_from_message(): 429 """Should cache a new topic mapping.""" 430 adapter = _make_adapter() 431 432 adapter._cache_dm_topic_from_message("111", "100", "General") 433 434 assert adapter._dm_topics["111:General"] == 100 435 436 437 def test_cache_dm_topic_from_message_no_overwrite(): 438 """Should not overwrite an existing cached topic.""" 439 adapter = _make_adapter() 440 adapter._dm_topics["111:General"] = 100 441 442 adapter._cache_dm_topic_from_message("111", "999", "General") 443 444 assert adapter._dm_topics["111:General"] == 100 # unchanged 445 446 447 # ── _build_message_event: auto_skill binding ── 448 449 450 def _make_mock_message(chat_id=111, chat_type="private", text="hello", thread_id=None, 451 user_id=42, user_name="Test User", forum_topic_created=None): 452 """Create a mock Telegram Message for _build_message_event tests.""" 453 chat = SimpleNamespace( 454 id=chat_id, 455 type=chat_type, 456 title=None, 457 ) 458 # Add full_name attribute for DM chats 459 if not hasattr(chat, "full_name"): 460 chat.full_name = user_name 461 462 user = SimpleNamespace( 463 id=user_id, 464 full_name=user_name, 465 ) 466 467 msg = SimpleNamespace( 468 chat=chat, 469 from_user=user, 470 text=text, 471 message_thread_id=thread_id, 472 message_id=1001, 473 reply_to_message=None, 474 date=None, 475 forum_topic_created=forum_topic_created, 476 ) 477 return msg 478 479 480 def test_build_message_event_sets_auto_skill(): 481 """When topic has a skill binding, auto_skill should be set on the event.""" 482 from gateway.platforms.base import MessageType 483 484 adapter = _make_adapter([ 485 { 486 "chat_id": 111, 487 "topics": [ 488 {"name": "My Project", "skill": "accessibility-auditor", "thread_id": 100}, 489 ], 490 } 491 ]) 492 adapter._dm_topics["111:My Project"] = 100 493 494 msg = _make_mock_message(chat_id=111, thread_id=100, text="check this page") 495 event = adapter._build_message_event(msg, MessageType.TEXT) 496 497 assert event.auto_skill == "accessibility-auditor" 498 # chat_topic should be the clean topic name, no [skill: ...] suffix 499 assert event.source.chat_topic == "My Project" 500 501 502 def test_build_message_event_no_auto_skill_without_binding(): 503 """Topics without skill binding should have auto_skill=None.""" 504 from gateway.platforms.base import MessageType 505 506 adapter = _make_adapter([ 507 { 508 "chat_id": 111, 509 "topics": [ 510 {"name": "General", "thread_id": 200}, 511 ], 512 } 513 ]) 514 adapter._dm_topics["111:General"] = 200 515 516 msg = _make_mock_message(chat_id=111, thread_id=200) 517 event = adapter._build_message_event(msg, MessageType.TEXT) 518 519 assert event.auto_skill is None 520 assert event.source.chat_topic == "General" 521 522 523 def test_build_message_event_no_auto_skill_without_thread(): 524 """Regular DM messages (no thread_id) should have auto_skill=None.""" 525 from gateway.platforms.base import MessageType 526 527 adapter = _make_adapter() 528 msg = _make_mock_message(chat_id=111, thread_id=None) 529 event = adapter._build_message_event(msg, MessageType.TEXT) 530 531 assert event.auto_skill is None 532 533 534 # ── _build_message_event: group_topics skill binding ── 535 536 # The telegram mock sets sys.modules["telegram.constants"] = telegram_mod (root mock), 537 # so `from telegram.constants import ChatType` in telegram.py resolves to 538 # telegram_mod.ChatType — not telegram_mod.constants.ChatType. We must use 539 # the same ChatType object the production code sees so equality checks work. 540 from telegram.constants import ChatType as _ChatType # noqa: E402 541 542 543 def test_group_topic_skill_binding(): 544 """Group topic with skill config should set auto_skill on the event.""" 545 from gateway.platforms.base import MessageType 546 547 adapter = _make_adapter(group_topics_config=[ 548 { 549 "chat_id": -1001234567890, 550 "topics": [ 551 {"name": "Engineering", "thread_id": 5, "skill": "software-development"}, 552 {"name": "Sales", "thread_id": 12, "skill": "sales-framework"}, 553 ], 554 } 555 ]) 556 557 msg = _make_mock_message( 558 chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=5, text="hello" 559 ) 560 event = adapter._build_message_event(msg, MessageType.TEXT) 561 562 assert event.auto_skill == "software-development" 563 assert event.source.chat_topic == "Engineering" 564 565 566 def test_group_topic_skill_binding_second_topic(): 567 """A different thread_id in the same group should resolve its own skill.""" 568 from gateway.platforms.base import MessageType 569 570 adapter = _make_adapter(group_topics_config=[ 571 { 572 "chat_id": -1001234567890, 573 "topics": [ 574 {"name": "Engineering", "thread_id": 5, "skill": "software-development"}, 575 {"name": "Sales", "thread_id": 12, "skill": "sales-framework"}, 576 ], 577 } 578 ]) 579 580 msg = _make_mock_message( 581 chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=12, text="deal update" 582 ) 583 event = adapter._build_message_event(msg, MessageType.TEXT) 584 585 assert event.auto_skill == "sales-framework" 586 assert event.source.chat_topic == "Sales" 587 588 589 def test_group_topic_no_skill_binding(): 590 """Group topic without a skill key should have auto_skill=None but set chat_topic.""" 591 from gateway.platforms.base import MessageType 592 593 adapter = _make_adapter(group_topics_config=[ 594 { 595 "chat_id": -1001234567890, 596 "topics": [ 597 {"name": "General", "thread_id": 1}, 598 ], 599 } 600 ]) 601 602 msg = _make_mock_message( 603 chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=1, text="hey" 604 ) 605 event = adapter._build_message_event(msg, MessageType.TEXT) 606 607 assert event.auto_skill is None 608 assert event.source.chat_topic == "General" 609 610 611 def test_group_topic_unmapped_thread_id(): 612 """Thread ID not in config should fall through — no skill, no topic name.""" 613 from gateway.platforms.base import MessageType 614 615 adapter = _make_adapter(group_topics_config=[ 616 { 617 "chat_id": -1001234567890, 618 "topics": [ 619 {"name": "Engineering", "thread_id": 5, "skill": "software-development"}, 620 ], 621 } 622 ]) 623 624 msg = _make_mock_message( 625 chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=999, text="random" 626 ) 627 event = adapter._build_message_event(msg, MessageType.TEXT) 628 629 assert event.auto_skill is None 630 assert event.source.chat_topic is None 631 632 633 def test_group_topic_unmapped_chat_id(): 634 """Chat ID not in group_topics config should fall through silently.""" 635 from gateway.platforms.base import MessageType 636 637 adapter = _make_adapter(group_topics_config=[ 638 { 639 "chat_id": -1001234567890, 640 "topics": [ 641 {"name": "Engineering", "thread_id": 5, "skill": "software-development"}, 642 ], 643 } 644 ]) 645 646 msg = _make_mock_message( 647 chat_id=-1009999999999, chat_type=_ChatType.SUPERGROUP, thread_id=5, text="wrong group" 648 ) 649 event = adapter._build_message_event(msg, MessageType.TEXT) 650 651 assert event.auto_skill is None 652 assert event.source.chat_topic is None 653 654 655 def test_group_topic_no_config(): 656 """No group_topics config at all should be fine — no skill, no topic.""" 657 from gateway.platforms.base import MessageType 658 659 adapter = _make_adapter() # no group_topics_config 660 661 msg = _make_mock_message( 662 chat_id=-1001234567890, chat_type=_ChatType.GROUP, thread_id=5, text="hi" 663 ) 664 event = adapter._build_message_event(msg, MessageType.TEXT) 665 666 assert event.auto_skill is None 667 assert event.source.chat_topic is None 668 669 670 def test_group_topic_chat_id_int_string_coercion(): 671 """chat_id as string in config should match integer chat.id via str() coercion.""" 672 from gateway.platforms.base import MessageType 673 674 adapter = _make_adapter(group_topics_config=[ 675 { 676 "chat_id": "-1001234567890", # string, not int 677 "topics": [ 678 {"name": "Dev", "thread_id": "7", "skill": "hermes-agent-dev"}, 679 ], 680 } 681 ]) 682 683 msg = _make_mock_message( 684 chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=7, text="test" 685 ) 686 event = adapter._build_message_event(msg, MessageType.TEXT) 687 688 assert event.auto_skill == "hermes-agent-dev" 689 assert event.source.chat_topic == "Dev" 690 691 692 # ── _build_message_event: from_user=None fallback in DMs ── 693 694 695 def test_build_message_event_dm_from_user_none_falls_back_to_chat_id(): 696 """When from_user is None in a DM, user_id should fall back to chat.id.""" 697 from gateway.platforms.base import MessageType 698 699 adapter = _make_adapter() 700 msg = _make_mock_message(chat_id=12345, user_id=42, user_name="Alice") 701 # Simulate from_user being None (edge case on fresh restart / forwarded msg) 702 msg.from_user = None 703 704 event = adapter._build_message_event(msg, MessageType.TEXT) 705 706 # Should fall back to chat.id since chat_type is "dm" 707 assert event.source.user_id == "12345" 708 assert event.source.user_name == "Alice" # falls back to chat.full_name 709 710 711 def test_build_message_event_group_from_user_none_stays_none(): 712 """When from_user is None in a group, user_id should remain None.""" 713 from gateway.platforms.base import MessageType 714 715 adapter = _make_adapter() 716 msg = _make_mock_message( 717 chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, 718 user_id=42, user_name="Alice" 719 ) 720 msg.from_user = None 721 722 event = adapter._build_message_event(msg, MessageType.TEXT) 723 724 # Groups should NOT fall back — anonymous senders stay None 725 assert event.source.user_id is None 726 assert event.source.user_name is None 727 728 729 def test_build_message_event_dm_from_user_present_uses_user(): 730 """When from_user is present in a DM, it should be used (no fallback).""" 731 from gateway.platforms.base import MessageType 732 733 adapter = _make_adapter() 734 msg = _make_mock_message(chat_id=12345, user_id=99999, user_name="Bob") 735 736 event = adapter._build_message_event(msg, MessageType.TEXT) 737 738 # Normal case — from_user is used directly 739 assert event.source.user_id == "99999" 740 assert event.source.user_name == "Bob"