/ tests / gateway / test_dm_topics.py
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"