Explorar o código

Track Chatto 0.5.0-beta.7

The ban triad in RoomService is replaced by a suspension triad:
BanMember/UnbanMember/ListBans (and the RoomBan message) become
RemoveUser / LiftSuspension / ListSuspensions (RoomSuspension). The
removal request's optional expires_at becomes a oneof suspension
(suspension_expires_at or suspend_indefinitely; at most one, and
Universal rooms require one). Client methods adapted to
remove_user/lift_suspension/list_suspensions with a oneof-conflict
guard, RoomBan dataclass renamed RoomSuspension, and the public
export updated. AGENTS.md service table + Gotcha added; tests now
cover the three RPCs including the oneof wiring.

Verification: ruff format+check clean; mypy zero new errors vs
baseline (only the 6 pre-existing import-untyped protobuf notes);
pytest 89 passed/2 skipped; live GetServer round-trip against
chat.chatto.run returned version 0.5.0-beta.7; live bot
integration tests (2) passed against the real server.

Bumped version 0.5.0b6 -> 0.5.0b7.
the-codemancer hai 1 mes
pai
achega
37b1b0af9f

+ 2 - 1
AGENTS.md

@@ -94,7 +94,7 @@ to its grants.
 | My account | `MyAccountService` | `UpdateProfile`, `UpdateSettings`, `SetPresence` (legacy live report), `GetPresencePreference`, `SetPresencePreference`, `RefreshPresence`, `SetCustomStatus`, `DeleteCustomStatus`, `ChangePassword`, `DeleteMyAccount` (avatar upload was removed in 0.5.0b4) |
 | Roles | `RoleService` | `ListRoles`, `GetRole`, `BatchGetRoles` |
 | Room directory | `RoomDirectoryService` | `ListRooms`, `ListRoomGroups`, `GetRoomGroup`, `BatchGetRoomGroups`, `GetRoom`, `BatchGetRooms` |
-| Rooms | `RoomService` | `CreateRoom`, `UpdateRoom`, `ArchiveRoom`, `UnarchiveRoom`, `JoinRoom`, `JoinRoomGroup`, `StartDM`, `LeaveRoom`, `AddMember`, `RemoveMember`, `ListMembers`, `GetMember`, `BatchGetMembers`, `BanMember`, `UnbanMember`, `ListBans`, `RefreshTypingIndicator`, `GetRoomEvents`, `GetRoomEventsAround`, `MarkRoomAsRead`, `ListRoomAttachments` |
+| Rooms | `RoomService` | `CreateRoom`, `UpdateRoom`, `ArchiveRoom`, `UnarchiveRoom`, `JoinRoom`, `JoinRoomGroup`, `StartDM`, `LeaveRoom`, `AddMember`, `RemoveMember`, `ListMembers`, `GetMember`, `BatchGetMembers`, `RemoveUser`, `LiftSuspension`, `ListSuspensions`, `RefreshTypingIndicator`, `GetRoomEvents`, `GetRoomEventsAround`, `MarkRoomAsRead`, `ListRoomAttachments` |
 | Messages | `MessageService` | `FetchLinkPreview`, `CreateMessage`, `UpdateMessage`, `DeleteMessage`, `DeleteAttachment`, `DeleteLinkPreview`, `GetMessage`, `BatchGetMessages`, `AddReaction`, `RemoveReaction` |
 | Threads | `ThreadService` | `FollowThread`, `UnfollowThread`, `ListFollowedThreads`, `GetThreadEvents`, `GetThreadEventsAround`, `MarkThreadAsRead` |
 | Notifications | `NotificationService` | `ListNotifications`, `GetNotification`, `BatchGetNotifications`, `ListRoomNotifications`, `ListRoomNotificationCounts`, `HasNotifications`, `DismissNotification`, `DismissAllNotifications` |
@@ -135,6 +135,7 @@ to its grants.
 - No more `motd` on the public profile; it is a separate authenticated RPC (`ServerService.GetMotd`).
 - Room groups can contain `SidebarLink` items (not just rooms). `RoomGroup.sidebar_links` exposes them.
 - **0.5.0b6 split presence into a *saved preference* and a *live report*.** `SetPresence` is now the legacy, transient live report (still rejects `OFFLINE`/`UNSPECIFIED`); a saved choice takes precedence over it, even a `user_selected` one. The new pair is `GetPresencePreference`/`SetPresencePreference` (choice for every device on the server; `OFFLINE` = "Appear Offline" and *is* allowed there; a stale `expected_revision` returns `ABORTED`) plus `RefreshPresence` for 30-second liveness. `Message` also gained `viewer_state` (`MessageViewerState.can_reply_in_thread`), resolved per viewer even before a thread exists.
+- **0.5.0b7 replaced the room ban triad with a *suspension* triad (breaking).** `BanMember`/`UnbanMember`/`ListBans` and the `RoomBan` message are gone; they are now `RemoveUser` (remove a member with a required reason; the optional `oneof suspension` is either `suspension_expires_at` or `suspend_indefinitely` — at most one, and Universal rooms require one; DM rooms are excluded), `LiftSuspension` (no-op success when no active suspension), and `ListSuspensions` (returns `repeated RoomSuspension suspensions`, page newest-first). The client methods are `remove_user(...)` / `lift_suspension(...)` / `list_suspensions(...)` and the dataclass is `RoomSuspension` (the ban's `expires_at` field name is kept). Both `RemoveUser` and `LiftSuspension` require `room.remove-member` at server scope.
 - **Never regenerate protos from `chattocorp/chatto@main`.** `scripts/generate_pb.sh` defaults to a tag derived from `pyproject.toml`: `v<base>` for finals, and `v<base>-<kind>.<n>` for PEP 440 pre-releases (`0.5.0b6` → `v0.5.0-beta.6`), probing candidates with `git ls-remote` (exact ref match; the REST `/tags/{name}` endpoint 404s on lightweight tags). Override with `CHATTO_REF=<tag>` only when you know the deployed server has caught up. chattolib 0.4.19 shipped once from `main` and broke realtime for every downstream client because Chatto's main had a protocol-v2 rewrite that the deployed 0.4.19 server did not speak.
 
 ### Known bugs

+ 50 - 49
proto/chatto/api/v1/rooms.proto

@@ -233,32 +233,35 @@ message RemoveMemberResponse {
   bool removed = 1;
 }
 
-// Request to ban a member from a channel room.
-message BanMemberRequest {
-  // Required. Channel room to ban the user from.
+// Request to remove a user from a channel room for moderation.
+message RemoveUserRequest {
+  // Required. Channel room to remove the user from.
   string room_id = 1 [(buf.validate.field).string.min_len = 1];
-  // Required. User to ban from the room.
+  // Required. Current room member to remove.
   string user_id = 2 [(buf.validate.field).string.min_len = 1];
   // Required moderator-entered reason stored for audit.
   string reason = 3 [(buf.validate.field).string = {
     min_len: 1
     max_len: 1000
   }];
-  // Optional future time when the ban expires.
-  google.protobuf.Timestamp expires_at = 4;
-}
-
-// Result of banning a room member.
-message BanMemberResponse {
-  reserved 1;
-  reserved "banned";
-}
-
-// Request to remove a channel room ban.
-message UnbanMemberRequest {
-  // Required. Channel room to unban the user from.
+  // Omit suspension to allow rejoining under normal room permissions.
+  // A Universal room requires one suspension choice.
+  oneof suspension {
+    // Future time after which the user may rejoin.
+    google.protobuf.Timestamp suspension_expires_at = 4;
+    // Must be true. The user cannot rejoin until the suspension is lifted.
+    bool suspend_indefinitely = 5;
+  }
+}
+
+// Result of a moderated room removal.
+message RemoveUserResponse {}
+
+// Request to lift a channel room suspension.
+message LiftSuspensionRequest {
+  // Required. Channel room containing the suspension.
   string room_id = 1 [(buf.validate.field).string.min_len = 1];
-  // Required. User to unban.
+  // Required. Suspended user.
   string user_id = 2 [(buf.validate.field).string.min_len = 1];
   // Required moderator-entered reason stored for audit.
   string reason = 3 [(buf.validate.field).string = {
@@ -267,49 +270,46 @@ message UnbanMemberRequest {
   }];
 }
 
-// Result of removing a room ban.
-message UnbanMemberResponse {
-  reserved 1;
-  reserved "unbanned";
-}
+// Result of lifting a room suspension.
+message LiftSuspensionResponse {}
 
-// Active channel room ban with optional hydrated room and user references.
-message RoomBan {
-  // Stable ban event ID.
+// Active channel room suspension with optional hydrated references.
+message RoomSuspension {
+  // Stable suspension event ID.
   string id = 1;
   // Channel room ID.
   string room_id = 2;
   // Channel room metadata, when the referenced room still exists.
   Room room = 3;
-  // Banned user ID.
+  // Suspended user ID.
   string user_id = 4;
-  // Banned user profile, when the referenced user still exists.
+  // Suspended user profile, when the referenced user still exists.
   DirectoryMember user = 5;
-  // Moderator user ID that created the ban.
+  // Moderator user ID that created the suspension.
   string moderator_id = 6;
   // Moderator profile, when the referenced user still exists.
   DirectoryMember moderator = 7;
-  // Moderator-entered ban reason.
+  // Moderator-entered removal reason.
   string reason = 8;
-  // Time the ban was created.
+  // Time the suspension was created.
   google.protobuf.Timestamp created_at = 9;
-  // Optional future time when the ban expires.
+  // Optional future time when the suspension expires. Absence means indefinite.
   google.protobuf.Timestamp expires_at = 10;
 }
 
-// Request to list active room bans.
-message ListBansRequest {
-  // Optional channel room filter. Empty lists active bans across all rooms.
+// Request to list active room suspensions.
+message ListSuspensionsRequest {
+  // Optional channel room filter. Empty lists active suspensions across all rooms.
   string room_id = 1;
   // Defaults to 50 results when absent or limit is zero. Maximum: 100.
   PageRequest page = 2;
 }
 
-// Active room bans visible to the current moderator.
-message ListBansResponse {
-  // Active bans by creation time, newest first; equal times use ban event ID
+// Active room suspensions visible to the current moderator.
+message ListSuspensionsResponse {
+  // Active suspensions by creation time, newest first; equal times use event ID
   // in ascending order.
-  repeated RoomBan bans = 1;
+  repeated RoomSuspension suspensions = 1;
   // Page metadata.
   PageInfo page = 2;
 }
@@ -463,16 +463,16 @@ service RoomService {
   // room or user.manage-accounts overrides the target's missing room.join.
   // Bot owners and bot.manage holders may also add bots, but without either
   // override the bot needs effective room.join, bounded by its owner's authority.
-  // Preserves permission grants. Bans, archived, universal, and DM rooms prevent adding.
+  // Preserves permission grants. Suspensions, archived, universal, and DM rooms prevent adding.
   rpc AddMember(AddMemberRequest) returns (AddMemberResponse);
   // Removes an explicit channel-room member. Requires room.manage for this room,
   // user.manage-accounts, ownership of the target bot, or bot.manage.
   // Removal is allowed after room.join is lost and from archived rooms.
   // Preserves permission grants. Universal and DM membership cannot be edited.
   rpc RemoveMember(RemoveMemberRequest) returns (RemoveMemberResponse);
-  // Lists active channel room bans. The caller must be allowed to moderate room
-  // membership bans.
-  rpc ListBans(ListBansRequest) returns (ListBansResponse);
+  // Lists active channel room suspensions. Requires server-scope
+  // room.remove-member.
+  rpc ListSuspensions(ListSuspensionsRequest) returns (ListSuspensionsResponse);
   // Lists current message-owned room attachments. Authentication and room
   // membership are required. Attachments also require message.read or a
   // matching thread relationship with message.read-interactions. The server omits attachments from
@@ -516,10 +516,11 @@ service RoomService {
   // usually call this after the user has viewed the latest visible event in the
   // room.
   rpc MarkRoomAsRead(MarkRoomAsReadRequest) returns (MarkRoomAsReadResponse);
-  // Bans a member from a channel room. Direct-message rooms cannot be moderated
-  // this way, and the target must currently be a room member.
-  rpc BanMember(BanMemberRequest) returns (BanMemberResponse);
-  // Removes an active channel room ban. Calling this when no active ban exists
-  // is allowed and still returns success.
-  rpc UnbanMember(UnbanMemberRequest) returns (UnbanMemberResponse);
+  // Removes a current channel room member with a required reason. Requires
+  // room.remove-member. Without suspension, ordinary join rules apply afterward.
+  // Universal rooms require a suspension. Direct-message rooms are excluded.
+  rpc RemoveUser(RemoveUserRequest) returns (RemoveUserResponse);
+  // Lifts an active channel room suspension. Requires room.remove-member.
+  // Calling this when no active suspension exists succeeds.
+  rpc LiftSuspension(LiftSuspensionRequest) returns (LiftSuspensionResponse);
 }

+ 1 - 1
pyproject.toml

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

+ 2 - 2
src/chattolib/__init__.py

@@ -54,11 +54,11 @@ from chattolib.types import (
     ProviderMetadata,
     Role,
     Room,
-    RoomBan,
     RoomDirectoryScope,
     RoomGroup,
     RoomKind,
     RoomSummary,
+    RoomSuspension,
     RoomThreadingMode,
     RoomViewerState,
     RoomWithViewerState,
@@ -129,10 +129,10 @@ __all__ = [
     "ProviderMetadata",
     "Role",
     "Room",
-    "RoomBan",
     "RoomDirectoryScope",
     "RoomGroup",
     "RoomKind",
+    "RoomSuspension",
     "RoomSummary",
     "RoomThreadingMode",
     "RoomViewerState",

+ 24 - 24
src/chattolib/_pb/chatto/api/v1/rooms_connect.py

@@ -189,14 +189,14 @@ class RoomServiceClient(ConnectClient):
             headers=headers,
         )
 
-    async def list_bans(self, request: chatto_api_v1_rooms_pb2.ListBansRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.ListBansResponse:
+    async def list_suspensions(self, request: chatto_api_v1_rooms_pb2.ListSuspensionsRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.ListSuspensionsResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="ListBans",
+                name="ListSuspensions",
                 service_name="chatto.api.v1.RoomService",
-                input=chatto_api_v1_rooms_pb2.ListBansRequest,
-                output=chatto_api_v1_rooms_pb2.ListBansResponse,
+                input=chatto_api_v1_rooms_pb2.ListSuspensionsRequest,
+                output=chatto_api_v1_rooms_pb2.ListSuspensionsResponse,
             ),
             headers=headers,
         )
@@ -297,26 +297,26 @@ class RoomServiceClient(ConnectClient):
             headers=headers,
         )
 
-    async def ban_member(self, request: chatto_api_v1_rooms_pb2.BanMemberRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.BanMemberResponse:
+    async def remove_user(self, request: chatto_api_v1_rooms_pb2.RemoveUserRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.RemoveUserResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="BanMember",
+                name="RemoveUser",
                 service_name="chatto.api.v1.RoomService",
-                input=chatto_api_v1_rooms_pb2.BanMemberRequest,
-                output=chatto_api_v1_rooms_pb2.BanMemberResponse,
+                input=chatto_api_v1_rooms_pb2.RemoveUserRequest,
+                output=chatto_api_v1_rooms_pb2.RemoveUserResponse,
             ),
             headers=headers,
         )
 
-    async def unban_member(self, request: chatto_api_v1_rooms_pb2.UnbanMemberRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.UnbanMemberResponse:
+    async def lift_suspension(self, request: chatto_api_v1_rooms_pb2.LiftSuspensionRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.LiftSuspensionResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="UnbanMember",
+                name="LiftSuspension",
                 service_name="chatto.api.v1.RoomService",
-                input=chatto_api_v1_rooms_pb2.UnbanMemberRequest,
-                output=chatto_api_v1_rooms_pb2.UnbanMemberResponse,
+                input=chatto_api_v1_rooms_pb2.LiftSuspensionRequest,
+                output=chatto_api_v1_rooms_pb2.LiftSuspensionResponse,
             ),
             headers=headers,
         )
@@ -503,14 +503,14 @@ class RoomServiceClientSync(ConnectClientSync):
             headers=headers,
         )
 
-    def list_bans(self, request: chatto_api_v1_rooms_pb2.ListBansRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.ListBansResponse:
+    def list_suspensions(self, request: chatto_api_v1_rooms_pb2.ListSuspensionsRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.ListSuspensionsResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="ListBans",
+                name="ListSuspensions",
                 service_name="chatto.api.v1.RoomService",
-                input=chatto_api_v1_rooms_pb2.ListBansRequest,
-                output=chatto_api_v1_rooms_pb2.ListBansResponse,
+                input=chatto_api_v1_rooms_pb2.ListSuspensionsRequest,
+                output=chatto_api_v1_rooms_pb2.ListSuspensionsResponse,
             ),
             headers=headers,
         )
@@ -611,26 +611,26 @@ class RoomServiceClientSync(ConnectClientSync):
             headers=headers,
         )
 
-    def ban_member(self, request: chatto_api_v1_rooms_pb2.BanMemberRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.BanMemberResponse:
+    def remove_user(self, request: chatto_api_v1_rooms_pb2.RemoveUserRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.RemoveUserResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="BanMember",
+                name="RemoveUser",
                 service_name="chatto.api.v1.RoomService",
-                input=chatto_api_v1_rooms_pb2.BanMemberRequest,
-                output=chatto_api_v1_rooms_pb2.BanMemberResponse,
+                input=chatto_api_v1_rooms_pb2.RemoveUserRequest,
+                output=chatto_api_v1_rooms_pb2.RemoveUserResponse,
             ),
             headers=headers,
         )
 
-    def unban_member(self, request: chatto_api_v1_rooms_pb2.UnbanMemberRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.UnbanMemberResponse:
+    def lift_suspension(self, request: chatto_api_v1_rooms_pb2.LiftSuspensionRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.LiftSuspensionResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="UnbanMember",
+                name="LiftSuspension",
                 service_name="chatto.api.v1.RoomService",
-                input=chatto_api_v1_rooms_pb2.UnbanMemberRequest,
-                output=chatto_api_v1_rooms_pb2.UnbanMemberResponse,
+                input=chatto_api_v1_rooms_pb2.LiftSuspensionRequest,
+                output=chatto_api_v1_rooms_pb2.LiftSuspensionResponse,
             ),
             headers=headers,
         )

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
src/chattolib/_pb/chatto/api/v1/rooms_pb2.py


+ 35 - 16
src/chattolib/client.py

@@ -100,9 +100,9 @@ from chattolib.types import (
     PresenceStatus,
     Role,
     Room,
-    RoomBan,
     RoomDirectoryScope,
     RoomGroup,
+    RoomSuspension,
     RoomThreadingMode,
     RoomWithViewerState,
     ServerConfig,
@@ -733,42 +733,61 @@ class ChattoClient:
             if m is not None
         ]
 
-    async def ban_member(
+    async def remove_user(
         self,
         room_id: str,
         user_id: str,
         reason: str,
         *,
-        expires_at: datetime | None = None,
+        suspension_expires_at: datetime | None = None,
+        suspend_indefinitely: bool = False,
     ) -> None:
-        req = rooms_pb2.BanMemberRequest(room_id=room_id, user_id=user_id, reason=reason)
-        if expires_at is not None:
-            req.expires_at.CopyFrom(_timestamp_pb(expires_at))
-        await self._rpc(self._svc.rooms.ban_member(req, headers=self._headers()))
+        """Remove a current room member, optionally suspending them.
+
+        Maps to ``RoomService.RemoveUser`` (the 0.5.0b7 replacement for
+        ``BanMember``). Without a suspension the user may rejoin under normal
+        room permissions; with one, they cannot rejoin until the suspension
+        expires or ``lift_suspension`` is called. At most one of the two
+        suspension choices may be given.
+        """
+        if suspend_indefinitely and suspension_expires_at is not None:
+            raise ValueError(
+                "choose either suspension_expires_at or suspend_indefinitely, not both"
+            )
+        req = rooms_pb2.RemoveUserRequest(room_id=room_id, user_id=user_id, reason=reason)
+        if suspend_indefinitely:
+            req.suspend_indefinitely = True
+        elif suspension_expires_at is not None:
+            req.suspension_expires_at.CopyFrom(_timestamp_pb(suspension_expires_at))
+        await self._rpc(self._svc.rooms.remove_user(req, headers=self._headers()))
 
-    async def unban_member(self, room_id: str, user_id: str, reason: str) -> None:
+    async def lift_suspension(self, room_id: str, user_id: str, reason: str) -> None:
         await self._rpc(
-            self._svc.rooms.unban_member(
-                rooms_pb2.UnbanMemberRequest(room_id=room_id, user_id=user_id, reason=reason),
+            self._svc.rooms.lift_suspension(
+                rooms_pb2.LiftSuspensionRequest(room_id=room_id, user_id=user_id, reason=reason),
                 headers=self._headers(),
             )
         )
 
-    async def list_bans(
+    async def list_suspensions(
         self,
         *,
         room_id: str = "",
         limit: int | None = None,
         offset: int | None = None,
-    ) -> tuple[list[RoomBan], Page]:
-        req = rooms_pb2.ListBansRequest(room_id=room_id)
+    ) -> tuple[list[RoomSuspension], Page]:
+        req = rooms_pb2.ListSuspensionsRequest(room_id=room_id)
         page = _page_pb(limit, offset)
         if page is not None:
             req.page.CopyFrom(page)
-        resp = await self._rpc(self._svc.rooms.list_bans(req, headers=self._headers()))
+        resp = await self._rpc(self._svc.rooms.list_suspensions(req, headers=self._headers()))
         data = pb_to_dict(resp)
-        bans = [b for b in (RoomBan.parse(row) for row in data.get("bans") or []) if b is not None]
-        return bans, Page.parse(data.get("page"))
+        suspensions = [
+            s
+            for s in (RoomSuspension.parse(row) for row in data.get("suspensions") or [])
+            if s is not None
+        ]
+        return suspensions, Page.parse(data.get("page"))
 
     async def refresh_typing_indicator(
         self, room_id: str, *, thread_root_event_id: str = ""

+ 2 - 2
src/chattolib/types.py

@@ -528,7 +528,7 @@ class RoomGroup:
 
 
 @dataclass
-class RoomBan:
+class RoomSuspension:
     id: str
     room_id: str
     user_id: str
@@ -541,7 +541,7 @@ class RoomBan:
     moderator: DirectoryMember | None = None
 
     @classmethod
-    def parse(cls, data: dict[str, Any] | None) -> RoomBan | None:
+    def parse(cls, data: dict[str, Any] | None) -> RoomSuspension | None:
         if not data:
             return None
         return cls(

+ 49 - 3
tests/test_client.py

@@ -8,6 +8,7 @@ paths without any real network traffic.
 
 from __future__ import annotations
 
+import datetime as dt
 from unittest.mock import AsyncMock
 
 import pytest
@@ -227,11 +228,56 @@ async def test_archive_room(client):
     assert room.archived is True
 
 
-async def test_ban_member(client):
-    _mock_method(client, "rooms", "ban_member", rooms_pb2.BanMemberResponse())
+async def test_remove_user(client):
+    resp = rooms_pb2.RemoveUserResponse()
+    mock = _mock_method(client, "rooms", "remove_user", resp)
 
     async with client:
-        assert await client.ban_member("r1", "u2", "spam") is None
+        await client.remove_user("r1", "u2", "spam")
+        await client.remove_user(
+            "r1", "u2", "spam", suspension_expires_at=dt.datetime.fromtimestamp(0, dt.UTC)
+        )
+        await client.remove_user("r1", "u2", "spam", suspend_indefinitely=True)
+
+    calls = [c.args[0] for c in mock.call_args_list]
+    assert calls[0].WhichOneof("suspension") is None
+    assert calls[1].WhichOneof("suspension") == "suspension_expires_at"
+    assert calls[2].WhichOneof("suspension") == "suspend_indefinitely"
+
+
+async def test_remove_user_rejects_both_suspensions(client):
+    _mock_method(client, "rooms", "remove_user", rooms_pb2.RemoveUserResponse())
+
+    async with client:
+        with pytest.raises(ValueError, match="not both"):
+            await client.remove_user(
+                "r1",
+                "u2",
+                "spam",
+                suspension_expires_at=dt.datetime.fromtimestamp(0, dt.UTC),
+                suspend_indefinitely=True,
+            )
+
+
+async def test_lift_suspension(client):
+    _mock_method(client, "rooms", "lift_suspension", rooms_pb2.LiftSuspensionResponse())
+
+    async with client:
+        assert await client.lift_suspension("r1", "u2", "reinstated") is None
+
+
+async def test_list_suspensions(client):
+    resp = rooms_pb2.ListSuspensionsResponse()
+    s = resp.suspensions.add()
+    s.id = "s1"
+    s.room_id = "r1"
+    s.user_id = "u2"
+    _mock_method(client, "rooms", "list_suspensions", resp)
+
+    async with client:
+        suspensions, _ = await client.list_suspensions(room_id="r1")
+
+    assert [x.id for x in suspensions] == ["s1"]
 
 
 async def test_start_dm(client):

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio