Explorar o código

Bot framework: group-aware joining + auto-join say()

Mirror the UI's room-group model in the bot framework:
- Bot.join_room_group(group_id): one-click join of every room in a group
- Bot.list_room_groups(): the groups and the rooms each contains
- Bot.join_all_rooms(): join every visible room, group by group, then
  any ungrouped rooms
- Bot.say(..., join_if_needed=True): auto-joins the room on a
  'not a member' post error, so 'be present everywhere' is one line

Bump to 0.5.0a3.post1 (chattolib-only, no server change).
Docs (docs/bots.md) and the echo_bot example updated to the group-aware
pattern; 14 bot tests (incl. group joining + auto-join say).
the-codemancer hai 2 meses
pai
achega
b82ad149a0
Modificáronse 5 ficheiros con 207 adicións e 17 borrados
  1. 13 7
      docs/bots.md
  2. 7 6
      examples/bot/echo_bot.py
  3. 1 1
      pyproject.toml
  4. 60 3
      src/chattolib/bot.py
  5. 126 0
      tests/test_bot.py

+ 13 - 7
docs/bots.md

@@ -16,7 +16,7 @@ grant set.
 from chattolib.bot import Bot
 
 async with await Bot.login("cht_BK_...") as bot:
-    print(bot.login_name, bot.user.is_bot)   # 'felix_bot' True
+    print(bot.login_name, bot.user.is_bot)  # 'felix_bot' True
 ```
 
 `Bot.login` also accepts `base_url=` to target a self-hosted or preview
@@ -69,18 +69,21 @@ import asyncio
 from chattolib.bot import Bot, BotMessageEvent
 from chattolib.types import PresenceStatus
 
+
 async def on_message(event: BotMessageEvent) -> None:
     if event.is_mention:
         await event.bot.reply(event, f"you said: {event.body!r}")
 
+
 async def main() -> None:
     async with await Bot.login("cht_BK_...") as bot:
         bot.on("message", on_message)
         await bot.set_presence(PresenceStatus.ONLINE)
-        for room in await bot.list_rooms():
-            if room.room:
-                await bot.join_room(room.room.id)
-        await bot.run()   # blocks until the stream closes
+        # Be present everywhere: join every visible room, group by group.
+        # (Or join selectively — the policy is up to your bot.)
+        await bot.join_all_rooms()
+        await bot.run()  # blocks until the stream closes
+
 
 asyncio.run(main())
 ```
@@ -93,14 +96,17 @@ handlers for Ctrl-C.
 
 | Method | Wraps | Notes |
 |--------|-------|-------|
-| `await bot.say(room_id, body)` | `post_message` | post a plain message |
+| `await bot.say(room_id, body)` | `post_message` | post a plain message; auto-joins the room first if the bot isn't a member (pass `join_if_needed=False` to opt out) |
 | `await bot.reply(target, body)` | `post_message` | `target` is a `Message` or `BotMessageEvent`; threads to its room |
 | `await bot.react(room_id, event_id, emoji)` | `add_reaction` | |
 | `await bot.unreact(room_id, event_id, emoji)` | `remove_reaction` | |
 | `await bot.set_presence(status)` | `update_presence` | `ONLINE` / `AWAY` / `DO_NOT_DISTURB` |
 | `await bot.set_status(emoji, text)` | `update_custom_status` | |
 | `await bot.clear_status()` | `delete_custom_status` | |
-| `await bot.join_room(room_id)` | `join_room` | |
+| `await bot.join_room(room_id)` | `join_room` | join a single room |
+| `await bot.join_room_group(group_id)` | `join_room_group` | join **all** rooms in a group in one call (the UI's one-click group join) |
+| `await bot.list_room_groups()` | `list_room_groups` | the groups and the rooms each contains |
+| `await bot.join_all_rooms()` | `join_room_group` + `join_room` | join every visible room, group by group, then any ungrouped rooms |
 | `await bot.leave_room(room_id)` | `leave_room` | |
 | `await bot.create_room(name, group_id, ...)` | `create_room` | |
 | `await bot.list_rooms()` | `list_rooms` | rooms the bot is a member of |

+ 7 - 6
examples/bot/echo_bot.py

@@ -60,13 +60,14 @@ async def main() -> None:
         bot.on("presence", lambda e: on_presence(bot, e))
         bot.on("typing", lambda e: on_typing(bot, e))
 
-        # Get online and join the rooms we can see.
+        # Get online and join the rooms we can see, group by group (the way
+        # the UI's one-click "join group" works). Swap for selective joins if
+        # your bot should only be present in some rooms.
         await bot.set_presence(PresenceStatus.ONLINE)
-        for room in await bot.list_rooms():
-            room_id = room.room.id if room.room else ""
-            if room_id:
-                await bot.join_room(room_id)
-                print(f"joined {room_id}")
+        for group in await bot.list_room_groups():
+            joined = await bot.join_room_group(group.id)
+            if joined:
+                print(f"joined group {group.name!r}: {joined}")
 
         # Run until interrupted.
         stop = asyncio.Event()

+ 1 - 1
pyproject.toml

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "chattolib"
-version = "0.5.0a3"
+version = "0.5.0a3.post1"
 description = "Async Python client library for the Chatto webchat Connect API"
 requires-python = ">=3.11"
 license = "MPL-2.0 AND Apache-2.0"

+ 60 - 3
src/chattolib/bot.py

@@ -62,6 +62,7 @@ from chattolib.types import (
     Message,
     PresenceStatus,
     Room,
+    RoomGroup,
     RoomWithViewerState,
     User,
 )
@@ -334,9 +335,22 @@ class Bot:
 
     # -- verbs: messaging --------------------------------------------------
 
-    async def say(self, room_id: str, body: str = "") -> Message:
-        """Post a message to a room. Returns the created :class:`Message`."""
-        return await self._client.post_message(room_id, body)
+    async def say(self, room_id: str, body: str = "", *, join_if_needed: bool = True) -> Message:
+        """Post a message to a room. Returns the created :class:`Message`.
+
+        With ``join_if_needed`` (the default), the bot joins the room first if
+        it isn't already a member — so a bot that wants to be present
+        everywhere can simply ``await bot.say(room_id, ...)`` without a
+        separate join step. Pass ``join_if_needed=False`` to instead surface
+        the server's ``permission_denied`` if the bot can't post.
+        """
+        try:
+            return await self._client.post_message(room_id, body)
+        except ChattoError as exc:
+            if not join_if_needed or "not a member" not in str(exc):
+                raise
+            await self._client.join_room(room_id)
+            return await self._client.post_message(room_id, body)
 
     async def reply(
         self,
@@ -384,6 +398,49 @@ class Bot:
         """Join a room. Returns the joined :class:`Room`."""
         return await self._client.join_room(room_id)
 
+    async def join_room_group(self, group_id: str) -> list[str]:
+        """Join **all** the rooms in a room group in one call.
+
+        Mirrors the Chatto UI's one-click "join group" action. Returns the
+        list of room IDs the bot is now a member of as a result.
+        """
+        return await self._client.join_room_group(group_id)
+
+    async def list_room_groups(self) -> list[RoomGroup]:
+        """List the room groups (and the rooms each contains) the bot can see."""
+        return await self._client.list_room_groups()
+
+    async def join_all_rooms(self) -> list[str]:
+        """Join every room the bot can see, grouped the way the UI does.
+
+        Joins each room group in one call (so a bot becomes a member of all
+        the rooms in a group at once), then joins any ungrouped rooms
+        individually. Returns the room IDs the bot is now a member of.
+        """
+        joined: list[str] = []
+        groups = await self.list_room_groups()
+        grouped_room_ids: set[str] = set()
+        for group in groups:
+            for rws in group.rooms:
+                if rws.room is not None:
+                    grouped_room_ids.add(rws.room.id)
+            try:
+                joined.extend(await self.join_room_group(group.id))
+            except ChattoError:
+                # A group the bot can't join is skipped, not fatal.
+                continue
+        # Rooms that are not part of any group.
+        for rws in await self.list_rooms():
+            if rws.room is None or rws.room.id in grouped_room_ids:
+                continue
+            if rws.viewer_state.is_member:
+                continue
+            try:
+                joined.append((await self.join_room(rws.room.id)).id)
+            except ChattoError:
+                continue
+        return joined
+
     async def leave_room(self, room_id: str) -> bool:
         """Leave a room."""
         return await self._client.leave_room(room_id)

+ 126 - 0
tests/test_bot.py

@@ -215,3 +215,129 @@ async def test_a_failing_handler_does_not_stop_the_loop():
     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()