瀏覽代碼

Track Chatto 0.5.0-beta.9

Profile edits consolidate onto a new public service: MyAccountService.UpdateProfile
and admin.v1.AdminUserService.UpdateUser were both removed; their single
replacement is chatto.api.v1.UserService.UpdateUserProfile (new
user_service.proto, now vendored), which takes a required user_id plus
display_name/login/bio with an inferred update_mask and returns just the
updated User. Client: new update_user_profile(user_id, ...) wrapper;
self-service update_profile(...) keeps its public shape (plus bio) but
resolves the caller's id via the viewer and delegates; admin_update_user(...)
delegates to the same privileged RPC, so its member-row second return is now
always None (re-fetch via admin_get_member). Also: User.bio documented as
Markdown; AdminUserService.AdminMember.last_login_change comment now
"start of the current username-change cooldown"; scripts/generate_pb.sh
fetches user_service.proto; AGENTS.md service table, realtime row untouched
(realtime still v4), and a b9 Gotcha entry.

Verification: ruff format+check clean (27 files); mypy zero new errors vs
baseline (same 6 pre-existing import-untyped protobuf notes); pytest
91 passed / 2 skipped; live GetServer round-trip against chat.chatto.run
returned version 0.5.0-beta.9; live bot integration tests 1 passed / 2
skipped (needs a second bot).

Bumped version 0.5.0b8 -> 0.5.0b9.
the-codemancer 1 月之前
父節點
當前提交
530cdea273

+ 5 - 3
AGENTS.md

@@ -70,7 +70,7 @@ generated protobuf bindings.
 - Enums on the wire are the **full enum value name** as a string, e.g. `"PRESENCE_STATUS_ONLINE"`. `types._parse_enum` also accepts the short tail (`"ONLINE"`) for robustness.
 - Timestamps are RFC 3339 strings (usually `Z`-suffixed). Use `types.parse_datetime` / `types.format_datetime` helpers.
 - `optional` scalars: absent from JSON when unset. A present empty string is distinct.
-- `bytes`: base64-encoded strings (used by `MyAccountService.UploadAvatar` via the `ImageUpload` message).
+- `bytes`: base64-encoded strings (used by `UserService.UploadAvatar` via the `ImageUpload` message).
 - Errors: non-2xx HTTP status with a JSON body of `{"code": "...", "message": "...", "details": [...]}`. `ChattoConnectError` surfaces `code`, `message`, `status_code`, `details`.
 
 ### Auth
@@ -91,7 +91,8 @@ to its grants.
 | Discovery | `chatto.discovery.v1.ServerDiscoveryService` | `GetServer` (public), `ListNeighbors` (public), `ListNeighborhoodServers` (public — the Neighborhood the server discovered in the background; `NeighborhoodServer { origin, profile, direct_neighbor, recommended_by_origins }` + `refreshed_at`) |
 | Server | `ServerService` | `GetMotd`, `GetRuntimeConfig` |
 | Viewer | `ViewerService` | `GetViewer` |
-| My account | `MyAccountService` | `UpdateProfile`, `UpdateSettings`, `SetPresence` (legacy live report), `GetPresencePreference`, `SetPresencePreference`, `RefreshPresence`, `SetCustomStatus`, `DeleteCustomStatus`, `ChangePassword`, `DeleteMyAccount` (avatar upload was removed in 0.5.0b4) |
+| My account | `MyAccountService` | `UpdateSettings`, `SetPresence` (legacy live report), `GetPresencePreference`, `SetPresencePreference`, `RefreshPresence`, `SetCustomStatus`, `DeleteCustomStatus`, `ChangePassword`, `DeleteMyAccount` (avatar upload was removed in 0.5.0b4; `UpdateProfile` moved to `UserService` in 0.5.0b9) |
+| User profiles | `UserService` | `UpdateUserProfile` (self or privileged — the single profile-edit RPC since 0.5.0b9; `display_name`/`login`/`bio` + inferred `update_mask`), `ListUsers`/`GetUser`/`BatchGetUsers` (directory), `UploadAvatar`/`DeleteAvatar` |
 | 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`, `RemoveUser`, `LiftSuspension`, `ListSuspensions`, `RefreshTypingIndicator`, `GetRoomEvents`, `GetRoomEventsAround`, `MarkRoomAsRead`, `ListRoomAttachments` |
@@ -106,7 +107,7 @@ to its grants.
 | 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)`, `MoveRoomGroup`, `MoveRoomToGroup`, `ReorderSidebarItemsInGroup`, `MoveSidebarItem`, `Create/Update/Delete/MoveSidebarLink(ToGroup)` |
-| Admin: users | `chatto.admin.v1.AdminUserService` | `ListMembers`, `GetMember`, `BatchGetMembers`, `AssignRole`, `RevokeRole`, `UpdateUser`, `UpdateUserPassword`, `ClearUsernameCooldown`, `DeleteUser` |
+| Admin: users | `chatto.admin.v1.AdminUserService` | `ListMembers`, `GetMember`, `BatchGetMembers`, `AssignRole`, `RevokeRole`, `UpdateUserPassword`, `ClearUsernameCooldown`, `DeleteUser` (the old `UpdateUser` admin RPC was removed in 0.5.0b9 — privileged profile edits go through `UserService.UpdateUserProfile`) |
 | 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) |
@@ -137,6 +138,7 @@ to its grants.
 - **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.
 - **0.5.0b8 added the public `ServerDiscoveryService.ListNeighborhoodServers` RPC** (no session needed; the server caches background discovery, refreshed at least hourly). `NeighborhoodServer.profile` is a `ServerPublicProfile` **without** the welcome message, and its logo/banner URLs are server-relative paths of copies on the *called* server — resolve them against that server's origin, not the neighbor's. `ListFollowedThreadsRequest.unread_only` filters to followed threads with unread replies (older servers ignore the field and return everything; the page total then counts only the filtered set).
+- **0.5.0b9 consolidated all public-profile edits into a new `chatto.api.v1.UserService.UpdateUserProfile` RPC (breaking for both edit paths).** `MyAccountService.UpdateProfile` was removed and `chatto.admin.v1.AdminUserService.UpdateUser` was removed; the new RPC takes a required `user_id` plus `display_name`/`login`/`bio` (an inferred `update_mask`) and returns just the updated `User` — no admin member row. Self-updates need no extra authority; editing another human requires `user.manage-accounts` (and, for a bot target, ownership of the bot, `user.manage-accounts`, or `bot.manage`), and a `user.manage-accounts` caller bypasses the username-change cooldown without starting one. Client: new `update_user_profile(user_id, ...)` plus a self-service `update_profile(...)` that resolves the caller's id via the viewer snapshot and delegates (same public signature as before, plus `bio`); `admin_update_user(...)` keeps its name/shape but now delegates to the same RPC and its second return value is always `None` (the removed response no longer carries an `AdminMember`) — re-fetch with `admin_get_member()` when the row is needed. `User.bio` is now documented as Markdown. The vendored `user_service.proto` also newly carries `ListUsers`/`GetUser`/`BatchGetUsers` (directory) and `UploadAvatar`/`DeleteAvatar`, for which chattolib still ships no wrappers.
 - **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

+ 1 - 34
proto/chatto/admin/v1/members.proto

@@ -2,7 +2,6 @@ syntax = "proto3";
 
 package chatto.admin.v1;
 
-import "google/protobuf/field_mask.proto";
 import "buf/validate/validate.proto";
 import "chatto/api/v1/pagination.proto";
 import "chatto/api/v1/roles.proto";
@@ -30,7 +29,7 @@ message AdminMember {
   repeated string verified_emails = 9;
   // Whether the caller may delete this account.
   bool viewer_can_delete_account = 10;
-  // Last self-service username change, when visible and known.
+  // Start of the current username-change cooldown, when visible and known.
   google.protobuf.Timestamp last_login_change = 11;
   // Public identity fields for this user.
   chatto.api.v1.User user = 12;
@@ -155,34 +154,6 @@ message RevokeRoleResponse {
   AdminMember member = 2;
 }
 
-// Request to update a user's identity as a server-admin action.
-message UpdateUserRequest {
-  // Target user ID.
-  string user_id = 1 [(buf.validate.field).string.min_len = 1];
-  // New non-empty display name. The server also rejects control and
-  // confusing invisible characters.
-  optional string display_name = 2 [(buf.validate.field).string = {min_len: 1, max_len: 32}];
-  // New login identifier, when changing it. The server accepts ASCII letters,
-  // digits, period, underscore, and hyphen, starting with a letter or digit.
-  optional string login = 3 [(buf.validate.field).string = {
-    min_len: 2
-    max_len: 32
-  }];
-  // Editable fields to apply or reset: display_name, login.
-  // Omit to infer populated fields; * selects all editable fields. An explicit
-  // empty mask is invalid. Unselected values are ignored. Selected absent values
-  // reset the field to its default, subject to field validation.
-  google.protobuf.FieldMask update_mask = 4;
-}
-
-// Result of an admin user update.
-message UpdateUserResponse {
-  // Updated user profile.
-  chatto.api.v1.User user = 1;
-  // Updated admin member row.
-  AdminMember member = 2;
-}
-
 // Request to update a user's password as a server-admin action.
 message ChangeUserPasswordRequest {
   // Target user ID.
@@ -244,10 +215,6 @@ service AdminUserService {
   // Revokes a role from a user. Requires role.assign, and non-owner callers may
   // only revoke roles whose permission decisions are within their authority.
   rpc RevokeRole(RevokeRoleRequest) returns (RevokeRoleResponse);
-  // Updates a human user's login and/or display name without applying the
-  // username-change cooldown. Requires user.manage-accounts, including when
-  // the caller targets their own account. Preserves the cooldown timestamp.
-  rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse);
   // Updates another user's password as an admin action. Requires
   // user.manage-accounts and a fresh credential for the caller; the caller
   // cannot target their own account.

+ 3 - 33
proto/chatto/api/v1/account.proto

@@ -11,34 +11,6 @@ import "chatto/api/v1/user_status.proto";
 import "chatto/api/v1/users.proto";
 import "chatto/api/v1/viewer.proto";
 
-// Request to update the authenticated user's profile. Human and bot accounts
-// use this same self-service operation. update_mask selects fields to apply
-// or reset. Fields outside the mask stay unchanged.
-message UpdateProfileRequest {
-  // New non-empty display name. The server also rejects control and
-  // confusing invisible characters.
-  optional string display_name = 1 [(buf.validate.field).string = {min_len: 1, max_len: 32}];
-  // New login identifier, when changing it. The server accepts ASCII letters,
-  // digits, period, underscore, and hyphen, starting with a letter or digit.
-  optional string login = 2 [(buf.validate.field).string = {
-    min_len: 2
-    max_len: 32
-  }];
-  // New Markdown biography, when changing it. Empty clears the bio.
-  optional string bio = 3 [(buf.validate.field).string.max_len = 1000];
-  // Editable fields to apply or reset: display_name, login, bio.
-  // Omit to infer populated fields; * selects all editable fields. An explicit
-  // empty mask is invalid. Unselected values are ignored. Selected absent values
-  // reset the field to its default, subject to field validation.
-  google.protobuf.FieldMask update_mask = 4;
-}
-
-// Result of a profile update.
-message UpdateProfileResponse {
-  // Updated user profile.
-  User user = 1;
-}
-
 // Request to update or add the authenticated user's password.
 message ChangePasswordRequest {
   // New password. The server applies the same password policy as registration
@@ -174,12 +146,10 @@ message DeleteMyAccountResponse {
   reserved "deleted";
 }
 
-// Self-service account, profile, display preference, presence,
-// custom-status, external identity, and account lifecycle commands for the
-// authenticated user.
+// Self-service account, display preference, presence, custom-status,
+// external identity, and account lifecycle commands for the authenticated
+// user. Public profile and avatar changes use UserService.
 service MyAccountService {
-  // Updates the authenticated user's login, display name, and/or bio.
-  rpc UpdateProfile(UpdateProfileRequest) returns (UpdateProfileResponse);
   // Updates or adds the authenticated user's password.
   rpc ChangePassword(ChangePasswordRequest) returns (ChangePasswordResponse);
   // Lists the authenticated user's verified email addresses and primary choice.

+ 168 - 0
proto/chatto/api/v1/user_service.proto

@@ -0,0 +1,168 @@
+syntax = "proto3";
+
+package chatto.api.v1;
+
+import "google/protobuf/field_mask.proto";
+import "buf/validate/validate.proto";
+import "chatto/api/v1/common.proto";
+import "chatto/api/v1/member_directory.proto";
+import "chatto/api/v1/pagination.proto";
+import "chatto/api/v1/users.proto";
+
+// Request for users visible to the authenticated user.
+// Results use creation time, oldest first. Users without a creation time
+// appear last, ordered by case-insensitive login.
+message ListUsersRequest {
+  reserved 2, 3;
+  reserved "limit", "offset";
+
+  // Optional case-insensitive search against login and display name.
+  string search = 1 [(buf.validate.field).string.max_len = 100];
+  // Defaults to 20 results when absent or limit is zero. Maximum: 500.
+  PageRequest page = 4;
+}
+
+// User directory page.
+message ListUsersResponse {
+  reserved 2, 3;
+  reserved "total_count", "has_more";
+
+  // Users in the requested page.
+  repeated DirectoryMember users = 1;
+  // Page metadata.
+  PageInfo page = 4;
+}
+
+// Request one visible user by stable user ID or login.
+message GetUserRequest {
+  oneof target {
+    // Target stable user ID.
+    string user_id = 1 [(buf.validate.field).string.min_len = 1];
+    // Target login identifier.
+    string login = 2 [(buf.validate.field).string.min_len = 1];
+  }
+}
+
+// User directory response.
+message GetUserResponse {
+  // Requested user.
+  DirectoryMember user = 1;
+}
+
+// Request user directory rows for a set of stable user IDs.
+message BatchGetUsersRequest {
+  // Required target user IDs. Unknown IDs are omitted from the response.
+  repeated string user_ids = 1 [(buf.validate.field).repeated = {
+    min_items: 1
+    max_items: 100
+    items: {
+      string: {min_len: 1}
+    }
+  }];
+}
+
+// Batch user directory response.
+message BatchGetUsersResponse {
+  // Found users. The server preserves first-seen request order and
+  // de-duplicates repeated IDs.
+  repeated DirectoryMember users = 1;
+}
+
+// Request to update one user's public profile. update_mask selects fields
+// to apply or reset. Fields outside the mask stay unchanged.
+message UpdateUserProfileRequest {
+  // Required target user ID. Self-updates are allowed. Updating another user
+  // requires the applicable account-management or bot-management authority.
+  string user_id = 1 [(buf.validate.field).string.min_len = 1];
+  // New non-empty display name. The server also rejects control and
+  // confusing invisible characters.
+  optional string display_name = 2 [(buf.validate.field).string = {min_len: 1, max_len: 32}];
+  // New login identifier, when changing it. The server accepts ASCII letters,
+  // digits, period, underscore, and hyphen, starting with a letter or digit.
+  optional string login = 3 [(buf.validate.field).string = {
+    min_len: 2
+    max_len: 32
+  }];
+  // New Markdown biography, when changing it. Empty clears the bio.
+  optional string bio = 4 [(buf.validate.field).string.max_len = 1000];
+  // Editable fields to apply or reset: display_name, login, bio.
+  // Omit to infer populated fields; * selects all editable fields. An explicit
+  // empty mask is invalid. Unselected values are ignored. Selected absent values
+  // reset the field to its default, subject to field validation.
+  google.protobuf.FieldMask update_mask = 5;
+}
+
+// Result of a user profile update.
+message UpdateUserProfileResponse {
+  // Updated user profile.
+  User user = 1;
+}
+
+// Request to upload and set one user's avatar.
+message UploadAvatarRequest {
+  reserved 1 to 3;
+  reserved "filename", "content_type";
+
+  // Image payload. The server validates, resizes, and stores a WebP avatar.
+  ImageUpload image = 4;
+  // Required target user ID. Self-updates are allowed. Updating another user
+  // requires the applicable account-management or bot-management authority.
+  string user_id = 5 [(buf.validate.field).string.min_len = 1];
+}
+
+// Result of uploading one user's avatar.
+message UploadAvatarResponse {
+  // Updated user profile.
+  User user = 1;
+}
+
+// Request to delete one user's avatar.
+message DeleteAvatarRequest {
+  // Required target user ID. Self-updates are allowed. Updating another user
+  // requires the applicable account-management or bot-management authority.
+  string user_id = 1 [(buf.validate.field).string.min_len = 1];
+}
+
+// Result of deleting one user's avatar.
+message DeleteAvatarResponse {
+  // Updated user profile.
+  User user = 1;
+}
+
+// Reads users and manages public user profiles and avatars.
+service UserService {
+  // Lists users visible to the authenticated user. Admin-sensitive account
+  // fields stay out of this public row shape.
+  rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
+  // Gets one visible user by user ID or login. Returns NOT_FOUND when the
+  // target is unknown.
+  rpc GetUser(GetUserRequest) returns (GetUserResponse);
+  // Gets visible user rows for multiple stable user IDs. Unknown IDs are
+  // omitted from the response.
+  rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
+  // Updates the target user's login, display name, and/or bio. Users can
+  // update themselves. Updating another human requires user.manage-accounts.
+  // A human who updates a bot needs ownership of the bot,
+  // user.manage-accounts, or bot.manage. A bot cannot target another account.
+  // A login change returns FAILED_PRECONDITION while the target's
+  // username-change cooldown is active. A successful login change starts a
+  // new cooldown. This also applies when a bot owner or bot manager changes a
+  // bot's login. A caller with user.manage-accounts bypasses the cooldown and
+  // does not start it. A case-only change does not check or start the
+  // cooldown. Unknown or deleted targets return NOT_FOUND. An invalid ID or a
+  // request without a selected field returns INVALID_ARGUMENT.
+  rpc UpdateUserProfile(UpdateUserProfileRequest) returns (UpdateUserProfileResponse);
+  // Uploads and sets an avatar for the target user. Users can update
+  // themselves. Updating another human requires user.manage-accounts.
+  // A human who updates a bot needs ownership of the bot,
+  // user.manage-accounts, or bot.manage.
+  // A bot cannot target another account. Unknown or deleted targets return
+  // NOT_FOUND. Invalid IDs or missing images return INVALID_ARGUMENT.
+  rpc UploadAvatar(UploadAvatarRequest) returns (UploadAvatarResponse);
+  // Deletes the target user's avatar. Authorization matches UploadAvatar. The
+  // call is idempotent. Unknown or deleted targets return NOT_FOUND. An
+  // invalid ID returns INVALID_ARGUMENT.
+  rpc DeleteAvatar(DeleteAvatarRequest) returns (DeleteAvatarResponse) {
+    option idempotency_level = IDEMPOTENT;
+  }
+}

+ 1 - 1
proto/chatto/api/v1/users.proto

@@ -30,7 +30,7 @@ message User {
   CustomUserStatus custom_status = 7;
   reserved 8;
   reserved "is_bot";
-  // Short self-authored biography shown on the user's profile, when set.
+  // Short Markdown biography shown on the user's profile, when set.
   optional string bio = 9;
   // IANA time zone the user chose to share on their profile (for example
   // "Europe/Berlin"). Absent means the user has not enabled time-zone

+ 1 - 1
pyproject.toml

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "chattolib"
-version = "0.5.0b8"
+version = "0.5.0b9"
 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
scripts/generate_pb.sh

@@ -96,8 +96,8 @@ for f in \
     notifications.proto pagination.proto permissions.proto presence.proto \
     push_notifications.proto reactions.proto read_state.proto \
     room_directory.proto rooms.proto room_timeline.proto roles.proto \
-    server.proto server_state.proto threads.proto users.proto \
-    user_status.proto viewer.proto voice_calls.proto
+    server.proto server_state.proto threads.proto user_service.proto \
+    users.proto user_status.proto viewer.proto voice_calls.proto
 do
     fetch "chatto/api/v1/$f"
 done

+ 0 - 24
src/chattolib/_pb/chatto/admin/v1/members_connect.py

@@ -66,18 +66,6 @@ class AdminUserServiceClient(ConnectClient):
             headers=headers,
         )
 
-    async def update_user(self, request: chatto_admin_v1_members_pb2.UpdateUserRequest, *, headers: dict[str, str] | None = None) -> chatto_admin_v1_members_pb2.UpdateUserResponse:
-        return await self.execute_unary(
-            request=request,
-            method=MethodInfo(
-                name="UpdateUser",
-                service_name="chatto.admin.v1.AdminUserService",
-                input=chatto_admin_v1_members_pb2.UpdateUserRequest,
-                output=chatto_admin_v1_members_pb2.UpdateUserResponse,
-            ),
-            headers=headers,
-        )
-
     async def change_user_password(self, request: chatto_admin_v1_members_pb2.ChangeUserPasswordRequest, *, headers: dict[str, str] | None = None) -> chatto_admin_v1_members_pb2.ChangeUserPasswordResponse:
         return await self.execute_unary(
             request=request,
@@ -176,18 +164,6 @@ class AdminUserServiceClientSync(ConnectClientSync):
             headers=headers,
         )
 
-    def update_user(self, request: chatto_admin_v1_members_pb2.UpdateUserRequest, *, headers: dict[str, str] | None = None) -> chatto_admin_v1_members_pb2.UpdateUserResponse:
-        return self.execute_unary(
-            request=request,
-            method=MethodInfo(
-                name="UpdateUser",
-                service_name="chatto.admin.v1.AdminUserService",
-                input=chatto_admin_v1_members_pb2.UpdateUserRequest,
-                output=chatto_admin_v1_members_pb2.UpdateUserResponse,
-            ),
-            headers=headers,
-        )
-
     def change_user_password(self, request: chatto_admin_v1_members_pb2.ChangeUserPasswordRequest, *, headers: dict[str, str] | None = None) -> chatto_admin_v1_members_pb2.ChangeUserPasswordResponse:
         return self.execute_unary(
             request=request,

File diff suppressed because it is too large
+ 0 - 1
src/chattolib/_pb/chatto/admin/v1/members_pb2.py


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

@@ -9,18 +9,6 @@ import chatto.api.v1.user_status_pb2 as chatto_api_v1_user_status_pb2
 from chattolib._connect import ConnectClient, ConnectClientSync, MethodInfo
 
 class MyAccountServiceClient(ConnectClient):
-    async def update_profile(self, request: chatto_api_v1_account_pb2.UpdateProfileRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_account_pb2.UpdateProfileResponse:
-        return await self.execute_unary(
-            request=request,
-            method=MethodInfo(
-                name="UpdateProfile",
-                service_name="chatto.api.v1.MyAccountService",
-                input=chatto_api_v1_account_pb2.UpdateProfileRequest,
-                output=chatto_api_v1_account_pb2.UpdateProfileResponse,
-            ),
-            headers=headers,
-        )
-
     async def change_password(self, request: chatto_api_v1_account_pb2.ChangePasswordRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_account_pb2.ChangePasswordResponse:
         return await self.execute_unary(
             request=request,
@@ -239,18 +227,6 @@ class MyAccountServiceClient(ConnectClient):
 
 class MyAccountServiceClientSync(ConnectClientSync):
 
-    def update_profile(self, request: chatto_api_v1_account_pb2.UpdateProfileRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_account_pb2.UpdateProfileResponse:
-        return self.execute_unary(
-            request=request,
-            method=MethodInfo(
-                name="UpdateProfile",
-                service_name="chatto.api.v1.MyAccountService",
-                input=chatto_api_v1_account_pb2.UpdateProfileRequest,
-                output=chatto_api_v1_account_pb2.UpdateProfileResponse,
-            ),
-            headers=headers,
-        )
-
     def change_password(self, request: chatto_api_v1_account_pb2.ChangePasswordRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_account_pb2.ChangePasswordResponse:
         return self.execute_unary(
             request=request,

File diff suppressed because it is too large
+ 0 - 0
src/chattolib/_pb/chatto/api/v1/account_pb2.py


+ 153 - 0
src/chattolib/_pb/chatto/api/v1/user_service_connect.py

@@ -0,0 +1,153 @@
+# Generated by chattolib's protoc plugin (protoc-gen-chattolib). DO NOT EDIT!
+
+# source: chatto/api/v1/user_service.proto
+
+import chatto.api.v1.user_service_pb2 as chatto_api_v1_user_service_pb2
+from chattolib._connect import ConnectClient, ConnectClientSync, MethodInfo
+
+class UserServiceClient(ConnectClient):
+    async def list_users(self, request: chatto_api_v1_user_service_pb2.ListUsersRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_user_service_pb2.ListUsersResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="ListUsers",
+                service_name="chatto.api.v1.UserService",
+                input=chatto_api_v1_user_service_pb2.ListUsersRequest,
+                output=chatto_api_v1_user_service_pb2.ListUsersResponse,
+            ),
+            headers=headers,
+        )
+
+    async def get_user(self, request: chatto_api_v1_user_service_pb2.GetUserRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_user_service_pb2.GetUserResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="GetUser",
+                service_name="chatto.api.v1.UserService",
+                input=chatto_api_v1_user_service_pb2.GetUserRequest,
+                output=chatto_api_v1_user_service_pb2.GetUserResponse,
+            ),
+            headers=headers,
+        )
+
+    async def batch_get_users(self, request: chatto_api_v1_user_service_pb2.BatchGetUsersRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_user_service_pb2.BatchGetUsersResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="BatchGetUsers",
+                service_name="chatto.api.v1.UserService",
+                input=chatto_api_v1_user_service_pb2.BatchGetUsersRequest,
+                output=chatto_api_v1_user_service_pb2.BatchGetUsersResponse,
+            ),
+            headers=headers,
+        )
+
+    async def update_user_profile(self, request: chatto_api_v1_user_service_pb2.UpdateUserProfileRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_user_service_pb2.UpdateUserProfileResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="UpdateUserProfile",
+                service_name="chatto.api.v1.UserService",
+                input=chatto_api_v1_user_service_pb2.UpdateUserProfileRequest,
+                output=chatto_api_v1_user_service_pb2.UpdateUserProfileResponse,
+            ),
+            headers=headers,
+        )
+
+    async def upload_avatar(self, request: chatto_api_v1_user_service_pb2.UploadAvatarRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_user_service_pb2.UploadAvatarResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="UploadAvatar",
+                service_name="chatto.api.v1.UserService",
+                input=chatto_api_v1_user_service_pb2.UploadAvatarRequest,
+                output=chatto_api_v1_user_service_pb2.UploadAvatarResponse,
+            ),
+            headers=headers,
+        )
+
+    async def delete_avatar(self, request: chatto_api_v1_user_service_pb2.DeleteAvatarRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_user_service_pb2.DeleteAvatarResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="DeleteAvatar",
+                service_name="chatto.api.v1.UserService",
+                input=chatto_api_v1_user_service_pb2.DeleteAvatarRequest,
+                output=chatto_api_v1_user_service_pb2.DeleteAvatarResponse,
+            ),
+            headers=headers,
+        )
+
+class UserServiceClientSync(ConnectClientSync):
+
+    def list_users(self, request: chatto_api_v1_user_service_pb2.ListUsersRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_user_service_pb2.ListUsersResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="ListUsers",
+                service_name="chatto.api.v1.UserService",
+                input=chatto_api_v1_user_service_pb2.ListUsersRequest,
+                output=chatto_api_v1_user_service_pb2.ListUsersResponse,
+            ),
+            headers=headers,
+        )
+
+    def get_user(self, request: chatto_api_v1_user_service_pb2.GetUserRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_user_service_pb2.GetUserResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="GetUser",
+                service_name="chatto.api.v1.UserService",
+                input=chatto_api_v1_user_service_pb2.GetUserRequest,
+                output=chatto_api_v1_user_service_pb2.GetUserResponse,
+            ),
+            headers=headers,
+        )
+
+    def batch_get_users(self, request: chatto_api_v1_user_service_pb2.BatchGetUsersRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_user_service_pb2.BatchGetUsersResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="BatchGetUsers",
+                service_name="chatto.api.v1.UserService",
+                input=chatto_api_v1_user_service_pb2.BatchGetUsersRequest,
+                output=chatto_api_v1_user_service_pb2.BatchGetUsersResponse,
+            ),
+            headers=headers,
+        )
+
+    def update_user_profile(self, request: chatto_api_v1_user_service_pb2.UpdateUserProfileRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_user_service_pb2.UpdateUserProfileResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="UpdateUserProfile",
+                service_name="chatto.api.v1.UserService",
+                input=chatto_api_v1_user_service_pb2.UpdateUserProfileRequest,
+                output=chatto_api_v1_user_service_pb2.UpdateUserProfileResponse,
+            ),
+            headers=headers,
+        )
+
+    def upload_avatar(self, request: chatto_api_v1_user_service_pb2.UploadAvatarRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_user_service_pb2.UploadAvatarResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="UploadAvatar",
+                service_name="chatto.api.v1.UserService",
+                input=chatto_api_v1_user_service_pb2.UploadAvatarRequest,
+                output=chatto_api_v1_user_service_pb2.UploadAvatarResponse,
+            ),
+            headers=headers,
+        )
+
+    def delete_avatar(self, request: chatto_api_v1_user_service_pb2.DeleteAvatarRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_user_service_pb2.DeleteAvatarResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="DeleteAvatar",
+                service_name="chatto.api.v1.UserService",
+                input=chatto_api_v1_user_service_pb2.DeleteAvatarRequest,
+                output=chatto_api_v1_user_service_pb2.DeleteAvatarResponse,
+            ),
+            headers=headers,
+        )

File diff suppressed because it is too large
+ 32 - 0
src/chattolib/_pb/chatto/api/v1/user_service_pb2.py


+ 3 - 0
src/chattolib/_transport.py

@@ -65,6 +65,7 @@ from chattolib._pb.chatto.api.v1.room_directory_connect import (
 from chattolib._pb.chatto.api.v1.rooms_connect import RoomServiceClient
 from chattolib._pb.chatto.api.v1.server_state_connect import ServerServiceClient
 from chattolib._pb.chatto.api.v1.threads_connect import ThreadServiceClient
+from chattolib._pb.chatto.api.v1.user_service_connect import UserServiceClient
 from chattolib._pb.chatto.api.v1.viewer_connect import ViewerServiceClient
 from chattolib._pb.chatto.api.v1.voice_calls_connect import VoiceCallServiceClient
 from chattolib._pb.chatto.discovery.v1.server_connect import (
@@ -88,6 +89,7 @@ class ServiceClients:
     rooms: RoomServiceClient
     messages: MessageServiceClient
     threads: ThreadServiceClient
+    user: UserServiceClient
     notifications: NotificationServiceClient
     notification_policy: NotificationPolicyServiceClient
     notification_prefs: NotificationPreferencesServiceClient
@@ -132,6 +134,7 @@ def build_service_clients(base_url: str) -> ServiceClients:
         rooms=make(RoomServiceClient),
         messages=make(MessageServiceClient),
         threads=make(ThreadServiceClient),
+        user=make(UserServiceClient),
         notifications=make(NotificationServiceClient),
         notification_policy=make(NotificationPolicyServiceClient),
         notification_prefs=make(NotificationPreferencesServiceClient),

+ 50 - 12
src/chattolib/client.py

@@ -62,6 +62,7 @@ from chattolib._pb.chatto.api.v1 import (
     rooms_pb2,
     server_state_pb2,
     threads_pb2,
+    user_service_pb2,
     user_status_pb2,
     viewer_pb2,
     voice_calls_pb2,
@@ -338,22 +339,55 @@ class ChattoClient:
 
     # --- MyAccount -----------------------------------------------------
 
-    async def update_profile(
+    async def update_user_profile(
         self,
+        user_id: str,
         *,
         display_name: str | None = None,
         login: str | None = None,
+        bio: str | None = None,
     ) -> User:
-        req = account_pb2.UpdateProfileRequest()
+        """Update a user's public profile via ``UserService.UpdateUserProfile``.
+
+        This is the one wire path for profile edits in 0.5.0-beta.9. Users
+        update their own account without extra authority; updating another
+        human requires ``user.manage-accounts`` (and, for a bot target,
+        ownership of the bot, ``user.manage-accounts``, or ``bot.manage``) —
+        the server enforces that. A ``login`` change is rate-limited by the
+        server's username-change cooldown. Returns the updated ``User``.
+        """
+        req = user_service_pb2.UpdateUserProfileRequest(user_id=user_id)
         if display_name is not None:
             req.display_name = display_name
         if login is not None:
             req.login = login
-        resp = await self._rpc(self._svc.account.update_profile(req, headers=self._headers()))
+        if bio is not None:
+            req.bio = bio
+        resp = await self._rpc(self._svc.user.update_user_profile(req, headers=self._headers()))
         user = User.parse(pb_to_dict(resp.user))
         assert user is not None
         return user
 
+    async def update_profile(
+        self,
+        *,
+        display_name: str | None = None,
+        login: str | None = None,
+        bio: str | None = None,
+    ) -> User:
+        """Update the authenticated user's own public profile.
+
+        The pre-0.5.0b9 ``MyAccountService.UpdateProfile`` RPC was removed;
+        profile edits now go through ``UserService.UpdateUserProfile``, which
+        needs a target ``user_id`` — so this resolves the caller's own id from
+        the viewer snapshot first and then delegates to
+        :meth:`update_user_profile`.
+        """
+        me = await self.me()
+        return await self.update_user_profile(
+            me.id, display_name=display_name, login=login, bio=bio
+        )
+
     async def update_settings(
         self,
         *,
@@ -2038,17 +2072,21 @@ class ChattoClient:
         *,
         display_name: str | None = None,
         login: str | None = None,
+        bio: str | None = None,
     ) -> tuple[User | None, AdminMember | None]:
-        req = admin_members_pb2.UpdateUserRequest(user_id=user_id)
-        if display_name is not None:
-            req.display_name = display_name
-        if login is not None:
-            req.login = login
-        resp = await self._rpc(self._svc.admin_users.update_user(req, headers=self._headers()))
-        return (
-            User.parse(pb_to_dict(resp.user)),
-            AdminMember.parse(pb_to_dict(resp.member)),
+        """Update a user's login / display name / bio as a server-admin action.
+
+        The pre-0.5.0b9 ``AdminUserService.UpdateUser`` RPC was removed; admin
+        profile edits are now just a privileged
+        :meth:`update_user_profile` (``user.manage-accounts`` bypasses the
+        username-change cooldown and does not start one), so the second
+        returned value is always ``None`` — re-fetch the member row with
+        :meth:`admin_get_member` when it is needed.
+        """
+        user = await self.update_user_profile(
+            user_id, display_name=display_name, login=login, bio=bio
         )
+        return user, None
 
     async def admin_change_user_password(self, user_id: str, password: str) -> AdminMember | None:
         resp = await self._rpc(

+ 33 - 2
tests/test_client.py

@@ -35,6 +35,7 @@ from chattolib._pb.chatto.api.v1 import (
     room_timeline_pb2,
     rooms_pb2,
     threads_pb2,
+    user_service_pb2,
     viewer_pb2,
     voice_calls_pb2,
 )
@@ -373,19 +374,49 @@ async def test_mark_room_as_read(client):
 # --- Profile / account ------------------------------------------------
 
 
+async def test_update_user_profile(client):
+    resp = user_service_pb2.UpdateUserProfileResponse()
+    resp.user.id = "u1"
+    resp.user.login = "newname"
+    resp.user.display_name = "New Name"
+    resp.user.bio = "# Hi"
+    resp.user.presence_status = "PRESENCE_STATUS_ONLINE"
+    _mock_method(client, "user", "update_user_profile", resp)
+
+    async with client:
+        user = await client.update_user_profile(
+            "u1", login="newname", display_name="New Name", bio="# Hi"
+        )
+
+    assert user.id == "u1"
+    assert user.login == "newname"
+    assert user.display_name == "New Name"
+    assert user.bio == "# Hi"
+
+
 async def test_update_profile(client):
-    resp = account_pb2.UpdateProfileResponse()
+    """Self-service update resolves the caller id via the viewer, then
+    delegates to the new UserService.UpdateUserProfile wire call."""
+    viewer = viewer_pb2.GetViewerResponse()
+    viewer.user.profile.id = "u1"
+    viewer.user.profile.login = "alice"
+    viewer.user.profile.display_name = "Alice"
+    _mock_method(client, "viewer", "get_viewer", viewer)
+
+    resp = user_service_pb2.UpdateUserProfileResponse()
     resp.user.id = "u1"
     resp.user.login = "newname"
     resp.user.display_name = "New Name"
     resp.user.presence_status = "PRESENCE_STATUS_ONLINE"
-    _mock_method(client, "account", "update_profile", resp)
+    update = _mock_method(client, "user", "update_user_profile", resp)
 
     async with client:
         user = await client.update_profile(login="newname", display_name="New Name")
 
     assert user.login == "newname"
     assert user.display_name == "New Name"
+    (req,) = update.await_args.args
+    assert req.user_id == "u1"
 
 
 async def test_set_presence(client):

Some files were not shown because too many files changed in this diff