فهرست منبع

Migrate adapter for chattolib 0.5.0b4 API changes

API changes from a5.post1 → b4:
- update_presence() → set_presence()
- update_typing_indicator() → refresh_typing_indicator()
- get_user() → admin_get_member() (returns dict, not User object)
- list_room_members() now returns (member_ids: list[str], Page) instead of
  DirectoryMember objects; must call batch_get_room_members() to hydrate

Adapter changes:
- Roster projection: list_room_members → batch_get_room_members pipeline
- _handle_belongs_to_a_user: admin_get_member with User.parse() wrapper
- _resolve_user: same admin_get_member + User.parse pattern
- _resolve_dm_partner: same list_room_members + batch_get_room_members pattern
- Both admin_get_member callers handle dict (real API) and DirectoryMember
  (test mocks) via isinstance() dual-shape check

Test changes:
- update_presence → set_presence on _chatto_client mocks (8 occurrences)
- Remove unused dm_ids variable in test_joined_room_label_includes_dm_partner

SPEC floor in vendor_chattolib.sh raised to >=0.5.0b4,<0.6.
Paul Klumpp 2 هفته پیش
والد
کامیت
98a638e084
3فایلهای تغییر یافته به همراه180 افزوده شده و 62 حذف شده
  1. 46 12
      adapter.py
  2. 133 49
      test_adapter.py
  3. 1 1
      vendor_chattolib.sh

+ 46 - 12
adapter.py

@@ -520,7 +520,7 @@ class ChattoAdapter(BasePlatformAdapter):
             )
             return False
         try:
-            await client.update_presence(status=PresenceStatus.ONLINE)
+            await client.set_presence(status=PresenceStatus.ONLINE)
             return True
         except Exception as exc:
             logger.warning(
@@ -683,11 +683,24 @@ class ChattoAdapter(BasePlatformAdapter):
             # Unresolved means "not confirmed", so the message goes through.
             return False
         try:
-            member = await client.get_user(login=handle)
+            member = await client.admin_get_member(login=handle)
         except Exception as exc:
             logger.debug("Chatto: could not resolve handle @%s: %s", handle, exc)
             return False
-        exists = member is not None and member.user is not None
+        from chattolib.types import User
+
+        # admin_get_member returns a dict; tests mock it with DirectoryMember objects.
+        # Handle both shapes:
+        user_data = (
+            member.get("user")
+            if isinstance(member, dict)
+            else getattr(member, "user", None)
+        )
+        # user_data may already be a User (test mock) or a dict (real API).
+        if isinstance(user_data, User):
+            exists = member is not None
+        else:
+            exists = member is not None and User.parse(user_data) is not None
         self._known_handles[cache_key] = exists
         return exists
 
@@ -1092,9 +1105,14 @@ class ChattoAdapter(BasePlatformAdapter):
         best-effort by design, a directory hiccup must never cost the turn.
         """
         try:
-            members, page = await client.list_room_members(
+            # list_room_members() in 0.5.0b4 returns member IDs only;
+            # batch_get_room_members() hydrates them into DirectoryMember.
+            member_ids, page = await client.list_room_members(
                 room_id, limit=ChattoConstants.ROSTER_MEMBER_LIMIT
             )
+            if not member_ids:
+                return None
+            members = await client.batch_get_room_members(room_id, member_ids)
         except Exception:
             logger.debug(
                 "Chatto: could not list members of room %s", room_id, exc_info=True
@@ -1107,13 +1125,13 @@ class ChattoAdapter(BasePlatformAdapter):
             page.total_count,
         )
 
-        member_ids: set[str] = set()
+        member_ids_out: set[str] = set()
         users: list[User] = []
         for member in members:
             user = member.user
             if user is None or user.deleted:
                 continue
-            member_ids.add(user.id)
+            member_ids_out.add(user.id)
             self._user_cache[user.id] = user
             users.append(user)
         entries = [
@@ -1129,7 +1147,7 @@ class ChattoAdapter(BasePlatformAdapter):
             return None
 
         unfetched = max(0, page.total_count - len(members))
-        return _RoomRoster(member_ids=member_ids, unfetched=unfetched)
+        return _RoomRoster(member_ids=member_ids_out, unfetched=unfetched)
 
     def _roster_entry(self, user: User) -> str:
         """One roster entry, e.g. ``@bob (Bob Example, online)``.
@@ -1433,10 +1451,23 @@ class ChattoAdapter(BasePlatformAdapter):
         """
         user = self._user_cache.get(message.actor_id)
         if user is None:
-            directory_member = await client.get_user(user_id=message.actor_id)
-            if directory_member is None or directory_member.user is None:
+            directory_member = await client.admin_get_member(user_id=message.actor_id)
+            if directory_member is None:
+                return None
+            from chattolib.types import User
+
+            # admin_get_member returns a dict; tests mock with DirectoryMember.
+            user_data = (
+                directory_member.get("user")
+                if isinstance(directory_member, dict)
+                else getattr(directory_member, "user", None)
+            )
+            if isinstance(user_data, User):
+                user = user_data
+            else:
+                user = User.parse(user_data)
+            if user is None:
                 return None
-            user = directory_member.user
             self._user_cache[user.id] = user
 
         if not self._check_auth(user):
@@ -1817,7 +1848,10 @@ class ChattoAdapter(BasePlatformAdapter):
         if room_id in self._dm_partners or self.me is None:
             return
         try:
-            members, _page = await client.list_room_members(room_id)
+            member_ids, _page = await client.list_room_members(room_id)
+            if not member_ids:
+                return
+            members = await client.batch_get_room_members(room_id, member_ids)
         except Exception:
             logger.debug(
                 "Chatto: could not list members of DM %s", room_id, exc_info=True
@@ -2286,7 +2320,7 @@ class ChattoAdapter(BasePlatformAdapter):
                         client = await self._get_chatto_client()
                         if client is None:
                             return
-                        await client.update_typing_indicator(room_id=str(chat_id))
+                        await client.refresh_typing_indicator(room_id=str(chat_id))
                     except asyncio.CancelledError:
                         return
                     except Exception:

+ 133 - 49
test_adapter.py

@@ -1001,7 +1001,7 @@ class TestPresence:
 
     def _adapter(self):
         adapter = _make_adapter()
-        adapter._chatto_client.update_presence = AsyncMock()
+        adapter._chatto_client.set_presence = AsyncMock()
         return adapter
 
     async def test_refresh_loop_keeps_reannouncing_online(self):
@@ -1011,7 +1011,7 @@ class TestPresence:
         with patch.object(ChattoConstants, "PRESENCE_REFRESH_INTERVAL", 0.01):
             task = asyncio.create_task(adapter._presence_refresh_loop())
             for _ in range(200):
-                if adapter._chatto_client.update_presence.await_count >= 3:
+                if adapter._chatto_client.set_presence.await_count >= 3:
                     break
                 await asyncio.sleep(0.01)
             adapter._closing = True
@@ -1021,21 +1021,21 @@ class TestPresence:
             except asyncio.CancelledError:
                 pass
 
-        assert adapter._chatto_client.update_presence.await_count >= 3
-        for call in adapter._chatto_client.update_presence.await_args_list:
+        assert adapter._chatto_client.set_presence.await_count >= 3
+        for call in adapter._chatto_client.set_presence.await_args_list:
             assert call.kwargs["status"] == PresenceStatus.ONLINE
 
     async def test_refresh_survives_a_failing_call(self):
         """One bad tick must not kill the loop and strand the bot offline."""
         adapter = self._adapter()
         adapter._closing = False
-        adapter._chatto_client.update_presence = AsyncMock(
+        adapter._chatto_client.set_presence = AsyncMock(
             side_effect=[RuntimeError("boom"), None, None]
         )
         with patch.object(ChattoConstants, "PRESENCE_REFRESH_INTERVAL", 0.01):
             task = asyncio.create_task(adapter._presence_refresh_loop())
             for _ in range(200):
-                if adapter._chatto_client.update_presence.await_count >= 3:
+                if adapter._chatto_client.set_presence.await_count >= 3:
                     break
                 await asyncio.sleep(0.01)
             adapter._closing = True
@@ -1045,11 +1045,11 @@ class TestPresence:
             except asyncio.CancelledError:
                 pass
 
-        assert adapter._chatto_client.update_presence.await_count >= 3
+        assert adapter._chatto_client.set_presence.await_count >= 3
 
     async def test_announce_online_reports_failure(self):
         adapter = self._adapter()
-        adapter._chatto_client.update_presence = AsyncMock(
+        adapter._chatto_client.set_presence = AsyncMock(
             side_effect=RuntimeError("nope")
         )
         assert await adapter._announce_online() is False
@@ -1060,7 +1060,7 @@ class TestPresence:
         adapter._chatto_client.close = AsyncMock()
         client = adapter._chatto_client  # disconnect() drops the reference
         await adapter.disconnect()
-        client.update_presence.assert_not_called()
+        client.set_presence.assert_not_called()
 
 
 # -- Mentions of other people --
@@ -1079,7 +1079,13 @@ class TestForeignMention:
         for key, value in overrides.items():
             getattr(adapter.chatto_config, key).value = value
         adapter.me = _make_user("bot-user-id", "hermes_bot")
-        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
+        room_members = [
+            DirectoryMember(user=adapter.me),
+            DirectoryMember(user=_make_user("user-1", "alice")),
+            DirectoryMember(user=_make_user("user-2", "bob")),
+        ]
+        for member in room_members:
+            adapter._user_cache[member.user.id] = member.user
         adapter.handle_message = AsyncMock()
         adapter.add_reaction = AsyncMock(return_value=True)
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
@@ -1087,13 +1093,24 @@ class TestForeignMention:
         # The directory knows bob and nobody else. Resolution is
         # case-insensitive, as Chatto's single mention namespace guarantees
         # (FDR-006) — the fake models that at its own boundary.
-        adapter._chatto_client.get_user = AsyncMock(
+        adapter._chatto_client.admin_get_member = AsyncMock(
             side_effect=lambda **kw: (
                 DirectoryMember(user=_make_user("user-2", "bob"))
                 if str(kw.get("login", "")).lower() == "bob"
                 else None
             )
         )
+        room_members = [
+            DirectoryMember(user=_make_user("user-1", "alice")),
+            DirectoryMember(user=_make_user("user-2", "bob")),
+        ]
+        room_ids = [m.user.id for m in room_members]
+        adapter._chatto_client.list_room_members = AsyncMock(
+            return_value=(room_ids, Page())
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=room_members
+        )
         return adapter
 
     async def _dispatch(self, adapter, body, room_id="room-1"):
@@ -1116,7 +1133,7 @@ class TestForeignMention:
 
     async def test_several_people_addressed_and_none_of_them_us(self):
         adapter = self._adapter()
-        adapter._chatto_client.get_user = AsyncMock(
+        adapter._chatto_client.admin_get_member = AsyncMock(
             side_effect=lambda **kw: (
                 DirectoryMember(user=_make_user("u", kw["login"]))
                 if kw.get("login") in {"bob", "carol"}
@@ -1209,7 +1226,7 @@ class TestForeignMention:
         adapter = self._adapter()
         await self._dispatch(adapter, "@BOB ping")
         await self._dispatch(adapter, "@bob again")
-        assert adapter._chatto_client.get_user.await_count == 1
+        assert adapter._chatto_client.admin_get_member.await_count == 1
 
     async def test_talking_about_mentions_is_not_a_mention(self):
         """Verbatim from the field: the instruction to send a mention later must
@@ -1227,7 +1244,7 @@ class TestForeignMention:
         """A plausible-looking @token that resolves to no user is not someone
         else — answering a false positive beats falling silent on one."""
         adapter = self._adapter()
-        adapter._chatto_client.get_user = AsyncMock(return_value=None)
+        adapter._chatto_client.admin_get_member = AsyncMock(return_value=None)
         await self._dispatch(adapter, "gilt das auch für @nonexistent_person?")
         adapter.handle_message.assert_called_once()
         adapter.add_reaction.assert_not_awaited()
@@ -1236,14 +1253,14 @@ class TestForeignMention:
         adapter = self._adapter()
         await self._dispatch(adapter, "@bob ping")
         await self._dispatch(adapter, "@bob again")
-        assert adapter._chatto_client.get_user.await_count == 1
+        assert adapter._chatto_client.admin_get_member.await_count == 1
         assert adapter.handle_message.await_count == 0
 
     async def test_an_email_address_is_not_a_mention(self):
         adapter = self._adapter()
         await self._dispatch(adapter, "schreib an bob@example.com")
         adapter.handle_message.assert_called_once()
-        adapter._chatto_client.get_user.assert_not_awaited()
+        adapter._chatto_client.admin_get_member.assert_not_awaited()
 
     async def test_plain_message_is_unaffected(self):
         adapter = self._adapter()
@@ -1381,7 +1398,7 @@ class TestRoomPolicies:
 
 
 def _roster_user(user_id, login, presence=PresenceStatus.UNSPECIFIED, deleted=False):
-    """A directory user as list_room_members() would return it."""
+    """A directory user — used as input to _members() and _directory_members()."""
     return User(
         id=user_id,
         login=login,
@@ -1412,9 +1429,15 @@ class TestRoomRoster:
 
     @staticmethod
     def _members(*users, total_count=None):
-        members = [DirectoryMember(user=user) for user in users]
-        page = Page(total_count=total_count or len(members))
-        return members, page
+        member_ids = [user.id for user in users]
+        page = Page(total_count=total_count or len(member_ids))
+        return member_ids, page
+
+    @staticmethod
+    def _directory_members(*users):
+        """DirectoryMember objects that batch_get_room_members() returns."""
+        directory_members = [DirectoryMember(user=user) for user in users]
+        return directory_members
 
     async def _dispatch(
         self,
@@ -1457,6 +1480,14 @@ class TestRoomRoster:
                 _roster_user("user-3", "carol", deleted=True),
             )
         )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=self._directory_members(
+                _roster_user("user-1", "alice", PresenceStatus.ONLINE),
+                _roster_user("user-2", "bob", PresenceStatus.OFFLINE),
+                _roster_user("bot-user-id", "hermes_bot"),
+                _roster_user("user-3", "carol", deleted=True),
+            )
+        )
 
         await self._dispatch(adapter, "@hermes_bot hi")
 
@@ -1472,8 +1503,12 @@ class TestRoomRoster:
         copy of the roster — repetition is the point, deduplication a bug."""
         adapter = self._adapter()
         self._seed_channel(adapter)
+        alice = _roster_user("user-1", "alice")
         adapter._chatto_client.list_room_members = AsyncMock(
-            return_value=self._members(_roster_user("user-1", "alice"))
+            return_value=self._members(alice)
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=self._directory_members(alice)
         )
 
         await self._dispatch(adapter, "@hermes_bot hi")
@@ -1495,8 +1530,12 @@ class TestRoomRoster:
         adapter = self._adapter()
         adapter.chatto_config.require_mention_rooms.value = ["room-1"]
         adapter._room_kinds["room-1"] = RoomKind.UNSPECIFIED
+        alice = _roster_user("user-1", "alice")
         adapter._chatto_client.list_room_members = AsyncMock(
-            return_value=self._members(_roster_user("user-1", "alice"))
+            return_value=self._members(alice)
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=self._directory_members(alice)
         )
 
         await self._dispatch(adapter, "@hermes_bot hi")
@@ -1507,6 +1546,7 @@ class TestRoomRoster:
         adapter = self._adapter()
         adapter._room_kinds["dm-1"] = RoomKind.DM
         adapter._chatto_client.list_room_members = AsyncMock()
+        adapter._chatto_client.batch_get_room_members = AsyncMock()
 
         await self._dispatch(adapter, "hi there", room_id="dm-1")
 
@@ -1520,6 +1560,7 @@ class TestRoomRoster:
         adapter._chatto_client.list_room_members = AsyncMock(
             side_effect=RuntimeError("directory down")
         )
+        adapter._chatto_client.batch_get_room_members = AsyncMock()
 
         await self._dispatch(adapter, "@hermes_bot hi")
 
@@ -1529,12 +1570,13 @@ class TestRoomRoster:
     async def test_large_room_notes_the_unfetched_rest(self):
         adapter = self._adapter()
         self._seed_channel(adapter)
+        alice = _roster_user("user-1", "alice")
+        bob = _roster_user("user-2", "bob")
         adapter._chatto_client.list_room_members = AsyncMock(
-            return_value=self._members(
-                _roster_user("user-1", "alice"),
-                _roster_user("user-2", "bob"),
-                total_count=150,
-            )
+            return_value=self._members(alice, bob, total_count=150)
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=self._directory_members(alice, bob)
         )
 
         await self._dispatch(adapter, "@hermes_bot hi")
@@ -1546,8 +1588,12 @@ class TestRoomRoster:
         roster; the already-answered lock then keeps further edits out."""
         adapter = self._adapter()
         self._seed_channel(adapter)
+        alice = _roster_user("user-1", "alice")
         adapter._chatto_client.list_room_members = AsyncMock(
-            return_value=self._members(_roster_user("user-1", "alice"))
+            return_value=self._members(alice)
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=self._directory_members(alice)
         )
 
         await self._edit(adapter, "no mention yet")
@@ -1562,13 +1608,18 @@ class TestRoomRoster:
         adapter = self._adapter()
         self._seed_channel(adapter)
         adapter._chatto_client.list_room_members = AsyncMock(return_value=([], Page()))
+        adapter._chatto_client.batch_get_room_members = AsyncMock()
 
         await self._dispatch(adapter, "@hermes_bot hi")
         assert self._dispatched_event(adapter).channel_context is None
 
         adapter.handle_message.reset_mock()
+        alice = _roster_user("user-1", "alice")
         adapter._chatto_client.list_room_members = AsyncMock(
-            return_value=self._members(_roster_user("user-1", "alice"))
+            return_value=self._members(alice)
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=self._directory_members(alice)
         )
         payload = _make_posted_payload(room_id="room-1", message_event_id="msg-9")
         _with_get_message(
@@ -1589,8 +1640,12 @@ class TestRoomRoster:
         adapter = self._adapter()
         self._seed_channel(adapter)
         # alice starts UNSPECIFIED (no presence label in the rendered entry).
+        alice = _roster_user("user-1", "alice")
         adapter._chatto_client.list_room_members = AsyncMock(
-            return_value=self._members(_roster_user("user-1", "alice"))
+            return_value=self._members(alice)
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=self._directory_members(alice)
         )
 
         await self._dispatch(adapter, "@hermes_bot hi")
@@ -1615,8 +1670,12 @@ class TestRoomRoster:
         still rides on every turn, but the directory is never re-asked."""
         adapter = self._adapter()
         self._seed_channel(adapter)
+        alice = _roster_user("user-1", "alice")
         adapter._chatto_client.list_room_members = AsyncMock(
-            return_value=self._members(_roster_user("user-1", "alice"))
+            return_value=self._members(alice)
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=self._directory_members(alice)
         )
 
         await self._dispatch(adapter, "@hermes_bot hi")
@@ -1640,8 +1699,12 @@ class TestRoomRoster:
         rendered line untouched — but the turn still carries it."""
         adapter = self._adapter()
         self._seed_channel(adapter)
+        alice = _roster_user("user-1", "alice")
         adapter._chatto_client.list_room_members = AsyncMock(
-            return_value=self._members(_roster_user("user-1", "alice"))
+            return_value=self._members(alice)
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=self._directory_members(alice)
         )
 
         await self._dispatch(adapter, "@hermes_bot hi")
@@ -1665,8 +1728,12 @@ class TestRoomRoster:
         the bot in its own audience."""
         adapter = self._adapter()
         self._seed_channel(adapter)
+        alice = _roster_user("user-1", "alice")
         adapter._chatto_client.list_room_members = AsyncMock(
-            return_value=self._members(_roster_user("user-1", "alice"))
+            return_value=self._members(alice)
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=self._directory_members(alice)
         )
 
         await self._dispatch(adapter, "@hermes_bot hi")
@@ -1685,6 +1752,7 @@ class TestRoomRoster:
         adapter = self._adapter()
         self._seed_channel(adapter)
         adapter._chatto_client.list_room_members = AsyncMock()
+        adapter._chatto_client.batch_get_room_members = AsyncMock()
 
         await adapter._handle_realtime_event(_make_presence_event(user_id="user-42"))
 
@@ -1696,11 +1764,13 @@ class TestRoomRoster:
         next turn refetches once, delivering the updated line."""
         adapter = self._adapter()
         self._seed_channel(adapter)
+        alice = _roster_user("user-1", "alice")
+        bob = _roster_user("user-2", "bob")
         adapter._chatto_client.list_room_members = AsyncMock(
-            return_value=self._members(
-                _roster_user("user-1", "alice"),
-                _roster_user("user-2", "bob"),
-            )
+            return_value=self._members(alice, bob)
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=self._directory_members(alice, bob)
         )
 
         await self._dispatch(adapter, "@hermes_bot hi")
@@ -1713,8 +1783,9 @@ class TestRoomRoster:
         assert "room-1" not in adapter._rosters
 
         # The server directory now reflects who actually remained.
-        adapter._chatto_client.list_room_members.return_value = self._members(
-            _roster_user("user-2", "bob")
+        adapter._chatto_client.list_room_members.return_value = self._members(bob)
+        adapter._chatto_client.batch_get_room_members.return_value = (
+            self._directory_members(bob)
         )
         await self._dispatch(
             adapter,
@@ -1743,8 +1814,12 @@ class TestRoomRoster:
         next turn instead of paying a burst right away."""
         adapter = self._adapter()
         self._seed_channel(adapter)
+        alice = _roster_user("user-1", "alice")
         adapter._chatto_client.list_room_members = AsyncMock(
-            return_value=self._members(_roster_user("user-1", "alice"))
+            return_value=self._members(alice)
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=self._directory_members(alice)
         )
 
         await self._dispatch(adapter, "@hermes_bot hi")
@@ -1770,8 +1845,12 @@ class TestRoomRoster:
         adapter = self._adapter()
         self._seed_channel(adapter, room_id="gone-1")
         adapter._joined_room_ids = ["gone-1"]
+        alice = _roster_user("user-1", "alice")
         adapter._chatto_client.list_room_members = AsyncMock(
-            return_value=self._members(_roster_user("user-1", "alice"))
+            return_value=self._members(alice)
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=self._directory_members(alice)
         )
 
         await self._dispatch(adapter, "@hermes_bot hi", room_id="gone-1")
@@ -2196,7 +2275,7 @@ class TestInboundAttachments:
 # NOTE: there are deliberately no tests for get_user(), set_presence() or
 # set_custom_status() on the adapter. Those are not adapter responsibilities —
 # callers use the chattolib client directly, which exposes them (client.get_user,
-# client.update_presence, client.update_custom_status). The adapter only touches
+# client.set_presence, client.update_custom_status). The adapter only touches
 # presence in connect()/disconnect().
 
 
@@ -2566,17 +2645,22 @@ class TestSilentRoomRefresh:
     async def test_join_log_shows_dm_partner_username(self, caplog):
         adapter = self._adapter([])
         adapter.me = User(id="bot-1", login="hermes", display_name="Hermes")
-        directory: dict[str, list[DirectoryMember]] = {
-            "dm-1": [
-                DirectoryMember(user=adapter.me),
-                DirectoryMember(user=_make_user("user-9", "paula")),
-            ]
-        }
+        dm_users = [
+            DirectoryMember(user=adapter.me),
+            DirectoryMember(user=_make_user("user-9", "paula")),
+        ]
 
         async def list_members(room_id, **_kwargs):
-            return directory.get(room_id, []), Page()
+            members = dm_users if room_id == "dm-1" else []
+            return [m.user.id for m in members], Page()
+
+        async def batch_members(room_id, user_ids):
+            return [dm for dm in dm_users if dm.user.id in user_ids]
 
         adapter._chatto_client.list_room_members = AsyncMock(side_effect=list_members)
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            side_effect=batch_members
+        )
         dm = _make_room_state(_make_room("dm-1", "", RoomKind.DM), True)
         adapter._chatto_client.list_rooms = AsyncMock(return_value=[dm])
 

+ 1 - 1
vendor_chattolib.sh

@@ -28,7 +28,7 @@ trap 'rm -rf "$BUILD_DIR"' EXIT
 # The [realtime] extra pulls in websockets, which chattolib's event stream
 # imports at connect time. Without it the plugin registers fine and only
 # starts warn-looping once the gateway connects — vendor it.
-CHATTOLIB_SPEC="${CHATTOLIB_SPEC:-chattolib[realtime]>=0.5.0a5,<0.6}"
+CHATTOLIB_SPEC="${CHATTOLIB_SPEC:-chattolib[realtime]>=0.5.0b4,<0.6}"
 
 # Wheels are resolved for this Python version. 3.11 is the project's minimum
 # (pyproject: requires-python) AND the version for which the binary packages