test_bot.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  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 protocol-4 realtime
  4. events through the dispatcher and assert the typed events and the
  5. convenience verbs.
  6. """
  7. from __future__ import annotations
  8. from unittest.mock import AsyncMock
  9. from chattolib._pb.chatto.api.v1 import messages_pb2, presence_pb2, rooms_pb2
  10. from chattolib._pb.chatto.realtime.v1 import events_pb2
  11. from chattolib.bot import (
  12. Bot,
  13. BotMessageEvent,
  14. BotPresenceEvent,
  15. BotPresencePreferenceEvent,
  16. BotReactionEvent,
  17. BotRoomEvent,
  18. BotTypingEvent,
  19. BotUserEvent,
  20. )
  21. from chattolib.client import ChattoClient
  22. from chattolib.realtime import RealtimeEvent
  23. from chattolib.types import BotInfo, Message, 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(
  28. id="u_bot",
  29. login="felix_bot",
  30. display_name="Felix - the bot",
  31. bot=BotInfo(),
  32. )
  33. return bot
  34. def _live(
  35. kind: str, payload, *, actor_id: str | None = None, event_id: str = "e1"
  36. ) -> RealtimeEvent:
  37. """Build a protocol-4 :class:`RealtimeEvent` envelope for the dispatcher."""
  38. return RealtimeEvent(
  39. id=event_id,
  40. created_at=None,
  41. actor_id=actor_id,
  42. cursor=None,
  43. kind=kind,
  44. payload=payload,
  45. raw=None,
  46. )
  47. def _mock(client: ChattoClient, service: str, method: str, response):
  48. svc = getattr(client._svc, service)
  49. setattr(svc, method, AsyncMock(return_value=response))
  50. return getattr(svc, method)
  51. # --- verbs ---------------------------------------------------------------
  52. async def test_say_posts_message():
  53. bot = _bot()
  54. resp = messages_pb2.CreateMessageResponse()
  55. resp.message.id = "e1"
  56. resp.message.room_id = "r1"
  57. resp.message.body = "hello"
  58. m = _mock(bot.client, "messages", "create_message", resp)
  59. msg = await bot.say("r1", "hello")
  60. assert msg.body == "hello"
  61. m.assert_awaited_once()
  62. async def test_reply_threads_to_original():
  63. bot = _bot()
  64. resp = messages_pb2.CreateMessageResponse()
  65. resp.message.id = "e2"
  66. resp.message.room_id = "r1"
  67. resp.message.body = "hi!"
  68. resp.message.in_reply_to = "e1"
  69. _mock(bot.client, "messages", "create_message", resp)
  70. target = BotMessageEvent(bot=bot, kind="message", message=resp.message)
  71. out = await bot.reply(target, "hi!")
  72. assert out.in_reply_to == "e1"
  73. async def test_react_and_unreact():
  74. bot = _bot()
  75. bot.client.add_reaction = AsyncMock(return_value=True) # type: ignore[method-assign]
  76. bot.client.remove_reaction = AsyncMock(return_value=True) # type: ignore[method-assign]
  77. assert await bot.react("r1", "e1", "👍") is True
  78. assert await bot.unreact("r1", "e1", "👍") is True
  79. async def test_set_presence_and_status():
  80. bot = _bot()
  81. bot.client.set_presence = AsyncMock(return_value=PresenceStatus.ONLINE) # type: ignore[method-assign]
  82. assert await bot.set_presence(PresenceStatus.ONLINE) is PresenceStatus.ONLINE
  83. bot.client.set_custom_status = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
  84. assert await bot.set_status("🛠️", "working") == {"ok": True}
  85. async def test_join_and_create_room():
  86. bot = _bot()
  87. join_resp = rooms_pb2.JoinRoomResponse()
  88. join_resp.room.id = "r1"
  89. join_resp.room.name = "general"
  90. _mock(bot.client, "rooms", "join_room", join_resp)
  91. room = await bot.join_room("r1")
  92. assert isinstance(room, Room) and room.id == "r1"
  93. create_resp = rooms_pb2.CreateRoomResponse()
  94. create_resp.room.id = "r2"
  95. create_resp.room.name = "new"
  96. _mock(bot.client, "rooms", "create_room", create_resp)
  97. created = await bot.create_room("new", "g1")
  98. assert created.name == "new"
  99. # --- event dispatch -------------------------------------------------------
  100. async def test_dispatch_message_event():
  101. bot = _bot()
  102. seen: list[BotMessageEvent] = []
  103. async def on_message(event: BotMessageEvent) -> None:
  104. seen.append(event)
  105. bot.on("message", on_message)
  106. # Protocol-4 message_posted is a thin hint; the bot hydrates the full
  107. # message via get_message, so mock that to supply the body.
  108. posted = events_pb2.MessagePostedEvent(room_id="r1", body_plaintext="hello @felix_bot")
  109. msg = Message(id="e1", room_id="r1", created_at=None, actor_id="u1", body="hello @felix_bot")
  110. bot.client.get_message = AsyncMock(return_value=msg) # type: ignore[method-assign]
  111. await bot._handle_live(_live("message_posted", posted, actor_id="u1"))
  112. assert len(seen) == 1
  113. assert seen[0].message.body == "hello @felix_bot"
  114. assert seen[0].room_id == "r1"
  115. assert seen[0].is_mention # body mentions the bot's login
  116. async def test_dispatch_message_event_skips_when_hydration_fails():
  117. bot = _bot()
  118. seen: list[BotMessageEvent] = []
  119. async def on_message(event: BotMessageEvent) -> None:
  120. seen.append(event)
  121. bot.on("message", on_message)
  122. posted = events_pb2.MessagePostedEvent(room_id="r1")
  123. bot.client.get_message = AsyncMock(return_value=None) # type: ignore[method-assign]
  124. await bot._handle_live(_live("message_posted", posted, actor_id="u1"))
  125. assert seen == []
  126. async def test_dispatch_room_lifecycle_event():
  127. bot = _bot()
  128. seen: list[BotRoomEvent] = []
  129. async def on_room(event: BotRoomEvent) -> None:
  130. seen.append(event)
  131. bot.on("room", on_room)
  132. await bot._handle_live(
  133. _live("room_created", events_pb2.RoomCreatedEvent(room_id="r1", name="general"))
  134. )
  135. assert len(seen) == 1
  136. assert seen[0].detail == "room_created"
  137. assert seen[0].room is not None
  138. assert seen[0].room.id == "r1"
  139. async def test_dispatch_presence_event():
  140. bot = _bot()
  141. seen: list[BotPresenceEvent] = []
  142. async def on_presence(event: BotPresenceEvent) -> None:
  143. seen.append(event)
  144. bot.on("presence", on_presence)
  145. payload = events_pb2.PresenceChangedEvent()
  146. payload.status = presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
  147. await bot._handle_live(_live("presence_changed", payload, actor_id="u1"))
  148. assert len(seen) == 1
  149. assert seen[0].user_id == "u1"
  150. assert seen[0].status is PresenceStatus.ONLINE
  151. async def test_dispatch_presence_preference_event():
  152. """0.5.0b6: the empty viewer_presence_preference_changed hint dispatches as
  153. a ``presence_preference`` event; handlers hydrate the choice themselves."""
  154. bot = _bot()
  155. seen: list[BotPresencePreferenceEvent] = []
  156. async def on_pref(event: BotPresencePreferenceEvent) -> None:
  157. seen.append(event)
  158. bot.on("presence_preference", on_pref)
  159. await bot._handle_live(
  160. _live(
  161. "viewer_presence_preference_changed", events_pb2.ViewerPresencePreferenceChangedEvent()
  162. )
  163. )
  164. assert len(seen) == 1
  165. assert seen[0].kind == "presence_preference"
  166. async def test_dispatch_reaction_event():
  167. bot = _bot()
  168. seen: list[BotReactionEvent] = []
  169. async def on_reaction(event: BotReactionEvent) -> None:
  170. seen.append(event)
  171. bot.on("reaction", on_reaction)
  172. payload = events_pb2.ReactionAddedEvent(room_id="r1", message_event_id="e1", emoji="👍")
  173. await bot._handle_live(_live("reaction_added", payload, actor_id="u1"))
  174. assert len(seen) == 1
  175. assert seen[0].emoji == "👍"
  176. assert seen[0].user_id == "u1"
  177. assert seen[0].added is True
  178. async def test_dispatch_live_typing():
  179. bot = _bot()
  180. seen: list[BotTypingEvent] = []
  181. async def on_typing(event: BotTypingEvent) -> None:
  182. seen.append(event)
  183. bot.on("typing", on_typing)
  184. await bot._handle_live(
  185. _live("user_typing", events_pb2.UserTypingEvent(room_id="r1"), actor_id="u1")
  186. )
  187. assert len(seen) == 1
  188. assert seen[0].room_id == "r1"
  189. async def test_dispatch_user_event():
  190. bot = _bot()
  191. seen: list[BotUserEvent] = []
  192. async def on_user(event: BotUserEvent) -> None:
  193. seen.append(event)
  194. bot.on("user", on_user)
  195. await bot._handle_live(
  196. _live("user_account_deleted", events_pb2.UserAccountDeletedEvent(user_id="u1"))
  197. )
  198. assert len(seen) == 1
  199. assert seen[0].removed is True
  200. async def test_wildcard_handler_receives_all():
  201. bot = _bot()
  202. seen: list[str] = []
  203. async def on_any(event) -> None:
  204. seen.append(event.kind)
  205. bot.on("*", on_any)
  206. payload = events_pb2.PresenceChangedEvent()
  207. payload.status = presence_pb2.PresenceStatus.PRESENCE_STATUS_AWAY
  208. await bot._handle_live(_live("presence_changed", payload, actor_id="u1"))
  209. assert seen == ["presence"]
  210. async def test_a_failing_handler_does_not_stop_the_loop():
  211. bot = _bot()
  212. calls: list[int] = []
  213. async def bad(event) -> None:
  214. raise RuntimeError("boom")
  215. async def good(event) -> None:
  216. calls.append(1)
  217. bot.on("presence", bad)
  218. bot.on("presence", good)
  219. payload = events_pb2.PresenceChangedEvent()
  220. payload.status = presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
  221. await bot._handle_live(_live("presence_changed", payload, actor_id="u1"))
  222. assert calls == [1] # the good handler still ran after the bad one raised
  223. # --- group-aware joining & auto-join say() ------------------------------
  224. async def test_join_room_group():
  225. bot = _bot()
  226. join_resp = rooms_pb2.JoinRoomGroupResponse()
  227. join_resp.joined_room_ids.extend(["r1", "r2"])
  228. m = _mock(bot.client, "rooms", "join_room_group", join_resp)
  229. joined = await bot.join_room_group("g1")
  230. assert joined == ["r1", "r2"]
  231. m.assert_awaited_once()
  232. async def test_say_auto_joins_when_not_member():
  233. bot = _bot()
  234. from chattolib.exceptions import ChattoConnectError
  235. post = messages_pb2.CreateMessageResponse()
  236. post.message.id = "e1"
  237. post.message.room_id = "r1"
  238. post.message.body = "hi"
  239. state = {"joined": False}
  240. async def fake_post(room_id, body, **kwargs):
  241. if not state["joined"]:
  242. raise ChattoConnectError("permission_denied", "not a member of this room")
  243. return post.message
  244. bot.client.post_message = fake_post # type: ignore[method-assign]
  245. join_resp = rooms_pb2.JoinRoomResponse()
  246. join_resp.room.id = "r1"
  247. async def fake_join(room_id):
  248. state["joined"] = True # joining makes the subsequent post succeed
  249. return join_resp.room
  250. bot.client.join_room = fake_join # type: ignore[method-assign]
  251. msg = await bot.say("r1", "hi")
  252. assert msg.id == "e1"
  253. assert state["joined"] is True
  254. async def test_say_no_join_when_disabled():
  255. bot = _bot()
  256. from chattolib.exceptions import ChattoConnectError
  257. async def deny(room_id, body, **kwargs):
  258. raise ChattoConnectError("permission_denied", "not a member of this room")
  259. bot.client.post_message = deny # type: ignore[method-assign]
  260. bot.client.join_room = AsyncMock() # type: ignore[method-assign]
  261. try:
  262. await bot.say("r1", "hi", join_if_needed=False)
  263. raise AssertionError("expected ChattoConnectError to propagate")
  264. except ChattoConnectError:
  265. pass
  266. bot.client.join_room.assert_not_awaited()