Bladeren bron

Cover the remaining Chatto Connect API surface

Extends ChattoClient so every non-operator RPC exposed by
connectapi.API.Handlers() in chattocorp/chatto is now callable through a
typed helper (or, for admin services whose shapes are server-version
dependent, a raw dict response).

New services:
- AssetUploadService — CreateUpload, UploadChunk, GetUpload,
  CompleteUpload, CancelUpload. Plus upload_attachment(room_id, path):
  reads bytes, computes SHA-256, respects max_chunk_size, completes, and
  returns an Asset whose id can be passed to post_message's
  attachment_asset_ids.
- RoleService (public) — ListRoles, GetRole, BatchGetRoles.
- MyAccountService external identity — ListExternalIdentities,
  StartExternalIdentityLink, DisconnectExternalIdentity.
- ExternalIdentityAuthService (public OAuth handoff) —
  GetPendingExternalIdentity, CreateExternalIdentityAccount,
  ConfirmExternalIdentityLink, CancelExternalIdentityFlow.
- AdminServerService — config get/update, logo/banner upload/delete,
  security config, blocked usernames.
- AdminRoomLayoutService — full CRUD for room groups (create, update,
  delete, reorder, move) and full sidebar-link CRUD/move/reorder. Closes
  bean chattolib-iy1y.
- AdminUserService — list/get/batch, assign/revoke role, update
  user/password, clear cooldown, delete.
- AdminRoleService — role CRUD + reorder.
- AdminEventLogService, AdminDiagnosticsService,
  AdminPermissionService — raw dict responses because the underlying
  permission/event shapes are server-version dependent.

New types: Role, AdminRole, AssetUpload, ExternalIdentityProvider,
LinkedExternalIdentity, ServerConfig, AdminMember,
AdminRoomLayoutGroup. New enums: AssetUploadStatus,
AdminRoomLayoutItemKind. All exported from chattolib.__init__.

Testing:
- 6 new mocked tests covering RoleService, the full AssetUpload
  create/chunk/complete cycle, ExternalIdentities, admin room-layout
  reads, admin sidebar-link create, and admin server config updates.
  Total 50 mocked tests, all passing. ruff and mypy --strict clean.
- Live-verified against Chatto HQ with RoboChatto:
  RoleService.ListRoles returned the 4 system roles
  (everyone/moderator/admin/owner) with every field decoded;
  MyAccount.ListExternalIdentities returned empty as expected (no OAuth
  configured on Chatto HQ).

CLAUDE.md refreshed with the new service rows.
TheCodemancer 3 maanden geleden
bovenliggende
commit
3f8815bb87
5 gewijzigde bestanden met toevoegingen van 1215 en 2 verwijderingen
  1. 11 1
      CLAUDE.md
  2. 20 0
      src/chattolib/__init__.py
  3. 760 1
      src/chattolib/client.py
  4. 218 0
      src/chattolib/types.py
  5. 206 0
      tests/test_client.py

+ 11 - 1
CLAUDE.md

@@ -76,8 +76,10 @@ generated protobuf bindings.
 | Discovery | `chatto.discovery.v1.ServerDiscoveryService` | `GetServer` (public) |
 | Server | `ServerService` | `GetMotd`, `GetRuntimeConfig` |
 | Viewer | `ViewerService` | `GetViewer` |
-| My account | `MyAccountService` | `UpdateProfile`, `UploadAvatar`, `DeleteAvatar`, `UpdatePassword`, `UpdateSettings`, `UpdatePresence`, `UpdateCustomStatus`, `DeleteCustomStatus`, `RequestAccountDeletion`, `DeleteMyAccount` |
+| My account | `MyAccountService` | `UpdateProfile`, `UploadAvatar`, `DeleteAvatar`, `UpdatePassword`, `UpdateSettings`, `UpdatePresence`, `UpdateCustomStatus`, `DeleteCustomStatus`, `RequestAccountDeletion`, `DeleteMyAccount`, `ListExternalIdentities`, `StartExternalIdentityLink`, `DisconnectExternalIdentity` |
 | Users | `UserService` | `ListUsers`, `GetUser`, `BatchGetUsers` |
+| Roles | `RoleService` | `ListRoles`, `GetRole`, `BatchGetRoles` |
+| External identity auth (public) | `chatto.auth.v1.ExternalIdentityAuthService` | `GetPendingExternalIdentity`, `CreateExternalIdentityAccount`, `ConfirmExternalIdentityLink`, `CancelExternalIdentityFlow` |
 | 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`, `UpdateTypingIndicator`, `GetRoomEvents`, `GetRoomEventsAround`, `MarkRoomAsRead`, `ListRoomAttachments` |
 | Messages | `MessageService` | `FetchLinkPreview`, `CreateMessage`, `UpdateMessage`, `DeleteMessage`, `DeleteAttachment`, `DeleteLinkPreview`, `GetMessage`, `BatchGetMessages`, `AddReaction`, `RemoveReaction` |
@@ -86,6 +88,14 @@ generated protobuf bindings.
 | Notification prefs | `NotificationPreferencesService` | `Get`/`Update` × `Server`/`Room` |
 | Push | `PushNotificationService` | `Subscribe`, `Unsubscribe` |
 | Assets | `AssetService` | `GetAsset`, `BatchGetAssets` |
+| Asset uploads | `AssetUploadService` | `CreateUpload`, `UploadChunk`, `GetUpload`, `CompleteUpload`, `CancelUpload`. `upload_attachment(room, path)` helper computes SHA-256, chunks, and completes in one call. |
+| Admin: server | `chatto.admin.v1.AdminServerService` | `GetServerConfig`, `UpdateServerConfig`, `UploadServerLogo`, `DeleteServerLogo`, `UploadServerBanner`, `DeleteServerBanner`, `GetServerSecurityConfig`, `UpdateBlockedUsernames` |
+| Admin: room layout | `chatto.admin.v1.AdminRoomLayoutService` | `ListRoomGroups`, `Create/Update/Delete/ReorderRoomGroup(s)`, `MoveRoomToGroup`, `ReorderSidebarItemsInGroup`, `Create/Update/Delete/MoveSidebarLink(ToGroup)` |
+| Admin: users | `chatto.admin.v1.AdminUserService` | `ListMembers`, `GetMember`, `BatchGetMembers`, `AssignRole`, `RevokeRole`, `UpdateUser`, `UpdateUserPassword`, `ClearUsernameCooldown`, `DeleteUser` |
+| Admin: roles | `chatto.admin.v1.AdminRoleService` | `ListRoles`, `GetRole`, `CreateRole`, `UpdateRole`, `DeleteRole`, `ReorderRoles` |
+| Admin: event log | `chatto.admin.v1.AdminEventLogService` | `ListEvents`, `ListEventTypes`, `GetEvent` (raw response) |
+| Admin: diagnostics | `chatto.admin.v1.AdminDiagnosticsService` | `GetSystemInfo` (raw response) |
+| Admin: permissions | `chatto.admin.v1.AdminPermissionService` | `GetRole/UserPermissionMatrix`, `ListRole/UserPermissionDecisions`, `ExplainPermissions`, `SetRolePermission`, `SetUserPermission` (raw responses where the permission shape is server-version-dependent) |
 | Voice calls | `VoiceCallService` | `ListActiveCalls`, `GetActiveCall`, `BatchGetActiveCalls`, `JoinCall`, `LeaveCall`, `GetCallToken` |
 | Realtime (WS) | `chatto.realtime.v1` protobuf WS | `stream_events(client)` / `RealtimeConnection` — full frame set: hello, subscribe, event, heartbeat, ping/pong, error, close |
 

+ 20 - 0
src/chattolib/__init__.py

@@ -12,14 +12,22 @@ from chattolib.realtime import (
 )
 from chattolib.types import (
     ActiveCall,
+    AdminMember,
+    AdminRole,
+    AdminRoomLayoutGroup,
+    AdminRoomLayoutItemKind,
     Asset,
+    AssetUpload,
+    AssetUploadStatus,
     AssetUrl,
     CustomUserStatus,
     DirectoryMember,
+    ExternalIdentityProvider,
     FollowedThread,
     FollowedThreadsPage,
     ImageFitMode,
     ImageTransformOptions,
+    LinkedExternalIdentity,
     LinkPreview,
     Message,
     MessageAttachment,
@@ -31,6 +39,7 @@ from chattolib.types import (
     Page,
     PresenceStatus,
     ProviderMetadata,
+    Role,
     Room,
     RoomBan,
     RoomDirectoryScope,
@@ -39,6 +48,7 @@ from chattolib.types import (
     RoomSummary,
     RoomViewerState,
     RoomWithViewerState,
+    ServerConfig,
     ServerLogin,
     ServerProfile,
     ServerRuntimeConfig,
@@ -67,14 +77,22 @@ __all__ = [
     "ServerHello",
     "stream_events",
     "ActiveCall",
+    "AdminMember",
+    "AdminRole",
+    "AdminRoomLayoutGroup",
+    "AdminRoomLayoutItemKind",
     "Asset",
+    "AssetUpload",
+    "AssetUploadStatus",
     "AssetUrl",
     "CustomUserStatus",
     "DirectoryMember",
+    "ExternalIdentityProvider",
     "FollowedThread",
     "FollowedThreadsPage",
     "ImageFitMode",
     "ImageTransformOptions",
+    "LinkedExternalIdentity",
     "LinkPreview",
     "Message",
     "MessageAttachment",
@@ -86,6 +104,7 @@ __all__ = [
     "Page",
     "PresenceStatus",
     "ProviderMetadata",
+    "Role",
     "Room",
     "RoomBan",
     "RoomDirectoryScope",
@@ -94,6 +113,7 @@ __all__ = [
     "RoomSummary",
     "RoomViewerState",
     "RoomWithViewerState",
+    "ServerConfig",
     "ServerLogin",
     "ServerProfile",
     "ServerRuntimeConfig",

+ 760 - 1
src/chattolib/client.py

@@ -8,6 +8,7 @@ request/response operations; realtime events live in ``chattolib.realtime``.
 from __future__ import annotations
 
 import base64
+import hashlib
 from datetime import datetime
 from pathlib import Path
 from typing import Any
@@ -15,14 +16,21 @@ from typing import Any
 import httpx
 
 from chattolib import _transport
-from chattolib.exceptions import ChattoAuthError
+from chattolib.exceptions import ChattoAuthError, ChattoError
 from chattolib.types import (
     ActiveCall,
+    AdminMember,
+    AdminRole,
+    AdminRoomLayoutGroup,
+    AdminRoomLayoutItemKind,
     Asset,
+    AssetUpload,
     DirectoryMember,
+    ExternalIdentityProvider,
     FollowedThread,
     FollowedThreadsPage,
     ImageTransformOptions,
+    LinkedExternalIdentity,
     LinkPreview,
     Message,
     Notification,
@@ -31,11 +39,13 @@ from chattolib.types import (
     NotificationsPage,
     Page,
     PresenceStatus,
+    Role,
     Room,
     RoomBan,
     RoomDirectoryScope,
     RoomGroup,
     RoomWithViewerState,
+    ServerConfig,
     ServerLogin,
     ServerProfile,
     ServerRuntimeConfig,
@@ -49,6 +59,7 @@ from chattolib.types import (
 )
 
 API_V1 = "chatto.api.v1"
+ADMIN_V1 = "chatto.admin.v1"
 AUTH_V1 = "chatto.auth.v1"
 DISCOVERY_V1 = "chatto.discovery.v1"
 
@@ -1157,3 +1168,751 @@ class ChattoClient:
             f"{API_V1}.VoiceCallService", "GetCallToken", {"roomId": room_id}
         )
         return str(data.get("token", ""))
+
+    # --- Roles (public) -----------------------------------------------
+
+    async def list_roles(self) -> list[Role]:
+        data = await self.call(f"{API_V1}.RoleService", "ListRoles")
+        return [
+            r for r in (Role.parse(row) for row in data.get("roles") or []) if r is not None
+        ]
+
+    async def get_role(self, name: str) -> Role | None:
+        data = await self.call(f"{API_V1}.RoleService", "GetRole", {"name": name})
+        return Role.parse(data.get("role"))
+
+    async def batch_get_roles(self, names: list[str]) -> list[Role]:
+        data = await self.call(
+            f"{API_V1}.RoleService", "BatchGetRoles", {"names": names}
+        )
+        return [
+            r for r in (Role.parse(row) for row in data.get("roles") or []) if r is not None
+        ]
+
+    # --- Asset uploads ------------------------------------------------
+
+    async def create_upload(
+        self,
+        room_id: str,
+        filename: str,
+        size: int,
+        sha256: str,
+        *,
+        content_type: str = "",
+    ) -> AssetUpload:
+        data = await self.call(
+            f"{API_V1}.AssetUploadService",
+            "CreateUpload",
+            {
+                "roomId": room_id,
+                "filename": filename,
+                "contentType": content_type,
+                "size": size,
+                "sha256": sha256,
+            },
+        )
+        upload = AssetUpload.parse(data.get("upload"))
+        assert upload is not None
+        return upload
+
+    async def upload_chunk(
+        self,
+        upload_id: str,
+        offset: int,
+        content: bytes,
+        chunk_sha256: str,
+    ) -> AssetUpload:
+        data = await self.call(
+            f"{API_V1}.AssetUploadService",
+            "UploadChunk",
+            {
+                "uploadId": upload_id,
+                "offset": offset,
+                "content": base64.b64encode(content).decode("ascii"),
+                "chunkSha256": chunk_sha256,
+            },
+        )
+        upload = AssetUpload.parse(data.get("upload"))
+        assert upload is not None
+        return upload
+
+    async def get_upload(self, upload_id: str) -> AssetUpload:
+        data = await self.call(
+            f"{API_V1}.AssetUploadService", "GetUpload", {"uploadId": upload_id}
+        )
+        upload = AssetUpload.parse(data.get("upload"))
+        assert upload is not None
+        return upload
+
+    async def complete_upload(self, upload_id: str) -> tuple[AssetUpload, Asset | None]:
+        data = await self.call(
+            f"{API_V1}.AssetUploadService",
+            "CompleteUpload",
+            {"uploadId": upload_id},
+        )
+        upload = AssetUpload.parse(data.get("upload"))
+        assert upload is not None
+        return upload, Asset.parse(data.get("asset"))
+
+    async def cancel_upload(self, upload_id: str) -> AssetUpload:
+        data = await self.call(
+            f"{API_V1}.AssetUploadService",
+            "CancelUpload",
+            {"uploadId": upload_id},
+        )
+        upload = AssetUpload.parse(data.get("upload"))
+        assert upload is not None
+        return upload
+
+    async def upload_attachment(
+        self,
+        room_id: str,
+        file_path: str | Path,
+        *,
+        content_type: str = "",
+        filename: str | None = None,
+    ) -> Asset:
+        """Upload a file as a room attachment and return the resulting Asset.
+
+        Convenience wrapper that computes the file's SHA-256, calls
+        ``CreateUpload``, streams chunks respecting the server's
+        ``max_chunk_size``, and finishes with ``CompleteUpload``. Use the
+        returned ``Asset.id`` in ``post_message(attachment_asset_ids=[...])``
+        to attach the file to a message.
+        """
+        path = Path(file_path)
+        data = path.read_bytes()
+        size = len(data)
+        sha = hashlib.sha256(data).hexdigest()
+        upload = await self.create_upload(
+            room_id,
+            filename or path.name,
+            size,
+            sha,
+            content_type=content_type,
+        )
+        chunk_size = upload.max_chunk_size or 512 * 1024
+        offset = upload.committed_offset
+        while offset < size:
+            end = min(offset + chunk_size, size)
+            chunk = data[offset:end]
+            chunk_sha = hashlib.sha256(chunk).hexdigest()
+            upload = await self.upload_chunk(upload.upload_id, offset, chunk, chunk_sha)
+            if upload.committed_offset <= offset:
+                raise ChattoError(
+                    f"upload stalled at offset {offset} (server reported "
+                    f"committed_offset={upload.committed_offset})"
+                )
+            offset = upload.committed_offset
+        upload, asset = await self.complete_upload(upload.upload_id)
+        if asset is None:
+            raise ChattoError("upload completed but server returned no asset")
+        return asset
+
+    # --- MyAccount external identities --------------------------------
+
+    async def list_external_identities(
+        self,
+    ) -> tuple[list[ExternalIdentityProvider], list[LinkedExternalIdentity]]:
+        data = await self.call(
+            f"{API_V1}.MyAccountService", "ListExternalIdentities"
+        )
+        providers = [ExternalIdentityProvider.parse(p) for p in data.get("providers") or []]
+        linked = [
+            LinkedExternalIdentity.parse(li) for li in data.get("linkedIdentities") or []
+        ]
+        return providers, linked
+
+    async def start_external_identity_link(
+        self,
+        provider_id: str,
+        *,
+        redirect_path: str = "",
+        current_password: str = "",
+    ) -> str:
+        data = await self.call(
+            f"{API_V1}.MyAccountService",
+            "StartExternalIdentityLink",
+            {
+                "providerId": provider_id,
+                "redirectPath": redirect_path,
+                "currentPassword": current_password,
+            },
+        )
+        return str(data.get("startUrl", ""))
+
+    async def disconnect_external_identity(
+        self, subject_hash: str, *, current_password: str = ""
+    ) -> bool:
+        data = await self.call(
+            f"{API_V1}.MyAccountService",
+            "DisconnectExternalIdentity",
+            {"subjectHash": subject_hash, "currentPassword": current_password},
+        )
+        return bool(data.get("disconnected", False))
+
+    # --- ExternalIdentityAuthService (public OAuth handoff) -----------
+
+    async def get_pending_external_identity(self, token: str) -> dict[str, Any]:
+        return await self.call(
+            f"{AUTH_V1}.ExternalIdentityAuthService",
+            "GetPendingExternalIdentity",
+            {"token": token},
+        )
+
+    async def create_external_identity_account(
+        self, token: str, login: str
+    ) -> dict[str, Any]:
+        return await self.call(
+            f"{AUTH_V1}.ExternalIdentityAuthService",
+            "CreateExternalIdentityAccount",
+            {"token": token, "login": login},
+        )
+
+    async def confirm_external_identity_link(
+        self, token: str
+    ) -> LinkedExternalIdentity | None:
+        data = await self.call(
+            f"{AUTH_V1}.ExternalIdentityAuthService",
+            "ConfirmExternalIdentityLink",
+            {"token": token},
+        )
+        linked = data.get("linkedIdentity")
+        return LinkedExternalIdentity.parse(linked) if linked else None
+
+    async def cancel_external_identity_flow(self, token: str) -> bool:
+        data = await self.call(
+            f"{AUTH_V1}.ExternalIdentityAuthService",
+            "CancelExternalIdentityFlow",
+            {"token": token},
+        )
+        return bool(data.get("cancelled", False))
+
+    # --- Admin: server --------------------------------------------------
+
+    async def admin_get_server_config(self) -> tuple[ServerConfig, ServerProfile]:
+        data = await self.call(f"{ADMIN_V1}.AdminServerService", "GetServerConfig")
+        return (
+            ServerConfig.parse(data.get("config")),
+            ServerProfile.parse(data.get("publicProfile")),
+        )
+
+    async def admin_update_server_config(
+        self,
+        *,
+        server_name: str | None = None,
+        description: str | None = None,
+        motd: str | None = None,
+        welcome_message: str | None = None,
+    ) -> tuple[ServerConfig, ServerProfile]:
+        request: dict[str, Any] = {}
+        if server_name is not None:
+            request["serverName"] = server_name
+        if description is not None:
+            request["description"] = description
+        if motd is not None:
+            request["motd"] = motd
+        if welcome_message is not None:
+            request["welcomeMessage"] = welcome_message
+        data = await self.call(
+            f"{ADMIN_V1}.AdminServerService", "UpdateServerConfig", request
+        )
+        return (
+            ServerConfig.parse(data.get("config")),
+            ServerProfile.parse(data.get("publicProfile")),
+        )
+
+    async def admin_upload_server_logo(
+        self,
+        file_path: str | Path,
+        *,
+        content_type: str = "image/png",
+    ) -> ServerProfile:
+        p = Path(file_path)
+        payload = {
+            "image": {
+                "image": base64.b64encode(p.read_bytes()).decode("ascii"),
+                "filename": p.name,
+                "contentType": content_type,
+            }
+        }
+        data = await self.call(
+            f"{ADMIN_V1}.AdminServerService", "UploadServerLogo", payload
+        )
+        return ServerProfile.parse(data.get("publicProfile"))
+
+    async def admin_delete_server_logo(self) -> ServerProfile:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminServerService", "DeleteServerLogo"
+        )
+        return ServerProfile.parse(data.get("publicProfile"))
+
+    async def admin_upload_server_banner(
+        self,
+        file_path: str | Path,
+        *,
+        content_type: str = "image/png",
+    ) -> ServerProfile:
+        p = Path(file_path)
+        payload = {
+            "image": {
+                "image": base64.b64encode(p.read_bytes()).decode("ascii"),
+                "filename": p.name,
+                "contentType": content_type,
+            }
+        }
+        data = await self.call(
+            f"{ADMIN_V1}.AdminServerService", "UploadServerBanner", payload
+        )
+        return ServerProfile.parse(data.get("publicProfile"))
+
+    async def admin_delete_server_banner(self) -> ServerProfile:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminServerService", "DeleteServerBanner"
+        )
+        return ServerProfile.parse(data.get("publicProfile"))
+
+    async def admin_get_server_security_config(self) -> list[str]:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminServerService", "GetServerSecurityConfig"
+        )
+        return list(data.get("blockedUsernames") or [])
+
+    async def admin_update_blocked_usernames(self, usernames: list[str]) -> list[str]:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminServerService",
+            "UpdateBlockedUsernames",
+            {"blockedUsernames": usernames},
+        )
+        return list(data.get("blockedUsernames") or [])
+
+    # --- Admin: room layout & sidebar links ---------------------------
+
+    async def admin_list_room_groups(self) -> list[AdminRoomLayoutGroup]:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoomLayoutService", "ListRoomGroups"
+        )
+        return [
+            g
+            for g in (
+                AdminRoomLayoutGroup.parse(row) for row in data.get("groups") or []
+            )
+            if g is not None
+        ]
+
+    async def admin_create_room_group(
+        self, name: str, description: str = ""
+    ) -> AdminRoomLayoutGroup:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoomLayoutService",
+            "CreateRoomGroup",
+            {"name": name, "description": description},
+        )
+        group = AdminRoomLayoutGroup.parse(data.get("group"))
+        assert group is not None
+        return group
+
+    async def admin_update_room_group(
+        self,
+        group_id: str,
+        *,
+        name: str | None = None,
+        description: str | None = None,
+    ) -> AdminRoomLayoutGroup:
+        request: dict[str, Any] = {"groupId": group_id}
+        if name is not None:
+            request["name"] = name
+        if description is not None:
+            request["description"] = description
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoomLayoutService", "UpdateRoomGroup", request
+        )
+        group = AdminRoomLayoutGroup.parse(data.get("group"))
+        assert group is not None
+        return group
+
+    async def admin_delete_room_group(self, group_id: str) -> bool:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoomLayoutService",
+            "DeleteRoomGroup",
+            {"groupId": group_id},
+        )
+        return bool(data.get("deleted", False))
+
+    async def admin_reorder_room_groups(
+        self, ordered_group_ids: list[str]
+    ) -> list[AdminRoomLayoutGroup]:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoomLayoutService",
+            "ReorderRoomGroups",
+            {"orderedGroupIds": ordered_group_ids},
+        )
+        return [
+            g
+            for g in (
+                AdminRoomLayoutGroup.parse(row) for row in data.get("groups") or []
+            )
+            if g is not None
+        ]
+
+    async def admin_move_room_to_group(self, room_id: str, group_id: str) -> Room:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoomLayoutService",
+            "MoveRoomToGroup",
+            {"roomId": room_id, "groupId": group_id},
+        )
+        room = Room.parse(data.get("room"))
+        assert room is not None
+        return room
+
+    async def admin_reorder_sidebar_items_in_group(
+        self,
+        group_id: str,
+        items: list[tuple[AdminRoomLayoutItemKind, str]],
+    ) -> AdminRoomLayoutGroup:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoomLayoutService",
+            "ReorderSidebarItemsInGroup",
+            {
+                "groupId": group_id,
+                "items": [{"kind": k.value, "id": i} for k, i in items],
+            },
+        )
+        group = AdminRoomLayoutGroup.parse(data.get("group"))
+        assert group is not None
+        return group
+
+    async def admin_create_sidebar_link(
+        self, group_id: str, label: str, url: str
+    ) -> dict[str, Any]:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoomLayoutService",
+            "CreateSidebarLink",
+            {"groupId": group_id, "label": label, "url": url},
+        )
+        sl = data.get("sidebarLink") or {}
+        return {"id": sl.get("id", ""), "label": sl.get("label", ""), "url": sl.get("url", "")}
+
+    async def admin_update_sidebar_link(
+        self,
+        link_id: str,
+        *,
+        label: str | None = None,
+        url: str | None = None,
+    ) -> dict[str, Any]:
+        request: dict[str, Any] = {"linkId": link_id}
+        if label is not None:
+            request["label"] = label
+        if url is not None:
+            request["url"] = url
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoomLayoutService", "UpdateSidebarLink", request
+        )
+        sl = data.get("sidebarLink") or {}
+        return {"id": sl.get("id", ""), "label": sl.get("label", ""), "url": sl.get("url", "")}
+
+    async def admin_delete_sidebar_link(self, link_id: str) -> bool:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoomLayoutService",
+            "DeleteSidebarLink",
+            {"linkId": link_id},
+        )
+        return bool(data.get("deleted", False))
+
+    async def admin_move_sidebar_link_to_group(
+        self, link_id: str, group_id: str
+    ) -> dict[str, Any]:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoomLayoutService",
+            "MoveSidebarLinkToGroup",
+            {"linkId": link_id, "groupId": group_id},
+        )
+        sl = data.get("sidebarLink") or {}
+        return {"id": sl.get("id", ""), "label": sl.get("label", ""), "url": sl.get("url", "")}
+
+    # --- Admin: users --------------------------------------------------
+
+    async def admin_list_members(
+        self,
+        *,
+        search: str = "",
+        limit: int | None = None,
+        offset: int | None = None,
+    ) -> tuple[list[AdminMember], list[Role], Page]:
+        request: dict[str, Any] = {}
+        if search:
+            request["search"] = search
+        page = _page_arg(limit, offset)
+        if page is not None:
+            request["page"] = page
+        data = await self.call(
+            f"{ADMIN_V1}.AdminUserService", "ListMembers", request
+        )
+        members = [
+            m
+            for m in (AdminMember.parse(row) for row in data.get("members") or [])
+            if m is not None
+        ]
+        roles = [
+            r for r in (Role.parse(row) for row in data.get("roles") or []) if r is not None
+        ]
+        return members, roles, Page.parse(data.get("page"))
+
+    async def admin_get_member(
+        self,
+        *,
+        user_id: str | None = None,
+        login: str | None = None,
+    ) -> dict[str, Any]:
+        if bool(user_id) == bool(login):
+            raise ValueError("admin_get_member requires exactly one of user_id or login")
+        request: dict[str, Any] = {"userId": user_id} if user_id else {"login": login}
+        return await self.call(
+            f"{ADMIN_V1}.AdminUserService", "GetMember", request
+        )
+
+    async def admin_batch_get_members(
+        self, user_ids: list[str]
+    ) -> list[AdminMember]:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminUserService",
+            "BatchGetMembers",
+            {"userIds": user_ids},
+        )
+        return [
+            m
+            for m in (AdminMember.parse(row) for row in data.get("members") or [])
+            if m is not None
+        ]
+
+    async def admin_assign_role(
+        self, user_id: str, role_name: str
+    ) -> AdminMember | None:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminUserService",
+            "AssignRole",
+            {"userId": user_id, "roleName": role_name},
+        )
+        return AdminMember.parse(data.get("member"))
+
+    async def admin_revoke_role(
+        self, user_id: str, role_name: str
+    ) -> AdminMember | None:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminUserService",
+            "RevokeRole",
+            {"userId": user_id, "roleName": role_name},
+        )
+        return AdminMember.parse(data.get("member"))
+
+    async def admin_update_user(
+        self,
+        user_id: str,
+        *,
+        display_name: str | None = None,
+        login: str | None = None,
+    ) -> tuple[User | None, AdminMember | None]:
+        request: dict[str, Any] = {"userId": user_id}
+        if display_name is not None:
+            request["displayName"] = display_name
+        if login is not None:
+            request["login"] = login
+        data = await self.call(
+            f"{ADMIN_V1}.AdminUserService", "UpdateUser", request
+        )
+        return User.parse(data.get("user")), AdminMember.parse(data.get("member"))
+
+    async def admin_update_user_password(
+        self, user_id: str, password: str
+    ) -> AdminMember | None:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminUserService",
+            "UpdateUserPassword",
+            {"userId": user_id, "password": password},
+        )
+        return AdminMember.parse(data.get("member"))
+
+    async def admin_clear_username_cooldown(self, user_id: str) -> bool:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminUserService",
+            "ClearUsernameCooldown",
+            {"userId": user_id},
+        )
+        return bool(data.get("cleared", False))
+
+    async def admin_delete_user(
+        self, user_id: str, *, current_password: str = ""
+    ) -> bool:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminUserService",
+            "DeleteUser",
+            {"userId": user_id, "currentPassword": current_password},
+        )
+        return bool(data.get("deleted", False))
+
+    # --- Admin: roles --------------------------------------------------
+
+    async def admin_list_roles(self) -> list[AdminRole]:
+        data = await self.call(f"{ADMIN_V1}.AdminRoleService", "ListRoles")
+        return [
+            r
+            for r in (AdminRole.parse(row) for row in data.get("roles") or [])
+            if r is not None
+        ]
+
+    async def admin_get_role(self, name: str) -> dict[str, Any]:
+        return await self.call(
+            f"{ADMIN_V1}.AdminRoleService", "GetRole", {"name": name}
+        )
+
+    async def admin_create_role(
+        self,
+        name: str,
+        *,
+        display_name: str = "",
+        description: str = "",
+        pingable: bool = False,
+    ) -> AdminRole | None:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoleService",
+            "CreateRole",
+            {
+                "name": name,
+                "displayName": display_name,
+                "description": description,
+                "pingable": pingable,
+            },
+        )
+        return AdminRole.parse(data.get("role"))
+
+    async def admin_update_role(
+        self,
+        name: str,
+        *,
+        display_name: str | None = None,
+        description: str | None = None,
+        pingable: bool | None = None,
+    ) -> AdminRole | None:
+        request: dict[str, Any] = {"name": name}
+        if display_name is not None:
+            request["displayName"] = display_name
+        if description is not None:
+            request["description"] = description
+        if pingable is not None:
+            request["pingable"] = pingable
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoleService", "UpdateRole", request
+        )
+        return AdminRole.parse(data.get("role"))
+
+    async def admin_delete_role(self, name: str) -> bool:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoleService", "DeleteRole", {"name": name}
+        )
+        return bool(data.get("deleted", False))
+
+    async def admin_reorder_roles(self, role_names: list[str]) -> list[AdminRole]:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminRoleService",
+            "ReorderRoles",
+            {"roleNames": role_names},
+        )
+        return [
+            r
+            for r in (AdminRole.parse(row) for row in data.get("roles") or [])
+            if r is not None
+        ]
+
+    # --- Admin: event log / diagnostics / permissions (raw) ----------
+
+    async def admin_list_events(
+        self,
+        *,
+        event_types: list[str] | None = None,
+        limit: int | None = None,
+        offset: int | None = None,
+    ) -> dict[str, Any]:
+        request: dict[str, Any] = {}
+        if event_types:
+            request["eventTypes"] = event_types
+        page = _page_arg(limit, offset)
+        if page is not None:
+            request["page"] = page
+        return await self.call(
+            f"{ADMIN_V1}.AdminEventLogService", "ListEvents", request
+        )
+
+    async def admin_list_event_types(self) -> list[str]:
+        data = await self.call(
+            f"{ADMIN_V1}.AdminEventLogService", "ListEventTypes"
+        )
+        return list(data.get("eventTypes") or [])
+
+    async def admin_get_event(self, event_id: str) -> dict[str, Any]:
+        return await self.call(
+            f"{ADMIN_V1}.AdminEventLogService", "GetEvent", {"eventId": event_id}
+        )
+
+    async def admin_get_system_info(self) -> dict[str, Any]:
+        return await self.call(
+            f"{ADMIN_V1}.AdminDiagnosticsService", "GetSystemInfo"
+        )
+
+    async def admin_get_role_permission_tier_matrix(self) -> dict[str, Any]:
+        return await self.call(
+            f"{ADMIN_V1}.AdminPermissionService", "GetRolePermissionTierMatrix"
+        )
+
+    async def admin_get_role_permission_matrix(self) -> dict[str, Any]:
+        return await self.call(
+            f"{ADMIN_V1}.AdminPermissionService", "GetRolePermissionMatrix"
+        )
+
+    async def admin_list_role_permission_decisions(
+        self, role_name: str
+    ) -> dict[str, Any]:
+        return await self.call(
+            f"{ADMIN_V1}.AdminPermissionService",
+            "ListRolePermissionDecisions",
+            {"roleName": role_name},
+        )
+
+    async def admin_get_user_permission_matrix(self, user_id: str) -> dict[str, Any]:
+        return await self.call(
+            f"{ADMIN_V1}.AdminPermissionService",
+            "GetUserPermissionMatrix",
+            {"userId": user_id},
+        )
+
+    async def admin_list_user_permission_decisions(
+        self, user_id: str
+    ) -> dict[str, Any]:
+        return await self.call(
+            f"{ADMIN_V1}.AdminPermissionService",
+            "ListUserPermissionDecisions",
+            {"userId": user_id},
+        )
+
+    async def admin_explain_permissions(self, request: dict[str, Any]) -> dict[str, Any]:
+        """Raw ExplainPermissions call. Request shape depends on server version."""
+        return await self.call(
+            f"{ADMIN_V1}.AdminPermissionService", "ExplainPermissions", request
+        )
+
+    async def admin_set_role_permission(
+        self, role_name: str, permission: str, *, granted: bool
+    ) -> dict[str, Any]:
+        return await self.call(
+            f"{ADMIN_V1}.AdminPermissionService",
+            "SetRolePermission",
+            {"roleName": role_name, "permission": permission, "granted": granted},
+        )
+
+    async def admin_set_user_permission(
+        self, user_id: str, permission: str, *, granted: bool
+    ) -> dict[str, Any]:
+        return await self.call(
+            f"{ADMIN_V1}.AdminPermissionService",
+            "SetUserPermission",
+            {"userId": user_id, "permission": permission, "granted": granted},
+        )

+ 218 - 0
src/chattolib/types.py

@@ -88,6 +88,19 @@ class VideoProcessingStatus(StrEnum):
     FAILED = "MESSAGE_VIDEO_PROCESSING_STATUS_FAILED"
 
 
+class AssetUploadStatus(StrEnum):
+    UNSPECIFIED = "ASSET_UPLOAD_STATUS_UNSPECIFIED"
+    OPEN = "ASSET_UPLOAD_STATUS_OPEN"
+    COMPLETED = "ASSET_UPLOAD_STATUS_COMPLETED"
+    CANCELLED = "ASSET_UPLOAD_STATUS_CANCELLED"
+
+
+class AdminRoomLayoutItemKind(StrEnum):
+    UNSPECIFIED = "ADMIN_ROOM_LAYOUT_ITEM_KIND_UNSPECIFIED"
+    ROOM = "ADMIN_ROOM_LAYOUT_ITEM_KIND_ROOM"
+    SIDEBAR_LINK = "ADMIN_ROOM_LAYOUT_ITEM_KIND_SIDEBAR_LINK"
+
+
 # --- Time helpers --------------------------------------------------------
 
 
@@ -897,3 +910,208 @@ class ActiveCall:
                 ActiveCallParticipant.parse(p) for p in data.get("participants") or []
             ],
         )
+
+
+# --- Roles ----------------------------------------------------------------
+
+
+@dataclass
+class Role:
+    name: str
+    display_name: str = ""
+    description: str = ""
+    is_system: bool = False
+    position: int = 0
+    pingable: bool = False
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> Role | None:
+        if not data:
+            return None
+        return cls(
+            name=data.get("name", ""),
+            display_name=data.get("displayName", ""),
+            description=data.get("description", ""),
+            is_system=bool(data.get("isSystem", False)),
+            position=int(data.get("position", 0)),
+            pingable=bool(data.get("pingable", False)),
+        )
+
+
+@dataclass
+class AdminRole:
+    role: Role | None
+    permissions: list[str] = field(default_factory=list)
+    permission_denials: list[str] = field(default_factory=list)
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> AdminRole | None:
+        if not data:
+            return None
+        return cls(
+            role=Role.parse(data.get("role")),
+            permissions=list(data.get("permissions") or []),
+            permission_denials=list(data.get("permissionDenials") or []),
+        )
+
+
+# --- Asset uploads --------------------------------------------------------
+
+
+@dataclass
+class AssetUpload:
+    upload_id: str
+    room_id: str
+    status: AssetUploadStatus = AssetUploadStatus.UNSPECIFIED
+    committed_offset: int = 0
+    size: int = 0
+    max_chunk_size: int = 0
+    sha256: str = ""
+    expires_at: datetime | None = None
+    asset_id: str = ""
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> AssetUpload | None:
+        if not data:
+            return None
+        return cls(
+            upload_id=data.get("uploadId", ""),
+            room_id=data.get("roomId", ""),
+            status=_parse_enum(
+                AssetUploadStatus,
+                data.get("status"),
+                AssetUploadStatus.UNSPECIFIED,
+            ),  # type: ignore[arg-type]
+            committed_offset=int(data.get("committedOffset", 0)),
+            size=int(data.get("size", 0)),
+            max_chunk_size=int(data.get("maxChunkSize", 0)),
+            sha256=data.get("sha256", ""),
+            expires_at=parse_datetime(data.get("expiresAt")),
+            asset_id=data.get("assetId", ""),
+        )
+
+
+# --- External identity ----------------------------------------------------
+
+
+@dataclass
+class ExternalIdentityProvider:
+    provider: ProviderMetadata | None
+    link_url: str = ""
+    linked: bool = False
+    linked_identity_subject_hash: str = ""
+
+    @classmethod
+    def parse(cls, data: dict[str, Any]) -> ExternalIdentityProvider:
+        provider_data = data.get("provider")
+        provider = ProviderMetadata.parse(provider_data) if provider_data else None
+        return cls(
+            provider=provider,
+            link_url=data.get("linkUrl", ""),
+            linked=bool(data.get("linked", False)),
+            linked_identity_subject_hash=data.get("linkedIdentitySubjectHash", ""),
+        )
+
+
+@dataclass
+class LinkedExternalIdentity:
+    provider_id: str
+    provider_type: str = ""
+    provider_label: str = ""
+    subject_hash: str = ""
+
+    @classmethod
+    def parse(cls, data: dict[str, Any]) -> LinkedExternalIdentity:
+        return cls(
+            provider_id=data.get("providerId", ""),
+            provider_type=data.get("providerType", ""),
+            provider_label=data.get("providerLabel", ""),
+            subject_hash=data.get("subjectHash", ""),
+        )
+
+
+# --- Admin: server / members ---------------------------------------------
+
+
+@dataclass
+class ServerConfig:
+    server_name: str = ""
+    description: str = ""
+    motd: str = ""
+    welcome_message: str = ""
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> ServerConfig:
+        data = data or {}
+        return cls(
+            server_name=data.get("serverName", ""),
+            description=data.get("description", ""),
+            motd=data.get("motd", ""),
+            welcome_message=data.get("welcomeMessage", ""),
+        )
+
+
+@dataclass
+class AdminMember:
+    user: User | None
+    roles: list[str] = field(default_factory=list)
+    created_at: datetime | None = None
+    has_verified_email: bool = False
+    verified_emails: list[str] = field(default_factory=list)
+    viewer_can_delete_account: bool = False
+    last_login_change: datetime | None = None
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> AdminMember | None:
+        if not data:
+            return None
+        return cls(
+            user=User.parse(data.get("user")),
+            roles=list(data.get("roles") or []),
+            created_at=parse_datetime(data.get("createdAt")),
+            has_verified_email=bool(data.get("hasVerifiedEmail", False)),
+            verified_emails=list(data.get("verifiedEmails") or []),
+            viewer_can_delete_account=bool(data.get("viewerCanDeleteAccount", False)),
+            last_login_change=parse_datetime(data.get("lastLoginChange")),
+        )
+
+
+# --- Admin: room layout --------------------------------------------------
+
+
+@dataclass
+class AdminRoomLayoutGroup:
+    id: str
+    name: str
+    description: str = ""
+    rooms: list[Room] = field(default_factory=list)
+    sidebar_links: list[SidebarLink] = field(default_factory=list)
+    can_create_room: bool = False
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> AdminRoomLayoutGroup | None:
+        if not data:
+            return None
+        rooms: list[Room] = []
+        links: list[SidebarLink] = []
+        for item in data.get("items") or []:
+            room = Room.parse(item.get("room"))
+            if room is not None:
+                rooms.append(room)
+            sl = item.get("sidebarLink")
+            if sl:
+                links.append(
+                    SidebarLink(
+                        id=sl.get("id", ""),
+                        label=sl.get("label", ""),
+                        url=sl.get("url", ""),
+                    )
+                )
+        return cls(
+            id=data.get("id", ""),
+            name=data.get("name", ""),
+            description=data.get("description", ""),
+            rooms=rooms,
+            sidebar_links=links,
+            can_create_room=bool(data.get("canCreateRoom", False)),
+        )

+ 206 - 0
tests/test_client.py

@@ -20,6 +20,7 @@ from chattolib.types import (
 BASE = "https://chat.chatto.run"
 CONNECT = "/api/connect"
 API_V1 = "chatto.api.v1"
+ADMIN_V1 = "chatto.admin.v1"
 DISCOVERY_V1 = "chatto.discovery.v1"
 
 
@@ -669,3 +670,208 @@ async def test_get_asset_with_thumbnail(mock_api, client):
     # ensure the request encoded thumbnail options with the wire enum
     body = route.calls.last.request.content
     assert b'"IMAGE_FIT_MODE_COVER"' in body
+
+
+# --- Roles ------------------------------------------------------------
+
+
+async def test_list_roles(mock_api, client):
+    _mount(mock_api, f"{API_V1}.RoleService", "ListRoles").mock(
+        return_value=httpx.Response(
+            200,
+            json={
+                "roles": [
+                    {
+                        "name": "everyone",
+                        "displayName": "Everyone",
+                        "isSystem": True,
+                        "position": 0,
+                    },
+                    {"name": "admin", "displayName": "Admin", "position": 10},
+                ]
+            },
+        )
+    )
+    async with client:
+        roles = await client.list_roles()
+    assert [r.name for r in roles] == ["everyone", "admin"]
+    assert roles[0].is_system is True
+
+
+# --- Asset uploads ---------------------------------------------------
+
+
+async def test_asset_upload_end_to_end(mock_api, client, tmp_path):
+    import hashlib
+
+    payload = b"hello world" * 100  # 1100 bytes; well under the mock chunk size
+    file = tmp_path / "greet.txt"
+    file.write_bytes(payload)
+    sha = hashlib.sha256(payload).hexdigest()
+
+    create = _mount(mock_api, f"{API_V1}.AssetUploadService", "CreateUpload").mock(
+        return_value=httpx.Response(
+            200,
+            json={
+                "upload": {
+                    "uploadId": "up1",
+                    "roomId": "r1",
+                    "status": "ASSET_UPLOAD_STATUS_OPEN",
+                    "committedOffset": 0,
+                    "size": len(payload),
+                    "maxChunkSize": 4096,
+                    "sha256": sha,
+                }
+            },
+        )
+    )
+    _mount(mock_api, f"{API_V1}.AssetUploadService", "UploadChunk").mock(
+        return_value=httpx.Response(
+            200,
+            json={
+                "upload": {
+                    "uploadId": "up1",
+                    "roomId": "r1",
+                    "status": "ASSET_UPLOAD_STATUS_OPEN",
+                    "committedOffset": len(payload),
+                    "size": len(payload),
+                    "maxChunkSize": 4096,
+                    "sha256": sha,
+                }
+            },
+        )
+    )
+    _mount(mock_api, f"{API_V1}.AssetUploadService", "CompleteUpload").mock(
+        return_value=httpx.Response(
+            200,
+            json={
+                "upload": {
+                    "uploadId": "up1",
+                    "roomId": "r1",
+                    "status": "ASSET_UPLOAD_STATUS_COMPLETED",
+                    "committedOffset": len(payload),
+                    "size": len(payload),
+                    "sha256": sha,
+                    "assetId": "a1",
+                },
+                "asset": {
+                    "id": "a1",
+                    "filename": "greet.txt",
+                    "contentType": "text/plain",
+                    "size": len(payload),
+                },
+            },
+        )
+    )
+    async with client:
+        asset = await client.upload_attachment(
+            "r1", file, content_type="text/plain"
+        )
+    assert asset.id == "a1"
+    assert asset.filename == "greet.txt"
+
+    # CreateUpload should have carried the correct sha and size.
+    body = create.calls.last.request.content
+    assert sha.encode() in body
+    assert str(len(payload)).encode() in body
+
+
+# --- External identities --------------------------------------------
+
+
+async def test_list_external_identities(mock_api, client):
+    _mount(mock_api, f"{API_V1}.MyAccountService", "ListExternalIdentities").mock(
+        return_value=httpx.Response(
+            200,
+            json={
+                "providers": [
+                    {
+                        "provider": {
+                            "id": "github",
+                            "type": "github",
+                            "label": "GitHub",
+                            "loginUrl": "/auth/github/login",
+                        },
+                        "linkUrl": "/auth/github/link",
+                        "linked": True,
+                        "linkedIdentitySubjectHash": "sha_xyz",
+                    }
+                ],
+                "linkedIdentities": [
+                    {
+                        "providerId": "github",
+                        "providerType": "github",
+                        "providerLabel": "GitHub",
+                        "subjectHash": "sha_xyz",
+                    }
+                ],
+            },
+        )
+    )
+    async with client:
+        providers, linked = await client.list_external_identities()
+    assert providers[0].linked is True
+    assert providers[0].provider is not None
+    assert providers[0].provider.id == "github"
+    assert linked[0].subject_hash == "sha_xyz"
+
+
+# --- Admin (structural smoke tests) ----------------------------------
+
+
+async def test_admin_list_room_groups(mock_api, client):
+    _mount(mock_api, f"{ADMIN_V1}.AdminRoomLayoutService", "ListRoomGroups").mock(
+        return_value=httpx.Response(
+            200,
+            json={
+                "groups": [
+                    {
+                        "id": "g1",
+                        "name": "General",
+                        "items": [
+                            {"room": {"id": "r1", "kind": "ROOM_KIND_CHANNEL", "name": "chat"}},
+                            {"sidebarLink": {"id": "sl1", "label": "Docs", "url": "https://x"}},
+                        ],
+                        "canCreateRoom": True,
+                    }
+                ]
+            },
+        )
+    )
+    async with client:
+        groups = await client.admin_list_room_groups()
+    assert len(groups) == 1
+    assert groups[0].name == "General"
+    assert groups[0].rooms[0].id == "r1"
+    assert groups[0].sidebar_links[0].label == "Docs"
+    assert groups[0].can_create_room is True
+
+
+async def test_admin_create_sidebar_link(mock_api, client):
+    _mount(mock_api, f"{ADMIN_V1}.AdminRoomLayoutService", "CreateSidebarLink").mock(
+        return_value=httpx.Response(
+            200,
+            json={"sidebarLink": {"id": "sl1", "label": "Docs", "url": "https://x"}},
+        )
+    )
+    async with client:
+        result = await client.admin_create_sidebar_link("g1", "Docs", "https://x")
+    assert result == {"id": "sl1", "label": "Docs", "url": "https://x"}
+
+
+async def test_admin_update_server_config(mock_api, client):
+    _mount(mock_api, f"{ADMIN_V1}.AdminServerService", "UpdateServerConfig").mock(
+        return_value=httpx.Response(
+            200,
+            json={
+                "config": {"serverName": "MyServer", "motd": "hi"},
+                "publicProfile": {"name": "MyServer", "version": "0.4.2"},
+            },
+        )
+    )
+    async with client:
+        config, profile = await client.admin_update_server_config(
+            server_name="MyServer", motd="hi"
+        )
+    assert config.server_name == "MyServer"
+    assert profile.version == "0.4.2"