| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372 |
- """Tests for the chattolib bot framework (chattolib.bot).
- The realtime WebSocket handshake is not exercised here (that is the job of
- the live integration test); instead we feed synthetic realtime frames through
- the dispatcher and assert the typed events and the convenience verbs.
- """
- from __future__ import annotations
- from unittest.mock import AsyncMock
- from chattolib._pb.chatto.api.v1 import (
- messages_pb2,
- presence_pb2,
- rooms_pb2,
- )
- from chattolib._pb.chatto.realtime.v1 import realtime_pb2
- from chattolib.bot import (
- Bot,
- BotMessageEvent,
- BotPresenceEvent,
- BotRoomEvent,
- BotTypingEvent,
- )
- from chattolib.client import ChattoClient
- from chattolib.realtime import _wrap_projection_event
- from chattolib.types import PresenceStatus, Room, User
- def _bot() -> Bot:
- client = ChattoClient(token="cht_BK_test", base_url="https://example.test")
- bot = Bot.from_client(client)
- bot._user = User(id="u_bot", login="felix_bot", display_name="Felix - the bot", is_bot=True)
- return bot
- def _mock(client: ChattoClient, service: str, method: str, response):
- svc = getattr(client._svc, service)
- setattr(svc, method, AsyncMock(return_value=response))
- return getattr(svc, method)
- # --- verbs ---------------------------------------------------------------
- async def test_say_posts_message():
- bot = _bot()
- resp = messages_pb2.CreateMessageResponse()
- resp.message.id = "e1"
- resp.message.room_id = "r1"
- resp.message.body = "hello"
- m = _mock(bot.client, "messages", "create_message", resp)
- msg = await bot.say("r1", "hello")
- assert msg.body == "hello"
- m.assert_awaited_once()
- async def test_reply_threads_to_original():
- bot = _bot()
- resp = messages_pb2.CreateMessageResponse()
- resp.message.id = "e2"
- resp.message.room_id = "r1"
- resp.message.body = "hi!"
- resp.message.in_reply_to = "e1"
- _mock(bot.client, "messages", "create_message", resp)
- target = BotMessageEvent(bot=bot, kind="message", message=resp.message)
- out = await bot.reply(target, "hi!")
- assert out.in_reply_to == "e1"
- async def test_react_and_unreact():
- bot = _bot()
- bot.client.add_reaction = AsyncMock(return_value=True) # type: ignore[method-assign]
- bot.client.remove_reaction = AsyncMock(return_value=True) # type: ignore[method-assign]
- assert await bot.react("r1", "e1", "👍") is True
- assert await bot.unreact("r1", "e1", "👍") is True
- async def test_set_presence_and_status():
- bot = _bot()
- bot.client.update_presence = AsyncMock(return_value=PresenceStatus.ONLINE) # type: ignore[method-assign]
- assert await bot.set_presence(PresenceStatus.ONLINE) is PresenceStatus.ONLINE
- bot.client.update_custom_status = AsyncMock(return_value={"ok": True}) # type: ignore[method-assign]
- assert await bot.set_status("🛠️", "working") == {"ok": True}
- async def test_join_and_create_room():
- bot = _bot()
- join_resp = rooms_pb2.JoinRoomResponse()
- join_resp.room.id = "r1"
- join_resp.room.name = "general"
- _mock(bot.client, "rooms", "join_room", join_resp)
- room = await bot.join_room("r1")
- assert isinstance(room, Room) and room.id == "r1"
- create_resp = rooms_pb2.CreateRoomResponse()
- create_resp.room.id = "r2"
- create_resp.room.name = "new"
- _mock(bot.client, "rooms", "create_room", create_resp)
- created = await bot.create_room("new", "g1")
- assert created.name == "new"
- # --- event dispatch -------------------------------------------------------
- async def test_dispatch_message_event():
- bot = _bot()
- seen: list[BotMessageEvent] = []
- async def on_message(event: BotMessageEvent) -> None:
- seen.append(event)
- bot.on("message", on_message)
- # Build a projection frame carrying a message_posted timeline event.
- frame = realtime_pb2.RealtimeProjectionEvent()
- frame.id = "p1"
- op = frame.operations.add()
- op.room_timeline_event_upsert.room_id = "r1"
- evt = op.room_timeline_event_upsert.event
- mp = evt.message_posted
- mp.message.id = "e1"
- mp.message.room_id = "r1"
- mp.message.body = "hello @felix_bot"
- mp.message.actor_id = "u1"
- mp.message.created_at.FromJsonString("2026-01-01T00:00:00Z")
- await bot._handle_projection(_wrap_projection_event(frame))
- assert len(seen) == 1
- assert seen[0].message.body == "hello @felix_bot"
- assert seen[0].room_id == "r1"
- assert seen[0].is_mention # body mentions the bot's login
- async def test_dispatch_room_lifecycle_event():
- # Regression: room-lifecycle timeline events (room_created, user_joined_room,
- # ...) carry a RoomTimelineRoomEvent with a `room_id` string, not a nested
- # `room` message. The handler must build the Room from the id and must not
- # raise (the old code called sub.HasField("room") on a field that doesn't
- # exist, crashing the server task on retained-timeline replay).
- bot = _bot()
- seen: list[BotRoomEvent] = []
- async def on_room(event: BotRoomEvent) -> None:
- seen.append(event)
- bot.on("room", on_room)
- frame = realtime_pb2.RealtimeProjectionEvent()
- frame.id = "p1"
- op = frame.operations.add()
- op.room_timeline_event_upsert.room_id = "r1"
- evt = op.room_timeline_event_upsert.event
- evt.room_created.room_id = "r1"
- await bot._handle_projection(_wrap_projection_event(frame))
- assert len(seen) == 1
- assert seen[0].detail == "room_created"
- assert seen[0].room is not None
- assert seen[0].room.id == "r1"
- async def test_dispatch_presence_event():
- bot = _bot()
- seen: list[BotPresenceEvent] = []
- async def on_presence(event: BotPresenceEvent) -> None:
- seen.append(event)
- bot.on("presence", on_presence)
- frame = realtime_pb2.RealtimeProjectionEvent()
- frame.id = "p2"
- op = frame.operations.add()
- op.presences_replace.statuses["u1"] = presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
- await bot._handle_projection(_wrap_projection_event(frame))
- assert len(seen) == 1
- assert seen[0].user_id == "u1"
- assert seen[0].status is PresenceStatus.ONLINE
- async def test_dispatch_live_typing():
- bot = _bot()
- seen: list[BotTypingEvent] = []
- async def on_typing(event: BotTypingEvent) -> None:
- seen.append(event)
- bot.on("typing", on_typing)
- from chattolib.realtime import RealtimeEvent
- live = RealtimeEvent(
- id="evt",
- created_at=None,
- actor_id="u1",
- kind="user_typing",
- payload=realtime_pb2.RealtimeTypingEvent(room_id="r1"),
- raw=None,
- )
- await bot._handle_live(live)
- assert len(seen) == 1
- assert seen[0].room_id == "r1"
- async def test_wildcard_handler_receives_all():
- bot = _bot()
- seen: list[str] = []
- async def on_any(event) -> None:
- seen.append(event.kind)
- bot.on("*", on_any)
- frame = realtime_pb2.RealtimeProjectionEvent()
- frame.id = "p3"
- op = frame.operations.add()
- op.presences_replace.statuses["u1"] = presence_pb2.PresenceStatus.PRESENCE_STATUS_AWAY
- await bot._handle_projection(_wrap_projection_event(frame))
- assert seen == ["presence"]
- async def test_a_failing_handler_does_not_stop_the_loop():
- bot = _bot()
- calls: list[int] = []
- async def bad(event) -> None:
- raise RuntimeError("boom")
- async def good(event) -> None:
- calls.append(1)
- bot.on("presence", bad)
- bot.on("presence", good)
- frame = realtime_pb2.RealtimeProjectionEvent()
- frame.id = "p4"
- op = frame.operations.add()
- op.presences_replace.statuses["u1"] = presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
- await bot._handle_projection(_wrap_projection_event(frame))
- assert calls == [1] # the good handler still ran after the bad one raised
- # --- group-aware joining & auto-join say() ------------------------------
- def _group_response(group_id: str, room_ids: list[str], *, member: bool = True):
- from chattolib._pb.chatto.api.v1 import room_directory_pb2
- resp = room_directory_pb2.ListRoomGroupsResponse()
- g = resp.groups.add()
- g.id = group_id
- g.name = "Group " + group_id
- for rid in room_ids:
- item = g.items.add()
- item.room.id = rid
- item.viewer_state.is_member = member
- return resp
- async def test_join_room_group():
- bot = _bot()
- from chattolib._pb.chatto.api.v1 import rooms_pb2
- join_resp = rooms_pb2.JoinRoomGroupResponse()
- join_resp.joined_room_ids.extend(["r1", "r2"])
- m = _mock(bot.client, "rooms", "join_room_group", join_resp)
- joined = await bot.join_room_group("g1")
- assert joined == ["r1", "r2"]
- m.assert_awaited_once()
- async def test_join_all_rooms_groups_then_ungrouped():
- bot = _bot()
- from chattolib._pb.chatto.api.v1 import room_directory_pb2, rooms_pb2
- # Two groups: g1 has r1,r2 (already members), g2 has r3 (not a member).
- groups_resp = room_directory_pb2.ListRoomGroupsResponse()
- g1 = groups_resp.groups.add()
- g1.id = "g1"
- for rid in ("r1", "r2"):
- it = g1.items.add()
- it.room.room.id = rid
- it.room.viewer_state.is_member = True
- g2 = groups_resp.groups.add()
- g2.id = "g2"
- it = g2.items.add()
- it.room.room.id = "r3"
- it.room.viewer_state.is_member = False
- _mock(bot.client, "room_directory", "list_room_groups", groups_resp)
- # join_room_group: g1 no-op, g2 joins r3.
- jg = rooms_pb2.JoinRoomGroupResponse()
- jg.joined_room_ids.extend(["r3"])
- bot.client.join_room_group = AsyncMock(return_value=["r3"]) # type: ignore[method-assign]
- # list_rooms returns the ungrouped room r4 (not a member) plus the group rooms.
- rooms_resp = room_directory_pb2.ListRoomsResponse()
- for rid, member in (("r1", True), ("r2", True), ("r3", True), ("r4", False)):
- row = rooms_resp.rooms.add()
- row.room.id = rid
- row.viewer_state.is_member = member
- _mock(bot.client, "room_directory", "list_rooms", rooms_resp)
- # r4 is ungrouped and not a member -> join_room is called for it.
- from chattolib.types import Room
- async def fake_join_room(room_id):
- return Room(id=room_id)
- bot.client.join_room = fake_join_room # type: ignore[method-assign]
- joined = await bot.join_all_rooms()
- # r3 from the group join + r4 from the individual join.
- assert "r3" in joined and "r4" in joined
- assert fake_join_room is bot.client.join_room
- async def test_say_auto_joins_when_not_member():
- bot = _bot()
- from chattolib._pb.chatto.api.v1 import messages_pb2, rooms_pb2
- from chattolib.exceptions import ChattoConnectError
- post = messages_pb2.CreateMessageResponse()
- post.message.id = "e1"
- post.message.room_id = "r1"
- post.message.body = "hi"
- state = {"joined": False}
- async def fake_post(room_id, body, **kwargs):
- if not state["joined"]:
- raise ChattoConnectError("permission_denied", "not a member of this room")
- return post.message
- bot.client.post_message = fake_post # type: ignore[method-assign]
- join_resp = rooms_pb2.JoinRoomResponse()
- join_resp.room.id = "r1"
- async def fake_join(room_id):
- state["joined"] = True # joining makes the subsequent post succeed
- return join_resp.room
- bot.client.join_room = fake_join # type: ignore[method-assign]
- msg = await bot.say("r1", "hi")
- assert msg.id == "e1"
- assert state["joined"] is True
- async def test_say_no_join_when_disabled():
- bot = _bot()
- from chattolib.exceptions import ChattoConnectError
- async def deny(room_id, body, **kwargs):
- raise ChattoConnectError("permission_denied", "not a member of this room")
- bot.client.post_message = deny # type: ignore[method-assign]
- bot.client.join_room = AsyncMock() # type: ignore[method-assign]
- try:
- await bot.say("r1", "hi", join_if_needed=False)
- raise AssertionError("expected ChattoConnectError to propagate")
- except ChattoConnectError:
- pass
- bot.client.join_room.assert_not_awaited()
|