test_bot_integration.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. """Integration tests for the :class:`~chattolib.bot.Bot` realtime event path.
  2. These run against a live Chatto server and exercise the *receive* side of the
  3. bot framework end-to-end: open the ``/api/realtime`` WebSocket, subscribe to
  4. rooms, and confirm that posted messages come back as typed
  5. :class:`~chattolib.bot.BotMessageEvent` values.
  6. Run with::
  7. CHATTO_BOT_KEY=cht_BK_... pytest tests/test_bot_integration.py -v
  8. Requires the ``CHATTO_BOT_KEY`` environment variable (a bot key, used
  9. directly as a bearer token). The optional ``CHATTO_BASE_URL`` overrides the
  10. target server (defaults to the production ``https://chat.chatto.run``).
  11. Unlike ``test_integration.py`` (which needs a human login/password), a bot
  12. key is enough here — the bot posts to itself, so no second identity is
  13. required. The ``test_bot_receives_message_from_another_user`` test is skipped
  14. unless ``CHATTO_SECOND_LOGIN``/``CHATTO_SECOND_PASSWORD`` are also set.
  15. """
  16. import asyncio
  17. import os
  18. import pytest
  19. from chattolib.bot import Bot, BotMessageEvent
  20. # Applied to the two tests that need a bot key; the anonymous
  21. # test_public_server_metadata runs regardless.
  22. _requires_bot_key = pytest.mark.skipif(
  23. not os.environ.get("CHATTO_BOT_KEY"),
  24. reason="CHATTO_BOT_KEY env var required",
  25. )
  26. BASE_URL = os.environ.get("CHATTO_BASE_URL", "https://chat.chatto.run")
  27. BOT_KEY = os.environ.get("CHATTO_BOT_KEY")
  28. SENTINEL = "[chattolib-bot-integration] receive-path check"
  29. async def _login_bot() -> Bot:
  30. assert BOT_KEY is not None # guaranteed by the module-level skipif
  31. return await Bot.login(BOT_KEY, base_url=BASE_URL)
  32. async def _pick_member_room_id(bot: Bot) -> str:
  33. """Return the id of a room the bot is a member of (joining one if needed)."""
  34. rooms = await bot.list_rooms()
  35. target = next((r for r in rooms if r.room and r.viewer_state.is_member), None)
  36. if target is not None:
  37. return target.room.id
  38. # No member room yet — join the first room in the first group.
  39. groups = await bot.list_room_groups()
  40. if not groups or not groups[0].rooms:
  41. pytest.skip("bot has no rooms and no room groups to join")
  42. first = groups[0].rooms[0].room
  43. if first is None:
  44. pytest.skip("first room group has no rooms")
  45. await bot.join_room(first.id)
  46. return first.id
  47. async def _wait_for_event(
  48. bot: Bot,
  49. room_id: str,
  50. *,
  51. trigger,
  52. matches,
  53. timeout: float = 30.0,
  54. ) -> list[BotMessageEvent]:
  55. """Subscribe to ``room_id``'s timeline, fire ``trigger``, await the event.
  56. ``trigger`` is an awaitable fired *after* the stream is subscribed (so the
  57. posted message is not projected before we're listening). ``matches`` is
  58. called with each incoming :class:`BotMessageEvent`; the first call that
  59. returns ``True`` ends the wait. Returns the list of matching events.
  60. Uses ``bot.run(until=...)`` so the stream shuts down cleanly — cancelling
  61. the run task directly would propagate into the websocket recv loop.
  62. """
  63. received: list[BotMessageEvent] = []
  64. done = asyncio.Event()
  65. def on_event(event: BotMessageEvent) -> None:
  66. if matches(event):
  67. received.append(event)
  68. done.set()
  69. bot.on("message", on_event)
  70. stop = asyncio.Event()
  71. run_task = asyncio.create_task(bot.run(retained_room_ids=[room_id], until=stop))
  72. await asyncio.sleep(2) # let the stream connect + subscribe
  73. try:
  74. await trigger()
  75. await asyncio.wait_for(done.wait(), timeout=timeout)
  76. finally:
  77. stop.set() # tell run() to stop looping
  78. try:
  79. await asyncio.wait_for(run_task, timeout=10)
  80. except (TimeoutError, asyncio.CancelledError, Exception):
  81. run_task.cancel()
  82. try:
  83. await run_task
  84. except (asyncio.CancelledError, Exception):
  85. pass
  86. return received
  87. @_requires_bot_key
  88. async def test_bot_receives_its_own_message():
  89. """Post a message and confirm it comes back over the realtime stream.
  90. This is the core receive-path check: the bot subscribes to a room, posts
  91. into it, and the server projects the ``message_posted`` timeline event
  92. back to the bot as a ``BotMessageEvent``.
  93. """
  94. bot = await _login_bot()
  95. try:
  96. target_id = await _pick_member_room_id(bot)
  97. received = await _wait_for_event(
  98. bot,
  99. target_id,
  100. trigger=lambda: bot.say(target_id, SENTINEL),
  101. matches=lambda e: e.room_id == target_id and e.body == SENTINEL,
  102. )
  103. assert len(received) >= 1, "bot did not receive its own posted message"
  104. # The echoed event should carry the same body and the bot as actor.
  105. assert received[0].body == SENTINEL
  106. assert received[0].message.actor_id == bot.user.id
  107. finally:
  108. await bot.close()
  109. @_requires_bot_key
  110. async def test_bot_receives_message_from_another_bot():
  111. """Receive a message posted by a *different* bot (needs a 2nd bot key).
  112. Normal humans talk to Chatto through the web app, not this library, so the
  113. only "other identity" chattolib can express is a second bot. Skipped unless
  114. ``CHATTO_SECOND_BOT_KEY`` is set.
  115. """
  116. second_key = os.environ.get("CHATTO_SECOND_BOT_KEY")
  117. if not second_key:
  118. pytest.skip("CHATTO_SECOND_BOT_KEY not set")
  119. bot = await _login_bot()
  120. try:
  121. target_id = await _pick_member_room_id(bot)
  122. async def trigger() -> None:
  123. other = await Bot.login(second_key, base_url=BASE_URL)
  124. try:
  125. await other.say(target_id, SENTINEL)
  126. finally:
  127. await other.close()
  128. received = await _wait_for_event(
  129. bot,
  130. target_id,
  131. trigger=trigger,
  132. matches=lambda e: e.message.actor_id != bot.user.id and e.room_id == target_id,
  133. )
  134. assert len(received) >= 1, "bot did not receive the other bot's message"
  135. assert received[0].message.actor_id != bot.user.id
  136. finally:
  137. await bot.close()
  138. async def test_public_server_metadata():
  139. """Anonymous (no auth) server discovery still works — no bot key needed."""
  140. from chattolib.client import ChattoClient
  141. async with ChattoClient(base_url=BASE_URL) as anonymous:
  142. profile, login = await anonymous.get_server()
  143. assert profile.name
  144. assert profile.version