test_bot.py 12 KB

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