test_bot.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. """Tests for the chattolib bot framework (chattolib.bot).
  2. The realtime WebSocket handshake is not exercised here (that is the job of
  3. the live integration test); instead we feed synthetic realtime frames through
  4. the dispatcher and assert the typed events and the convenience verbs.
  5. """
  6. from __future__ import annotations
  7. from unittest.mock import AsyncMock
  8. from chattolib._pb.chatto.api.v1 import (
  9. messages_pb2,
  10. presence_pb2,
  11. rooms_pb2,
  12. )
  13. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  14. from chattolib.bot import (
  15. Bot,
  16. BotMessageEvent,
  17. BotPresenceEvent,
  18. BotTypingEvent,
  19. )
  20. from chattolib.client import ChattoClient
  21. from chattolib.realtime import _wrap_projection_event
  22. from chattolib.types import PresenceStatus, Room, User
  23. def _bot() -> Bot:
  24. client = ChattoClient(token="cht_BK_test", base_url="https://example.test")
  25. bot = Bot.from_client(client)
  26. bot._user = User(id="u_bot", login="felix_bot", display_name="Felix - the bot", is_bot=True)
  27. return bot
  28. def _mock(client: ChattoClient, service: str, method: str, response):
  29. svc = getattr(client._svc, service)
  30. setattr(svc, method, AsyncMock(return_value=response))
  31. return getattr(svc, method)
  32. # --- verbs ---------------------------------------------------------------
  33. async def test_say_posts_message():
  34. bot = _bot()
  35. resp = messages_pb2.CreateMessageResponse()
  36. resp.message.id = "e1"
  37. resp.message.room_id = "r1"
  38. resp.message.body = "hello"
  39. m = _mock(bot.client, "messages", "create_message", resp)
  40. msg = await bot.say("r1", "hello")
  41. assert msg.body == "hello"
  42. m.assert_awaited_once()
  43. async def test_reply_threads_to_original():
  44. bot = _bot()
  45. resp = messages_pb2.CreateMessageResponse()
  46. resp.message.id = "e2"
  47. resp.message.room_id = "r1"
  48. resp.message.body = "hi!"
  49. resp.message.in_reply_to = "e1"
  50. _mock(bot.client, "messages", "create_message", resp)
  51. target = BotMessageEvent(bot=bot, kind="message", message=resp.message)
  52. out = await bot.reply(target, "hi!")
  53. assert out.in_reply_to == "e1"
  54. async def test_react_and_unreact():
  55. bot = _bot()
  56. bot.client.add_reaction = AsyncMock(return_value=True) # type: ignore[method-assign]
  57. bot.client.remove_reaction = AsyncMock(return_value=True) # type: ignore[method-assign]
  58. assert await bot.react("r1", "e1", "👍") is True
  59. assert await bot.unreact("r1", "e1", "👍") is True
  60. async def test_set_presence_and_status():
  61. bot = _bot()
  62. bot.client.update_presence = AsyncMock(return_value=PresenceStatus.ONLINE) # type: ignore[method-assign]
  63. assert await bot.set_presence(PresenceStatus.ONLINE) is PresenceStatus.ONLINE
  64. bot.client.update_custom_status = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
  65. assert await bot.set_status("🛠️", "working") == {"ok": True}
  66. async def test_join_and_create_room():
  67. bot = _bot()
  68. join_resp = rooms_pb2.JoinRoomResponse()
  69. join_resp.room.id = "r1"
  70. join_resp.room.name = "general"
  71. _mock(bot.client, "rooms", "join_room", join_resp)
  72. room = await bot.join_room("r1")
  73. assert isinstance(room, Room) and room.id == "r1"
  74. create_resp = rooms_pb2.CreateRoomResponse()
  75. create_resp.room.id = "r2"
  76. create_resp.room.name = "new"
  77. _mock(bot.client, "rooms", "create_room", create_resp)
  78. created = await bot.create_room("new", "g1")
  79. assert created.name == "new"
  80. # --- event dispatch -------------------------------------------------------
  81. async def test_dispatch_message_event():
  82. bot = _bot()
  83. seen: list[BotMessageEvent] = []
  84. async def on_message(event: BotMessageEvent) -> None:
  85. seen.append(event)
  86. bot.on("message", on_message)
  87. # Build a projection frame carrying a message_posted timeline event.
  88. frame = realtime_pb2.RealtimeProjectionEvent()
  89. frame.id = "p1"
  90. op = frame.operations.add()
  91. op.room_timeline_event_upsert.room_id = "r1"
  92. evt = op.room_timeline_event_upsert.event
  93. mp = evt.message_posted
  94. mp.message.id = "e1"
  95. mp.message.room_id = "r1"
  96. mp.message.body = "hello @felix_bot"
  97. mp.message.actor_id = "u1"
  98. mp.message.created_at.FromJsonString("2026-01-01T00:00:00Z")
  99. await bot._handle_projection(_wrap_projection_event(frame))
  100. assert len(seen) == 1
  101. assert seen[0].message.body == "hello @felix_bot"
  102. assert seen[0].room_id == "r1"
  103. assert seen[0].is_mention # body mentions the bot's login
  104. async def test_dispatch_presence_event():
  105. bot = _bot()
  106. seen: list[BotPresenceEvent] = []
  107. async def on_presence(event: BotPresenceEvent) -> None:
  108. seen.append(event)
  109. bot.on("presence", on_presence)
  110. frame = realtime_pb2.RealtimeProjectionEvent()
  111. frame.id = "p2"
  112. op = frame.operations.add()
  113. op.presences_replace.statuses["u1"] = presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
  114. await bot._handle_projection(_wrap_projection_event(frame))
  115. assert len(seen) == 1
  116. assert seen[0].user_id == "u1"
  117. assert seen[0].status is PresenceStatus.ONLINE
  118. async def test_dispatch_live_typing():
  119. bot = _bot()
  120. seen: list[BotTypingEvent] = []
  121. async def on_typing(event: BotTypingEvent) -> None:
  122. seen.append(event)
  123. bot.on("typing", on_typing)
  124. from chattolib.realtime import RealtimeEvent
  125. live = RealtimeEvent(
  126. id="evt",
  127. created_at=None,
  128. actor_id="u1",
  129. kind="user_typing",
  130. payload=realtime_pb2.RealtimeTypingEvent(room_id="r1"),
  131. raw=None,
  132. )
  133. await bot._handle_live(live)
  134. assert len(seen) == 1
  135. assert seen[0].room_id == "r1"
  136. async def test_wildcard_handler_receives_all():
  137. bot = _bot()
  138. seen: list[str] = []
  139. async def on_any(event) -> None:
  140. seen.append(event.kind)
  141. bot.on("*", on_any)
  142. frame = realtime_pb2.RealtimeProjectionEvent()
  143. frame.id = "p3"
  144. op = frame.operations.add()
  145. op.presences_replace.statuses["u1"] = presence_pb2.PresenceStatus.PRESENCE_STATUS_AWAY
  146. await bot._handle_projection(_wrap_projection_event(frame))
  147. assert seen == ["presence"]
  148. async def test_a_failing_handler_does_not_stop_the_loop():
  149. bot = _bot()
  150. calls: list[int] = []
  151. async def bad(event) -> None:
  152. raise RuntimeError("boom")
  153. async def good(event) -> None:
  154. calls.append(1)
  155. bot.on("presence", bad)
  156. bot.on("presence", good)
  157. frame = realtime_pb2.RealtimeProjectionEvent()
  158. frame.id = "p4"
  159. op = frame.operations.add()
  160. op.presences_replace.statuses["u1"] = presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
  161. await bot._handle_projection(_wrap_projection_event(frame))
  162. assert calls == [1] # the good handler still ran after the bad one raised
  163. # --- group-aware joining & auto-join say() ------------------------------
  164. def _group_response(group_id: str, room_ids: list[str], *, member: bool = True):
  165. from chattolib._pb.chatto.api.v1 import room_directory_pb2
  166. resp = room_directory_pb2.ListRoomGroupsResponse()
  167. g = resp.groups.add()
  168. g.id = group_id
  169. g.name = "Group " + group_id
  170. for rid in room_ids:
  171. item = g.items.add()
  172. item.room.id = rid
  173. item.viewer_state.is_member = member
  174. return resp
  175. async def test_join_room_group():
  176. bot = _bot()
  177. from chattolib._pb.chatto.api.v1 import rooms_pb2
  178. join_resp = rooms_pb2.JoinRoomGroupResponse()
  179. join_resp.joined_room_ids.extend(["r1", "r2"])
  180. m = _mock(bot.client, "rooms", "join_room_group", join_resp)
  181. joined = await bot.join_room_group("g1")
  182. assert joined == ["r1", "r2"]
  183. m.assert_awaited_once()
  184. async def test_join_all_rooms_groups_then_ungrouped():
  185. bot = _bot()
  186. from chattolib._pb.chatto.api.v1 import room_directory_pb2, rooms_pb2
  187. # Two groups: g1 has r1,r2 (already members), g2 has r3 (not a member).
  188. groups_resp = room_directory_pb2.ListRoomGroupsResponse()
  189. g1 = groups_resp.groups.add()
  190. g1.id = "g1"
  191. for rid in ("r1", "r2"):
  192. it = g1.items.add()
  193. it.room.room.id = rid
  194. it.room.viewer_state.is_member = True
  195. g2 = groups_resp.groups.add()
  196. g2.id = "g2"
  197. it = g2.items.add()
  198. it.room.room.id = "r3"
  199. it.room.viewer_state.is_member = False
  200. _mock(bot.client, "room_directory", "list_room_groups", groups_resp)
  201. # join_room_group: g1 no-op, g2 joins r3.
  202. jg = rooms_pb2.JoinRoomGroupResponse()
  203. jg.joined_room_ids.extend(["r3"])
  204. bot.client.join_room_group = AsyncMock(return_value=["r3"]) # type: ignore[method-assign]
  205. # list_rooms returns the ungrouped room r4 (not a member) plus the group rooms.
  206. rooms_resp = room_directory_pb2.ListRoomsResponse()
  207. for rid, member in (("r1", True), ("r2", True), ("r3", True), ("r4", False)):
  208. row = rooms_resp.rooms.add()
  209. row.room.id = rid
  210. row.viewer_state.is_member = member
  211. _mock(bot.client, "room_directory", "list_rooms", rooms_resp)
  212. # r4 is ungrouped and not a member -> join_room is called for it.
  213. from chattolib.types import Room
  214. async def fake_join_room(room_id):
  215. return Room(id=room_id)
  216. bot.client.join_room = fake_join_room # type: ignore[method-assign]
  217. joined = await bot.join_all_rooms()
  218. # r3 from the group join + r4 from the individual join.
  219. assert "r3" in joined and "r4" in joined
  220. assert fake_join_room is bot.client.join_room
  221. async def test_say_auto_joins_when_not_member():
  222. bot = _bot()
  223. from chattolib._pb.chatto.api.v1 import messages_pb2, rooms_pb2
  224. from chattolib.exceptions import ChattoConnectError
  225. post = messages_pb2.CreateMessageResponse()
  226. post.message.id = "e1"
  227. post.message.room_id = "r1"
  228. post.message.body = "hi"
  229. state = {"joined": False}
  230. async def fake_post(room_id, body, **kwargs):
  231. if not state["joined"]:
  232. raise ChattoConnectError("permission_denied", "not a member of this room")
  233. return post.message
  234. bot.client.post_message = fake_post # type: ignore[method-assign]
  235. join_resp = rooms_pb2.JoinRoomResponse()
  236. join_resp.room.id = "r1"
  237. async def fake_join(room_id):
  238. state["joined"] = True # joining makes the subsequent post succeed
  239. return join_resp.room
  240. bot.client.join_room = fake_join # type: ignore[method-assign]
  241. msg = await bot.say("r1", "hi")
  242. assert msg.id == "e1"
  243. assert state["joined"] is True
  244. async def test_say_no_join_when_disabled():
  245. bot = _bot()
  246. from chattolib.exceptions import ChattoConnectError
  247. async def deny(room_id, body, **kwargs):
  248. raise ChattoConnectError("permission_denied", "not a member of this room")
  249. bot.client.post_message = deny # type: ignore[method-assign]
  250. bot.client.join_room = AsyncMock() # type: ignore[method-assign]
  251. try:
  252. await bot.say("r1", "hi", join_if_needed=False)
  253. raise AssertionError("expected ChattoConnectError to propagate")
  254. except ChattoConnectError:
  255. pass
  256. bot.client.join_room.assert_not_awaited()