소스 검색

Upgrade chattolib to 0.5.0a2 (Chatto v0.5.0-alpha.2)

- Regenerate protos + _pb bindings pinned to v0.5.0-alpha.2
- Notifications: replace Notification/NotificationsPage with the
  NotificationOccurrence model (NotificationSignal,
  NotificationMessageReference, NotificationRoomUnreadCount,
  NotificationPolicy, NotificationOccurrencesPage); client RPCs now
  target ListNotificationOccurrences, Get/BatchGetNotificationOccurrences,
  MarkNotificationRead, Delete/BatchDelete/DeleteAllNotificationOccurrences,
  and Get/UpdateNotificationPolicy
- Realtime: protocol v2; add RealtimeProjectionEvent/Operation,
  caught_up handling, hydrate_room, resume_cursor/retained_room_ids
- Rooms: add slow_mode_seconds + threading_mode (RoomThreadingMode),
  PinnedMessage/PinnedMessagesPage and pin/unpin RPCs
- Users: add is_bot, bio, timezone
- Link previews: add social_post (SocialPostPreview et al); Message.pinned
- Bump version to 0.5.0a2
the-codemancer 2 달 전
부모
커밋
ad52defd8b
84개의 변경된 파일과 3231개의 추가작업 그리고 1453개의 파일을 삭제
  1. 1 0
      .venv
  2. 7 7
      proto/buf/validate/validate.proto
  3. 82 0
      proto/chatto/admin/v1/diagnostics.proto
  4. 2 1
      proto/chatto/admin/v1/event_log.proto
  5. 17 7
      proto/chatto/admin/v1/members.proto
  6. 22 11
      proto/chatto/admin/v1/permissions.proto
  7. 46 7
      proto/chatto/admin/v1/room_layout.proto
  8. 15 6
      proto/chatto/api/v1/account.proto
  9. 10 4
      proto/chatto/api/v1/attachments.proto
  10. 26 0
      proto/chatto/api/v1/common.proto
  11. 67 0
      proto/chatto/api/v1/link_previews.proto
  12. 3 1
      proto/chatto/api/v1/member_directory.proto
  13. 17 2
      proto/chatto/api/v1/message_types.proto
  14. 32 13
      proto/chatto/api/v1/messages.proto
  15. 392 147
      proto/chatto/api/v1/notifications.proto
  16. 1 0
      proto/chatto/api/v1/permissions.proto
  17. 37 3
      proto/chatto/api/v1/push_notifications.proto
  18. 3 1
      proto/chatto/api/v1/reactions.proto
  19. 2 2
      proto/chatto/api/v1/read_state.proto
  20. 12 7
      proto/chatto/api/v1/room_directory.proto
  21. 26 1
      proto/chatto/api/v1/room_timeline.proto
  22. 122 16
      proto/chatto/api/v1/rooms.proto
  23. 15 0
      proto/chatto/api/v1/server.proto
  24. 26 15
      proto/chatto/api/v1/threads.proto
  25. 8 0
      proto/chatto/api/v1/users.proto
  26. 4 19
      proto/chatto/api/v1/viewer.proto
  27. 41 3
      proto/chatto/api/v1/voice_calls.proto
  28. 11 1
      proto/chatto/auth/v1/external_identity_auth.proto
  29. 4 1
      proto/chatto/discovery/v1/server.proto
  30. 337 425
      proto/chatto/realtime/v1/realtime.proto
  31. 1 1
      pyproject.toml
  32. 1 1
      scripts/generate_pb.sh
  33. 18 4
      src/chattolib/__init__.py
  34. 3 3
      src/chattolib/_pb/buf/validate/validate_pb2.py
  35. 4 3
      src/chattolib/_pb/chatto/admin/v1/diagnostics_pb2.py
  36. 3 3
      src/chattolib/_pb/chatto/admin/v1/event_log_pb2.py
  37. 3 3
      src/chattolib/_pb/chatto/admin/v1/members_pb2.py
  38. 3 3
      src/chattolib/_pb/chatto/admin/v1/permissions_pb2.py
  39. 3 3
      src/chattolib/_pb/chatto/admin/v1/roles_pb2.py
  40. 130 0
      src/chattolib/_pb/chatto/admin/v1/room_layout_connect.py
  41. 3 3
      src/chattolib/_pb/chatto/admin/v1/room_layout_pb2.py
  42. 3 3
      src/chattolib/_pb/chatto/admin/v1/server_pb2.py
  43. 8 8
      src/chattolib/_pb/chatto/api/v1/account_connect.py
  44. 4 3
      src/chattolib/_pb/chatto/api/v1/account_pb2.py
  45. 3 3
      src/chattolib/_pb/chatto/api/v1/asset_uploads_pb2.py
  46. 3 3
      src/chattolib/_pb/chatto/api/v1/attachments_pb2.py
  47. 10 8
      src/chattolib/_pb/chatto/api/v1/common_pb2.py
  48. 3 3
      src/chattolib/_pb/chatto/api/v1/external_identities_pb2.py
  49. 4 4
      src/chattolib/_pb/chatto/api/v1/link_previews_pb2.py
  50. 3 3
      src/chattolib/_pb/chatto/api/v1/member_directory_pb2.py
  51. 3 3
      src/chattolib/_pb/chatto/api/v1/message_types_pb2.py
  52. 3 3
      src/chattolib/_pb/chatto/api/v1/messages_pb2.py
  53. 3 3
      src/chattolib/_pb/chatto/api/v1/notification_preferences_pb2.py
  54. 504 202
      src/chattolib/_pb/chatto/api/v1/notifications_connect.py
  55. 6 3
      src/chattolib/_pb/chatto/api/v1/notifications_pb2.py
  56. 3 3
      src/chattolib/_pb/chatto/api/v1/pagination_pb2.py
  57. 3 3
      src/chattolib/_pb/chatto/api/v1/permissions_pb2.py
  58. 3 3
      src/chattolib/_pb/chatto/api/v1/presence_pb2.py
  59. 67 2
      src/chattolib/_pb/chatto/api/v1/push_notifications_connect.py
  60. 25 14
      src/chattolib/_pb/chatto/api/v1/push_notifications_pb2.py
  61. 3 3
      src/chattolib/_pb/chatto/api/v1/reactions_pb2.py
  62. 3 3
      src/chattolib/_pb/chatto/api/v1/read_state_pb2.py
  63. 3 3
      src/chattolib/_pb/chatto/api/v1/roles_pb2.py
  64. 4 3
      src/chattolib/_pb/chatto/api/v1/room_directory_pb2.py
  65. 4 3
      src/chattolib/_pb/chatto/api/v1/room_timeline_pb2.py
  66. 195 0
      src/chattolib/_pb/chatto/api/v1/rooms_connect.py
  67. 5 3
      src/chattolib/_pb/chatto/api/v1/rooms_pb2.py
  68. 8 6
      src/chattolib/_pb/chatto/api/v1/server_pb2.py
  69. 3 3
      src/chattolib/_pb/chatto/api/v1/server_state_pb2.py
  70. 3 3
      src/chattolib/_pb/chatto/api/v1/threads_pb2.py
  71. 3 3
      src/chattolib/_pb/chatto/api/v1/user_status_pb2.py
  72. 5 5
      src/chattolib/_pb/chatto/api/v1/users_pb2.py
  73. 3 4
      src/chattolib/_pb/chatto/api/v1/viewer_pb2.py
  74. 65 0
      src/chattolib/_pb/chatto/api/v1/voice_calls_connect.py
  75. 3 3
      src/chattolib/_pb/chatto/api/v1/voice_calls_pb2.py
  76. 3 3
      src/chattolib/_pb/chatto/auth/v1/external_identity_auth_pb2.py
  77. 8 4
      src/chattolib/_pb/chatto/discovery/v1/server_connect.py
  78. 13 10
      src/chattolib/_pb/chatto/discovery/v1/server_pb2.py
  79. 11 4
      src/chattolib/_pb/chatto/realtime/v1/realtime_pb2.py
  80. 200 302
      src/chattolib/client.py
  81. 99 6
      src/chattolib/realtime.py
  82. 299 35
      src/chattolib/types.py
  83. 42 32
      tests/test_client.py
  84. 25 8
      tests/test_realtime.py

+ 1 - 0
.venv

@@ -0,0 +1 @@
+/home/f3l1x/source/repos/chattolib/.venv

+ 7 - 7
proto/buf/validate/validate.proto

@@ -40,7 +40,7 @@ syntax = "proto2";
 // [Go](https://github.com/bufbuild/protovalidate-go),
 // [JavaScript/TypeScript](https://github.com/bufbuild/protovalidate-es),
 // [Java](https://github.com/bufbuild/protovalidate-java),
-// [Python](https://github.com/bufbuild/protovalidate-python),
+// [Python](https://github.com/bufbuild/protovalidate-py),
 // or [C++](https://github.com/bufbuild/protovalidate-cc).
 package buf.validate;
 
@@ -105,7 +105,7 @@ extend google.protobuf.FieldOptions {
 // `Rule` represents a validation rule written in the Common Expression
 // Language (CEL) syntax. Each Rule includes a unique identifier, an
 // optional error message, and the CEL expression to evaluate. For more
-// information, [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/).
+// information, [see our documentation](https://protovalidate.com/schemas/custom-rules/).
 //
 // ```proto
 // message Foo {
@@ -145,7 +145,7 @@ message MessageRules {
   // simpler syntax when defining CEL Rules where `id` and `message` derived from the `expression`. `id` will
   // be same as the `expression`.
   //
-  // For more information, [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/).
+  // For more information, [see our documentation](https://protovalidate.com/schemas/custom-rules/).
   //
   // ```proto
   // message MyMessage {
@@ -159,7 +159,7 @@ message MessageRules {
   repeated string cel_expression = 5;
   // `cel` is a repeated field of type Rule. Each Rule specifies a validation rule to be applied to this message.
   // These rules are written in Common Expression Language (CEL) syntax. For more information,
-  // [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/).
+  // [see our documentation](https://protovalidate.com/schemas/custom-rules/).
   //
   //
   // ```proto
@@ -256,7 +256,7 @@ message FieldRules {
   // simpler syntax when defining CEL Rules where `id` and `message` derived from the `expression`. `id` will
   // be same as the `expression`.
   //
-  // For more information, [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/).
+  // For more information, [see our documentation](https://protovalidate.com/schemas/custom-rules/).
   //
   // ```proto
   // message MyMessage {
@@ -267,7 +267,7 @@ message FieldRules {
   repeated string cel_expression = 29;
   // `cel` is a repeated field used to represent a textual expression
   // in the Common Expression Language (CEL) syntax. For more information,
-  // [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/).
+  // [see our documentation](https://protovalidate.com/schemas/custom-rules/).
   //
   // ```proto
   // message MyMessage {
@@ -390,7 +390,7 @@ message FieldRules {
 message PredefinedRules {
   // `cel` is a repeated field used to represent a textual expression
   // in the Common Expression Language (CEL) syntax. For more information,
-  // [see our documentation](https://buf.build/docs/protovalidate/schemas/predefined-rules/).
+  // [see our documentation](https://protovalidate.com/schemas/predefined-rules/).
   //
   // ```proto
   // message MyMessage {

+ 82 - 0
proto/chatto/admin/v1/diagnostics.proto

@@ -2,6 +2,8 @@ syntax = "proto3";
 
 package chatto.admin.v1;
 
+import "google/protobuf/timestamp.proto";
+
 // Request to read owner-only server diagnostics.
 message GetSystemInfoRequest {}
 
@@ -11,6 +13,86 @@ message GetSystemInfoResponse {
   AdminSystemInfo system_info = 1;
   // Event-sourced projection health and memory estimates.
   repeated AdminProjectionState projections = 2;
+  // Recoverable physical deletion worker health.
+  AdminAssetCleanupStatus asset_cleanup = 3;
+  // Known durable worker queues and their current broker-derived health.
+  repeated AdminDurableWorkerStatus durable_workers = 4;
+  // Whether projection diagnostics were collected successfully.
+  optional bool projections_available = 5;
+}
+
+// Broker-derived health for one known durable worker queue.
+message AdminDurableWorkerStatus {
+  // Stable machine-readable worker key.
+  string key = 1;
+  // Current availability and work state.
+  AdminDurableWorkerHealth health = 2;
+  // Deliveries not yet handed to a handler.
+  string pending_count = 3;
+  // Deliveries awaiting acknowledgement; this does not prove handler liveness.
+  string ack_pending_count = 4;
+  // Pull requests currently waiting for work.
+  string waiting_count = 5;
+  // Redelivered messages that remain unacknowledged.
+  string redelivered_count = 6;
+  // Latest EVT stream sequence delivered to this queue.
+  string last_delivered_sequence = 7;
+  // Highest contiguous EVT stream sequence acknowledged by this queue.
+  string ack_floor_sequence = 8;
+}
+
+// Operator-facing state derived from a durable consumer.
+enum AdminDurableWorkerHealth {
+  ADMIN_DURABLE_WORKER_HEALTH_UNSPECIFIED = 0;
+  // This optional worker is not required by current server configuration.
+  ADMIN_DURABLE_WORKER_HEALTH_INACTIVE = 1;
+  // A worker is waiting and all known work is acknowledged.
+  ADMIN_DURABLE_WORKER_HEALTH_HEALTHY = 2;
+  // A worker is available and unacknowledged work remains.
+  ADMIN_DURABLE_WORKER_HEALTH_WORKING = 3;
+  // An acknowledgement is pending, but broker state cannot prove whether a
+  // handler is active or the delivery is awaiting recovery after a crash.
+  ADMIN_DURABLE_WORKER_HEALTH_UNCONFIRMED = 4;
+  // The consumer exists but has neither a waiting pull nor an ack-pending delivery.
+  ADMIN_DURABLE_WORKER_HEALTH_STALLED = 5;
+  // The required consumer is unavailable.
+  ADMIN_DURABLE_WORKER_HEALTH_UNAVAILABLE = 6;
+}
+
+// Current health of recoverable message-owned asset deletion.
+message AdminAssetCleanupStatus {
+  // Operator-facing state derived from the shared durable consumer.
+  AdminAssetCleanupHealth health = 1;
+  // Deletion effects currently waiting for a successful retry.
+  int64 pending_count = 2;
+  // Deprecated. Shared durable consumers do not expose this value.
+  google.protobuf.Timestamp oldest_pending_at = 3 [deprecated = true];
+  // Deprecated. Shared durable consumers run continuously rather than in passes.
+  bool pass_in_progress = 4 [deprecated = true];
+  // Deprecated. Shared durable consumers run continuously rather than in passes.
+  google.protobuf.Timestamp last_pass_at = 5 [deprecated = true];
+  // Deprecated. Shared durable consumers run continuously rather than in passes.
+  google.protobuf.Timestamp last_successful_pass_at = 6 [deprecated = true];
+  // Deprecated. Shared durable consumers do not publish a liveness heartbeat.
+  google.protobuf.Timestamp updated_at = 7 [deprecated = true];
+  // Deprecated. Use health and pending_count instead.
+  bool last_pass_failed = 8 [deprecated = true];
+  // Global EVT sequence of the most recently delivered deletion fact.
+  string last_inspected_sequence = 9;
+  // Current latest global EVT sequence matching asset deletion facts.
+  string latest_deletion_sequence = 10;
+}
+
+// Operator-facing state for the shared durable asset cleanup queue.
+enum AdminAssetCleanupHealth {
+  ADMIN_ASSET_CLEANUP_HEALTH_UNSPECIFIED = 0;
+  ADMIN_ASSET_CLEANUP_HEALTH_INACTIVE = 1;
+  ADMIN_ASSET_CLEANUP_HEALTH_INITIALIZING = 2;
+  ADMIN_ASSET_CLEANUP_HEALTH_HEALTHY = 3;
+  ADMIN_ASSET_CLEANUP_HEALTH_RETRYING = 4;
+  ADMIN_ASSET_CLEANUP_HEALTH_STALLED = 5;
+  // Cleanup diagnostics could not be read; other system diagnostics remain valid.
+  ADMIN_ASSET_CLEANUP_HEALTH_UNAVAILABLE = 6;
 }
 
 // Broker and application diagnostics shown on the server admin system page.

+ 2 - 1
proto/chatto/admin/v1/event_log.proto

@@ -84,7 +84,8 @@ message AdminEventLogEntry {
   string actor_id = 7;
   // Event creation timestamp.
   google.protobuf.Timestamp created_at = 8;
-  // Pretty-printed JSON representation of the event payload.
+  // Pretty-printed, sanitized JSON representation of the event payload.
+  // Password verifiers are omitted.
   string payload_json = 9;
 }
 

+ 17 - 7
proto/chatto/admin/v1/members.proto

@@ -83,6 +83,15 @@ message GetMemberResponse {
   bool viewer_can_manage_roles = 5;
   // Whether the caller may edit direct user permissions.
   bool viewer_can_manage_user_permissions = 6;
+  // Roles the caller may assign without granting authority the caller does not
+  // possess. Populated when assignment limits are enforced.
+  repeated string assignable_role_names = 7;
+  // Roles the caller may revoke without removing restrictions or authority
+  // beyond the caller's own. Populated when assignment limits are enforced.
+  repeated string revocable_role_names = 8;
+  // True when assignable_role_names and revocable_role_names are authoritative.
+  // Older servers leave this false and clients may retain legacy behaviour.
+  bool role_assignment_limits_enforced = 9;
 }
 
 // Request server-admin member rows for a set of stable user IDs.
@@ -195,11 +204,11 @@ message ClearUsernameCooldownResponse {
 
 // Request to delete a user account as a server-admin action.
 message DeleteUserRequest {
+  reserved 2;
+  reserved "current_password";
+
   // Target user ID.
   string user_id = 1 [(buf.validate.field).string.min_len = 1];
-  // Current password proof for accounts with a password when the active
-  // runtime credential is no longer fresh.
-  string current_password = 2;
 }
 
 // Result of deleting a user account.
@@ -218,9 +227,11 @@ service AdminUserService {
   rpc GetMember(GetMemberRequest) returns (GetMemberResponse);
   // Gets server member rows for multiple users. Requires admin.view-users.
   rpc BatchGetMembers(BatchGetMembersRequest) returns (BatchGetMembersResponse);
-  // Assigns a role to a user. Requires role.assign.
+  // Assigns a role to a user. Requires role.assign, and non-owner callers may
+  // only assign roles whose granted authority they already possess.
   rpc AssignRole(AssignRoleRequest) returns (AssignRoleResponse);
-  // Revokes a role from a user. Requires role.assign.
+  // 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 another user's login and/or display name as an admin action.
   // Requires user.manage-accounts; the caller cannot target their own account.
@@ -233,7 +244,6 @@ service AdminUserService {
   // user.manage-accounts.
   rpc ClearUsernameCooldown(ClearUsernameCooldownRequest) returns (ClearUsernameCooldownResponse);
   // Deletes a user account as an admin action. Requires user.delete-any for
-  // other users, user.delete-self for the caller, and a fresh credential for
-  // the caller.
+  // other users or user.delete-self for the caller.
   rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
 }

+ 22 - 11
proto/chatto/admin/v1/permissions.proto

@@ -102,6 +102,9 @@ message PermissionMatrixCell {
   PermissionDecision override = 3;
   // Effective decision at this scope.
   PermissionDecision effective = 4;
+  // Whether an explicit allow may currently be stored for this target. Absent
+  // when no additional delegation ceiling applies.
+  optional bool allow_permitted = 5;
 }
 
 // Permission matrix for one role across all scopes.
@@ -209,7 +212,7 @@ enum PermissionDecisionLevel {
 message PermissionTraceEntry {
   // Level at which this decision was observed.
   PermissionDecisionLevel level = 1;
-  // Role name or policy marker that produced the decision.
+  // Role name, user ID, or policy marker that produced the decision.
   string role_name = 2;
   // Decision observed at this trace step.
   PermissionDecision decision = 3;
@@ -221,14 +224,17 @@ message PermissionTraceEntry {
 message PermissionExplanation {
   // Permission identifier.
   string permission = 1;
-  // Overall decision after applying deny-wins resolution.
+  // Overall decision after resolving named subjects and the everyone baseline.
   PermissionDecision state = 2;
   // Level of the winning decision, when state is not NONE.
   PermissionDecisionLevel decided_at = 3;
-  // Role name or policy marker that produced the winning decision.
+  // Role name, user ID, or policy marker that produced the winning decision.
   string decided_by_role = 4;
   // Ordered decision trace. The first entry is the winning decision.
   repeated PermissionTraceEntry trace = 5;
+  // Permission whose effective allow includes this permission. Empty when the
+  // permission was resolved directly.
+  string included_by_permission = 6;
 }
 
 // Request permission explanations for a user at server or room scope.
@@ -301,9 +307,9 @@ message SetUserPermissionResponse {
 
 // Provides permission matrix reads and admin permission writes.
 service AdminPermissionService {
-  // Gets the role-permission matrix for a single tier. Requires role.manage
-  // for server/group scope; room scope requires role.manage or room.manage in
-  // that room.
+  // Gets the role-permission matrix for a single tier. Server scope requires
+  // role.manage; group and room scopes require role.manage or effective
+  // room.manage at the requested resource.
   rpc GetRolePermissionTierMatrix(GetRolePermissionTierMatrixRequest) returns (GetRolePermissionTierMatrixResponse);
   // Gets one role's full permission matrix. Requires role.manage. Returns
   // NOT_FOUND when the role does not exist.
@@ -311,16 +317,21 @@ service AdminPermissionService {
   // Lists one role's permission decisions as resource-oriented rows. Requires
   // role.manage. Returns NOT_FOUND when the role does not exist.
   rpc ListRolePermissionDecisions(ListRolePermissionDecisionsRequest) returns (ListRolePermissionDecisionsResponse);
-  // Gets one user's full permission matrix. Requires user.manage-permissions.
+  // Gets one user's full permission matrix. Human targets require
+  // user.manage-permissions; bot targets require ownership or bot.manage.
   // Returns NOT_FOUND when the user does not exist.
   rpc GetUserPermissionMatrix(GetUserPermissionMatrixRequest) returns (GetUserPermissionMatrixResponse);
-  // Lists one user's permission decisions as resource-oriented rows. Requires
-  // user.manage-permissions. Returns NOT_FOUND when the user does not exist.
+  // Lists one user's permission decisions as resource-oriented rows. Human
+  // targets require user.manage-permissions; bot targets require ownership or
+  // bot.manage. Returns NOT_FOUND when the user does not exist.
   rpc ListUserPermissionDecisions(ListUserPermissionDecisionsRequest) returns (ListUserPermissionDecisionsResponse);
   // Explains permission resolution for another user. Requires role.manage.
   rpc ExplainPermissions(ExplainPermissionsRequest) returns (ExplainPermissionsResponse);
-  // Sets one role permission decision. Requires role.manage or scoped room.manage for room scope.
+  // Sets one role permission decision. Server scope requires role.manage;
+  // group and room scopes also accept effective room.manage at that resource.
   rpc SetRolePermission(SetRolePermissionRequest) returns (SetRolePermissionResponse);
-  // Sets one user permission decision. Requires user.manage-permissions.
+  // Sets one user permission decision. Human targets require
+  // user.manage-permissions; bot targets require ownership or bot.manage and
+  // accept only allow or clear within the bot owner's current authority.
   rpc SetUserPermission(SetUserPermissionRequest) returns (SetUserPermissionResponse);
 }

+ 46 - 7
proto/chatto/admin/v1/room_layout.proto

@@ -33,6 +33,40 @@ message AdminRoomLayoutGroup {
   bool can_create_room = 6;
 }
 
+// Request for management-authorized channel room metadata.
+message GetRoomRequest {
+  // Required channel room ID.
+  string room_id = 1 [(buf.validate.field).string.min_len = 1];
+}
+
+// Channel room metadata and the caller's management capabilities.
+message GetRoomResponse {
+  // Requested channel room. This read does not require ordinary directory
+  // visibility when the caller can configure the room's role permissions.
+  chatto.api.v1.Room room = 1;
+  // Whether the caller can update the room's general settings.
+  bool viewer_can_manage_room = 2;
+  // Whether the caller can configure role permissions for the room.
+  bool viewer_can_manage_permissions = 3;
+}
+
+// Request for management-authorized room group metadata.
+message GetRoomGroupRequest {
+  // Required room group ID.
+  string group_id = 1 [(buf.validate.field).string.min_len = 1];
+}
+
+// Room group metadata and the caller's management capabilities.
+message GetRoomGroupResponse {
+  // Requested room group metadata. Ordered room and link entries are omitted so
+  // role permission managers do not gain visibility into private room names.
+  AdminRoomLayoutGroup group = 1;
+  // Whether the caller can update the room group's general settings.
+  bool viewer_can_manage_group = 2;
+  // Whether the caller can configure role permissions for the room group.
+  bool viewer_can_manage_permissions = 3;
+}
+
 // Request for the server-admin room group layout.
 message ListRoomGroupsRequest {}
 
@@ -231,17 +265,22 @@ message MoveSidebarLinkToGroupResponse {
 
 // Reads and mutates the server-admin room layout.
 service AdminRoomLayoutService {
-  // Lists the editable room group layout. Requires role.manage and returns
-  // visible room entries, including archived rooms for administrative actions
-  // such as unarchiving.
+  // Gets management-authorized channel room metadata. Requires role.manage or
+  // effective room.manage for the room.
+  rpc GetRoom(GetRoomRequest) returns (GetRoomResponse);
+  // Gets management-authorized room group metadata. Requires role.manage or
+  // effective room.manage for the group.
+  rpc GetRoomGroup(GetRoomGroupRequest) returns (GetRoomGroupResponse);
+  // Lists the editable room group layout. Requires server-scope room.manage
+  // and includes archived rooms for administrative actions such as unarchiving.
   rpc ListRoomGroups(ListRoomGroupsRequest) returns (ListRoomGroupsResponse);
-  // Creates a room group. Requires role.manage.
+  // Creates a room group. Requires server-scope room.manage.
   rpc CreateRoomGroup(CreateRoomGroupRequest) returns (CreateRoomGroupResponse);
-  // Updates a room group's metadata. Requires role.manage.
+  // Updates a room group's metadata. Requires effective room.manage for the group.
   rpc UpdateRoomGroup(UpdateRoomGroupRequest) returns (UpdateRoomGroupResponse);
-  // Deletes an empty room group. Requires role.manage.
+  // Deletes an empty room group. Requires effective room.manage for the group.
   rpc DeleteRoomGroup(DeleteRoomGroupRequest) returns (DeleteRoomGroupResponse);
-  // Replaces the global room group order. Requires role.manage.
+  // Replaces the global room group order. Requires server-scope room.manage.
   rpc ReorderRoomGroups(ReorderRoomGroupsRequest) returns (ReorderRoomGroupsResponse);
   // Moves a channel room to another room group. Requires room.manage in both
   // source and destination groups.

+ 15 - 6
proto/chatto/api/v1/account.proto

@@ -9,9 +9,10 @@ import "chatto/api/v1/presence.proto";
 import "chatto/api/v1/user_status.proto";
 import "chatto/api/v1/users.proto";
 import "chatto/api/v1/viewer.proto";
+import "google/protobuf/descriptor.proto";
 
-// Request to update the authenticated user's profile. At least one field must
-// be present.
+// Request to update the authenticated user's profile. Human and bot accounts
+// use this same self-service operation. At least one field must be present.
 message UpdateProfileRequest {
   // New display name, when changing it. Empty clears the explicit display
   // name. The server also rejects control and confusing invisible characters.
@@ -22,6 +23,8 @@ message UpdateProfileRequest {
     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];
 }
 
 // Result of a profile update.
@@ -114,12 +117,14 @@ message DeleteMyAccountResponse {
 // custom-status, external identity, and account lifecycle commands for the
 // authenticated user.
 service MyAccountService {
-  // Updates the authenticated user's login and/or display name.
+  // Updates the authenticated user's login, display name, and/or bio.
   rpc UpdateProfile(UpdateProfileRequest) returns (UpdateProfileResponse);
   // Uploads and sets the authenticated user's avatar.
   rpc UploadAvatar(UploadAvatarRequest) returns (UploadAvatarResponse);
   // Deletes the authenticated user's avatar. The call is idempotent.
-  rpc DeleteAvatar(DeleteAvatarRequest) returns (DeleteAvatarResponse);
+  rpc DeleteAvatar(DeleteAvatarRequest) returns (DeleteAvatarResponse) {
+    option idempotency_level = IDEMPOTENT;
+  }
   // Updates or adds the authenticated user's password.
   rpc UpdatePassword(UpdatePasswordRequest) returns (UpdatePasswordResponse);
   // Updates the authenticated user's display preferences.
@@ -142,9 +147,13 @@ service MyAccountService {
   rpc UpdateCustomStatus(UpdateCustomStatusRequest) returns (UpdateCustomStatusResponse);
   // Deletes the current user's custom status. The call is idempotent and returns
   // the resulting empty status state.
-  rpc DeleteCustomStatus(DeleteCustomStatusRequest) returns (DeleteCustomStatusResponse);
-  // Issues a short-lived token used to confirm self-deletion.
+  rpc DeleteCustomStatus(DeleteCustomStatusRequest) returns (DeleteCustomStatusResponse) {
+    option idempotency_level = IDEMPOTENT;
+  }
+  // Issues a short-lived token used to confirm self-deletion. Requires
+  // user.delete-self.
   rpc RequestAccountDeletion(RequestAccountDeletionRequest) returns (RequestAccountDeletionResponse);
   // Permanently deletes the authenticated account after token validation.
+  // Requires user.delete-self.
   rpc DeleteMyAccount(DeleteMyAccountRequest) returns (DeleteMyAccountResponse);
 }

+ 10 - 4
proto/chatto/api/v1/attachments.proto

@@ -81,11 +81,17 @@ message BatchGetAssetsResponse {
 
 // Reads room-scoped asset metadata and freshly signed asset URLs.
 service AssetService {
-  // Reads one asset. Authentication and room membership are required. Returns
-  // NOT_FOUND when the asset is missing, deleted, or owned by a different room.
+  // Reads one asset. Authentication and room membership are required.
+  // Channel-room assets also require message.read or a matching thread
+  // relationship with message.read-interactions. DM membership authorizes DM
+  // assets. Returns NOT_FOUND when the asset is missing, deleted, or owned by a
+  // different room.
   rpc GetAsset(GetAssetRequest) returns (GetAssetResponse);
   // Reads many assets in one room. Authentication and room membership are
-  // required. Missing, deleted, and wrong-room asset IDs are omitted. Results
-  // preserve first-seen request order and repeated asset IDs are de-duplicated.
+  // required. Channel-room assets also require message.read or a matching
+  // thread relationship with message.read-interactions. DM membership
+  // authorizes DM assets. Missing, deleted, wrong-room, and inaccessible asset
+  // IDs are omitted. Results preserve first-seen request order, and repeated
+  // asset IDs are de-duplicated.
   rpc BatchGetAssets(BatchGetAssetsRequest) returns (BatchGetAssetsResponse);
 }

+ 26 - 0
proto/chatto/api/v1/common.proto

@@ -14,6 +14,26 @@ enum ImageFitMode {
   IMAGE_FIT_MODE_COVER = 2;
 }
 
+// Policy governing thread creation and reply placement in a channel room.
+// Direct-message rooms do not support threads and use UNSPECIFIED.
+enum RoomThreadingMode {
+  // No mode was supplied. CreateRoom treats this as ENABLED so older stored
+  // rooms and omitted request fields preserve Chatto's default behavior.
+  ROOM_THREADING_MODE_UNSPECIFIED = 0;
+  // Every new root message establishes a thread, and replies to root messages
+  // must be posted in that root's thread.
+  ROOM_THREADING_MODE_REQUIRED = 1;
+  // Clients should steer replies into threads, but the server accepts both
+  // threaded and ordinary in-room replies.
+  ROOM_THREADING_MODE_ENCOURAGED = 2;
+  // Threads are available and clients may freely choose whether to use them.
+  ROOM_THREADING_MODE_ENABLED = 3;
+  // New threads, thread replies, and channel echoes are rejected. Existing
+  // threads and echoes remain readable and ordinary in-room reply attribution
+  // remains available.
+  ROOM_THREADING_MODE_DISABLED = 4;
+}
+
 // Image transform parameters for generated image URLs.
 message ImageTransformOptions {
   // Target image width in pixels.
@@ -50,4 +70,10 @@ message ProviderMetadata {
   string label = 3;
   // URL that starts login for this provider.
   string login_url = 4;
+  // OIDC issuer URL. Absent for providers that do not use OpenID Connect.
+  // Clients can compare this value with their own trusted issuer selection;
+  // the server does not select the client's identity provider.
+  optional string issuer_url = 5;
+  // Whether an unlinked identity may create a new account through this provider.
+  optional bool auto_provision = 6;
 }

+ 67 - 0
proto/chatto/api/v1/link_previews.proto

@@ -3,6 +3,7 @@ syntax = "proto3";
 package chatto.api.v1;
 
 import "buf/validate/validate.proto";
+import "google/protobuf/timestamp.proto";
 
 // Request to fetch server-side link preview metadata for a URL.
 message FetchLinkPreviewRequest {
@@ -36,6 +37,72 @@ message LinkPreview {
   optional string embed_type = 7;
   // Provider-specific embed ID, when recognized.
   optional string embed_id = 8;
+  // Structured, provider-neutral social-post data for native rendering.
+  SocialPostPreview social_post = 9;
+}
+
+// Bounded, provider-neutral social-post data used for native preview cards.
+message SocialPostPreview {
+  // Stable lowercase provider identifier, such as "bluesky".
+  string provider = 1;
+  // Author shown on the post.
+  SocialPostAuthor author = 2;
+  // Plain-text post body.
+  string text = 3;
+  // Post publication time.
+  google.protobuf.Timestamp published_at = 4;
+  // Images attached directly to the post, in display order.
+  repeated SocialPostImage images = 5;
+  // Website card embedded in the post, when present.
+  SocialPostExternalLink external_link = 6;
+  // Provider-supplied content warning, when present.
+  optional string content_warning = 7;
+  // Canonical URL for this post. Always set for quoted posts; the outer post
+  // may also use LinkPreview.url.
+  string url = 8;
+  // Post quoted by this post, when present. Quotes inside this snapshot are
+  // omitted so preview size and rendering depth remain bounded.
+  SocialPostPreview quoted_post = 9;
+}
+
+// Author shown on a social-post preview.
+message SocialPostAuthor {
+  // Author display name.
+  string display_name = 1;
+  // Provider-native handle without a leading at-sign when applicable.
+  string handle = 2;
+  // Locally served author avatar URL, when available.
+  optional string avatar_url = 3;
+  // Locally stored author avatar asset ID, when available.
+  optional string avatar_asset_id = 4;
+}
+
+// One image attached directly to a social post.
+message SocialPostImage {
+  // Locally served image URL.
+  string url = 1;
+  // Locally stored image asset ID.
+  string asset_id = 2;
+  // Author-provided alternative text.
+  optional string alt = 3;
+  // Source aspect-ratio width, when known.
+  optional uint32 width = 4;
+  // Source aspect-ratio height, when known.
+  optional uint32 height = 5;
+}
+
+// Website card embedded in a social post.
+message SocialPostExternalLink {
+  // Destination URL.
+  string url = 1;
+  // Card title, when available.
+  optional string title = 2;
+  // Card description, when available.
+  optional string description = 3;
+  // Locally served card image URL, when available.
+  optional string image_url = 4;
+  // Locally stored card image asset ID, when available.
+  optional string image_asset_id = 5;
 }
 
 // Result of fetching link preview metadata.

+ 3 - 1
proto/chatto/api/v1/member_directory.proto

@@ -81,7 +81,9 @@ message ListRoomMembersRequest {
   reserved 3, 4;
   reserved "limit", "offset";
 
-  // Required. Room whose explicit members should be listed.
+  // Required. Room whose effective members should be listed. Existing members
+  // and room.manage holders may list a channel room; other nonmembers need both
+  // room.list and room.join.
   string room_id = 1 [(buf.validate.field).string.min_len = 1];
   // Optional case-insensitive search against login and display name.
   string search = 2 [(buf.validate.field).string.max_len = 100];

+ 17 - 2
proto/chatto/api/v1/message_types.proto

@@ -42,6 +42,13 @@ message MessageVideoVariant {
   MessageAssetUrl asset_url = 5;
 }
 
+// HLS adaptive-streaming metadata for a processed video.
+message MessageVideoHLS {
+  // Signed URL for the HLS master playlist. The URL authorises all playlists
+  // and segments in this video generation and expires with the attachment URL.
+  MessageAssetUrl master_playlist_url = 1;
+}
+
 // Processing metadata for a video attachment.
 //
 // Clients can use this object to show upload/transcoding progress and decide
@@ -64,6 +71,9 @@ message MessageVideoProcessing {
   MessageAssetUrl thumbnail_asset_url = 7;
   // Available transcoded renditions.
   repeated MessageVideoVariant variants = 8;
+  // Adaptive-streaming metadata. Absent on MP4-only historical results and
+  // servers that do not support HLS processing.
+  MessageVideoHLS hls = 9;
 }
 
 // Attachment metadata included with a message.
@@ -108,7 +118,8 @@ message MessageReaction {
 message ThreadViewerState {
   // Whether the current user follows this message's thread, when known.
   optional bool is_following = 1;
-  // True when the thread has unread replies for the current user, when known.
+  // True when the thread has unread replies or neutral Badge attention for the
+  // current user, when known.
   optional bool has_unread = 2;
 }
 
@@ -170,9 +181,13 @@ message Message {
   string channel_echo_event_id = 13;
   // Reaction summaries for this message.
   repeated MessageReaction reactions = 19;
-  // Aggregated thread state, when known for a thread root message.
+  // Aggregated thread state. Present only when a durable thread has been
+  // established for this root message, including an empty thread.
   ThreadSummary thread = 20;
   // Time when the message content was deleted through retraction or account
   // crypto-shredding. Absent when unavailable body content is not a deletion.
   google.protobuf.Timestamp deleted_at = 21;
+  // True when this message's canonical message is currently pinned in its
+  // channel room. Always false for direct-message rooms.
+  bool pinned = 22;
 }

+ 32 - 13
proto/chatto/api/v1/messages.proto

@@ -27,8 +27,9 @@ message CreateMessageRequest {
   string room_id = 1 [(buf.validate.field).string.min_len = 1];
   // Message body text. Required unless attachment_asset_ids is non-empty.
   string body = 2;
-  // Existing room-scoped attachment asset IDs to include with the message.
-  // At most 10 IDs may be supplied.
+  // Completed room-scoped asset IDs uploaded by the caller and not yet attached
+  // to a message. The server attaches them exclusively and atomically to this
+  // message. At most 10 IDs may be supplied.
   repeated string attachment_asset_ids = 3 [(buf.validate.field).repeated = {
     max_items: 10
     items: {
@@ -47,6 +48,10 @@ message CreateMessageRequest {
   // Short-lived token returned by FetchLinkPreview for the selected URL. The
   // server resolves the token to cached, server-fetched metadata during post.
   string link_preview_token = 10;
+  // True to establish the new root message as a thread immediately. Requires
+  // both message.post and message.post-in-thread. Channel rooms only; cannot
+  // be combined with thread_root_event_id.
+  bool create_thread = 11;
 }
 
 // Result of creating a message.
@@ -202,11 +207,15 @@ service MessageService {
   // Creates a message for the current user. The user must be a room member and
   // must have message.post for room messages or message.post-in-thread for
   // thread replies. Echoing a thread reply also requires message.echo and
-  // message.post.
+  // message.post. The room's Threading Mode may automatically establish a root
+  // thread or reject a thread placement that the mode does not allow.
   rpc CreateMessage(CreateMessageRequest) returns (CreateMessageResponse);
   // Edits a message body. Authors can edit their own messages within the edit
   // window. Non-authors need message.manage and cannot change channel echo
-  // state.
+  // state. Disabled rooms reject creation of a new channel echo while allowing
+  // an existing echo to be removed. Room membership is also required.
+  // Channel-room edits require message.read or a matching thread relationship
+  // with message.read-interactions. DM membership authorizes the DM read.
   rpc UpdateMessage(UpdateMessageRequest) returns (UpdateMessageResponse);
   // Retracts a message. Authors can delete their own messages. Non-authors need
   // message.manage.
@@ -216,19 +225,29 @@ service MessageService {
   // Removes the accepted link preview from the author's own message.
   rpc DeleteLinkPreview(DeleteLinkPreviewRequest) returns (DeleteLinkPreviewResponse);
   // Reads one renderable message, including current body, attachment metadata,
-  // link preview, reactions, and thread metadata. Authentication and room
-  // membership are required. Returns NOT_FOUND when the event does not exist,
-  // is not a message, has been retracted, or belongs to a different room.
+  // link preview, reactions, thread metadata, and pin state. Authentication
+  // and room membership are required. Channel-room reads also require
+  // message.read or a matching thread relationship with
+  // message.read-interactions. DM membership authorizes DM reads. Returns
+  // NOT_FOUND when the event does not exist, is not a message, has been
+  // retracted, or belongs to a different room.
   rpc GetMessage(GetMessageRequest) returns (GetMessageResponse);
-  // Reads many renderable messages in one room. Authentication and room
-  // membership are required. Missing, retracted, non-message, and wrong-room
-  // event IDs are omitted. Results preserve first-seen request order and
-  // repeated event IDs are de-duplicated.
+  // Reads many renderable messages and their current pin state in one room.
+  // Authentication and room membership are required. Channel-room reads also
+  // require message.read or a matching thread relationship with
+  // message.read-interactions. DM membership authorizes DM reads. Missing,
+  // retracted, non-message, wrong-room, and inaccessible event IDs are omitted.
+  // Results preserve first-seen request order, and repeated event IDs are
+  // de-duplicated.
   rpc BatchGetMessages(BatchGetMessagesRequest) returns (BatchGetMessagesResponse);
   // Adds a reaction to a message. The user must be a room member and have
-  // message.react in the target room.
+  // message.react. Channel-room reactions also require message.read or a
+  // matching thread relationship with message.read-interactions. DM membership
+  // authorizes the DM read.
   rpc AddReaction(AddReactionRequest) returns (AddReactionResponse);
   // Removes a reaction from a message. The user must be a room member and have
-  // message.react in the target room.
+  // message.react. Channel-room reactions also require message.read or a
+  // matching thread relationship with message.read-interactions. DM membership
+  // authorizes the DM read.
   rpc RemoveReaction(RemoveReactionRequest) returns (RemoveReactionResponse);
 }

+ 392 - 147
proto/chatto/api/v1/notifications.proto

@@ -6,123 +6,258 @@ import "buf/validate/validate.proto";
 import "chatto/api/v1/pagination.proto";
 import "chatto/api/v1/rooms.proto";
 import "chatto/api/v1/users.proto";
+import "google/protobuf/descriptor.proto";
+import "google/protobuf/empty.proto";
+import "google/protobuf/field_mask.proto";
 import "google/protobuf/timestamp.proto";
 
-// Direct-message notification payload.
-message DirectMessageNotification {
-  reserved 1;
-  reserved "room_id";
+// Delivery behavior for one notification signal class.
+enum NotificationDeliveryMode {
+  option allow_alias = true;
+
+  // No delivery mode was specified. This value is not a valid override.
+  NOTIFICATION_DELIVERY_MODE_UNSPECIFIED = 0;
+  // Matching activity does not create user attention.
+  NOTIFICATION_DELIVERY_MODE_OFF = 1;
+  // Deprecated alias for NOTIFICATION_DELIVERY_MODE_IN_APP_NOTIFICATION.
+  NOTIFICATION_DELIVERY_MODE_SILENT = 2 [deprecated = true];
+  // Matching activity creates an in-app notification but does not send push.
+  NOTIFICATION_DELIVERY_MODE_IN_APP_NOTIFICATION = 2;
+  // Deprecated alias for NOTIFICATION_DELIVERY_MODE_PUSH_NOTIFICATION.
+  NOTIFICATION_DELIVERY_MODE_ALERT = 3 [deprecated = true];
+  // Matching activity creates an in-app notification and may send push.
+  NOTIFICATION_DELIVERY_MODE_PUSH_NOTIFICATION = 3;
+  // Matching activity adds a neutral unread indicator without creating a
+  // notification occurrence, playing a sound, or sending push.
+  NOTIFICATION_DELIVERY_MODE_UNREAD_BADGE = 4;
+}
 
-  // Message event ID.
-  string event_id = 2;
-  // DM room where the message was posted.
-  RoomSummary room = 3;
+// Delivery modes for Chatto's finite built-in notification signal classes.
+// Presence is significant: absent fields inherit in a stored policy.
+message NotificationDeliveryModes {
+  optional NotificationDeliveryMode direct_messages = 1;
+  optional NotificationDeliveryMode direct_mentions = 2;
+  optional NotificationDeliveryMode replies = 3;
+  optional NotificationDeliveryMode role_mentions = 4;
+  optional NotificationDeliveryMode here_mentions = 5;
+  optional NotificationDeliveryMode all_mentions = 6;
+  optional NotificationDeliveryMode followed_threads = 7;
+  // Deprecated compatibility slot. Use room_messages at room scope.
+  optional NotificationDeliveryMode followed_rooms = 8 [deprecated = true];
+  optional NotificationDeliveryMode reactions = 9;
+  // Delivery mode for root messages posted directly to joined channel rooms.
+  // Thread messages and direct messages use their own signal classes.
+  optional NotificationDeliveryMode room_messages = 10;
 }
 
-// Mention notification payload.
-message MentionNotification {
-  // Room where the mention occurred.
+// Visual importance of an unread notification. This is independent from
+// whether the notification is eligible for sound or push delivery.
+enum NotificationAttentionLevel {
+  // No attention level was provided.
+  NOTIFICATION_ATTENTION_LEVEL_UNSPECIFIED = 0;
+  // Ambient activity appears with a neutral unread indicator.
+  NOTIFICATION_ATTENTION_LEVEL_AMBIENT = 1;
+  // Important activity appears with Chatto's notification orange.
+  NOTIFICATION_ATTENTION_LEVEL_IMPORTANT = 2;
+}
+
+// Exact visible message destination carried by a notification signal.
+message NotificationMessageReference {
+  // Room containing the source activity.
   RoomSummary room = 1;
-  // Message event ID.
+  // Exact source or reacted-to message event to reveal.
   string event_id = 2;
-  // Thread root event ID when the mention happened inside a thread.
+  // Thread root when the target is inside a thread.
   optional string thread_root_event_id = 3;
 }
 
-// Reply notification payload.
-message ReplyNotification {
-  // Room where the reply occurred.
-  RoomSummary room = 1;
-  // Reply event ID.
-  string event_id = 2;
-  // Event ID of the message being replied to.
-  string in_reply_to_id = 3;
-  // Thread root event ID when the reply happened inside a thread.
-  optional string thread_root_event_id = 4;
+// A direct message sent to the viewer.
+message DirectMessageReceived {
+  NotificationMessageReference message = 1;
 }
 
-// All-messages room notification payload.
-message RoomMessageNotification {
-  // Room where the message was posted.
-  RoomSummary room = 1;
-  // Message event ID.
-  string event_id = 2;
+// A message that mentions the viewer directly.
+message DirectMentionReceived {
+  NotificationMessageReference message = 1;
 }
 
-// One pending notification for the authenticated viewer.
-message NotificationItem {
-  reserved 4;
-  reserved "summary";
+// A reply to one of the viewer's messages.
+message ReplyReceived {
+  NotificationMessageReference message = 1;
+}
 
-  // Stable notification ID.
-  string id = 1;
-  // Creation time.
-  google.protobuf.Timestamp created_at = 2;
-  // User who triggered the notification, when still resolvable.
-  User actor = 3;
+// A message mentioning a role held by the viewer.
+message RoleMentionReceived {
+  NotificationMessageReference message = 1;
+  // Source-time role handles that selected the viewer. Multiple matching role
+  // mentions are consolidated into one occurrence for the source message.
+  repeated string role_names = 2;
+}
+
+// An `@here` message that included the viewer.
+message HereMentionReceived {
+  NotificationMessageReference message = 1;
+}
+
+// An `@all` message that included the viewer.
+message AllMentionReceived {
+  NotificationMessageReference message = 1;
+}
+
+// Activity in a thread followed by the viewer.
+message FollowedThreadActivity {
+  NotificationMessageReference message = 1;
+}
+
+// Deprecated compatibility payload. Chatto does not derive this signal.
+message FollowedRoomActivity {
+  option deprecated = true;
 
+  NotificationMessageReference message = 1;
+}
+
+// A root message posted directly to a channel room that the viewer belongs to.
+message RoomMessageReceived {
+  NotificationMessageReference message = 1;
+}
+
+// A reaction added to one of the viewer's messages.
+message ReactionReceived {
+  NotificationMessageReference message = 1;
+  // Exact reaction supplied by the actor.
+  string emoji = 2;
+}
+
+// Immutable, typed cause and destination of one notification. Clients must
+// preserve unknown variants as generic dismissible rows and must not infer
+// navigation semantics for them.
+message NotificationSignal {
   oneof kind {
-    // Direct-message notification.
-    DirectMessageNotification direct_message = 10;
-    // Mention notification.
-    MentionNotification mention = 11;
-    // Reply notification.
-    ReplyNotification reply = 12;
-    // All-messages room notification.
-    RoomMessageNotification room_message = 13;
+    DirectMessageReceived direct_message_received = 1;
+    DirectMentionReceived direct_mention_received = 2;
+    ReplyReceived reply_received = 3;
+    RoleMentionReceived role_mention_received = 4;
+    HereMentionReceived here_mention_received = 5;
+    AllMentionReceived all_mention_received = 6;
+    FollowedThreadActivity followed_thread_activity = 7;
+    FollowedRoomActivity followed_room_activity = 8 [deprecated = true];
+    ReactionReceived reaction_received = 9;
+    RoomMessageReceived room_message_received = 10;
   }
 }
 
-// Request for the authenticated viewer's pending notifications.
-message ListNotificationsRequest {
-  reserved 1, 2;
-  reserved "limit", "offset";
+// One exact Notifications 2.0 source occurrence.
+message NotificationOccurrence {
+  // Stable occurrence ID.
+  string id = 1;
+  // Time of the source activity.
+  google.protobuf.Timestamp created_at = 2;
+  // User who caused the source activity, when still visible.
+  User actor = 3;
+  // Immutable cause and destination represented by this occurrence.
+  NotificationSignal signal = 4;
+  // Whether this occurrence still contributes unread attention.
+  bool unread = 5;
+  // Absolute expiry, 90 days after the source activity.
+  google.protobuf.Timestamp expires_at = 6;
+  // Source-time visual importance of this occurrence.
+  NotificationAttentionLevel attention_level = 7;
+}
 
+// Request for one page of notification occurrences.
+message ListNotificationOccurrencesRequest {
   // Page request. Defaults to 50 results when absent or limit is zero.
-  PageRequest page = 3;
+  PageRequest page = 1;
 }
 
-// Request for pending notifications scoped to one room.
-message ListRoomNotificationsRequest {
-  reserved 2, 3;
-  reserved "limit", "offset";
+// One page of exact notification occurrences.
+message ListNotificationOccurrencesResponse {
+  // Occurrences newest activity first.
+  repeated NotificationOccurrence occurrences = 1;
+  // Page metadata.
+  PageInfo page = 2;
+  // Total unread occurrence count, independent of presentation grouping.
+  int32 unread_count = 3;
+  // Earliest expiry in the complete list, including occurrences outside this page.
+  // Clients refresh authoritative notification state at this boundary.
+  google.protobuf.Timestamp next_expiry_at = 4;
+  // Complete unread-occurrence counts by target room. This is independent of
+  // the bounded page so room indicators remain authoritative.
+  repeated NotificationRoomUnreadCount room_unread_counts = 5;
+  // Exact unread occurrences with Important attention.
+  int32 important_unread_count = 6;
+}
 
-  // Required. Room whose notifications should be listed.
-  string room_id = 1 [(buf.validate.field).string.min_len = 1];
-  // Page request. Defaults to 50 results when absent or limit is zero.
-  PageRequest page = 4;
+// Request one exact notification occurrence by stable ID.
+message GetNotificationOccurrenceRequest {
+  // Required stable occurrence ID.
+  string notification_id = 1 [(buf.validate.field).string.min_len = 1];
 }
 
-// Pending notification page.
-message ListNotificationsResponse {
-  reserved 2, 3, 4;
-  reserved "total_count", "has_more", "server_name";
+// One exact notification occurrence.
+message GetNotificationOccurrenceResponse {
+  // Visible occurrence. Absence is reported as NOT_FOUND.
+  NotificationOccurrence occurrence = 1;
+}
 
-  // Page notifications, newest first.
-  repeated NotificationItem notifications = 1;
-  // Page metadata.
-  PageInfo page = 5;
+// Request an explicit bounded set of notification occurrences.
+message BatchGetNotificationOccurrencesRequest {
+  // Stable occurrence IDs. Unknown, deleted, and currently invisible IDs are
+  // omitted. Repeated IDs are de-duplicated in first-seen request order.
+  repeated string notification_ids = 1 [(buf.validate.field).repeated = {
+    min_items: 1
+    max_items: 100
+    items: {
+      string: {min_len: 1}
+    }
+  }];
+}
+
+// Exact visible notification occurrences requested by stable ID.
+message BatchGetNotificationOccurrencesResponse {
+  // Found occurrences in first-seen request order.
+  repeated NotificationOccurrence occurrences = 1;
 }
 
-// Request one pending notification for the authenticated viewer.
-message GetNotificationRequest {
-  // Required notification ID.
+// Unread notification occurrences currently targeting one room.
+message NotificationRoomUnreadCount {
+  // Target room ID.
+  string room_id = 1;
+  // Number of unread occurrences targeting this room.
+  int32 unread_count = 2;
+  // Unread occurrences in this room with Important attention.
+  int32 important_unread_count = 3;
+}
+
+// Request to mark one notification occurrence read.
+message MarkNotificationReadRequest {
+  // Required stable occurrence ID.
   string notification_id = 1 [(buf.validate.field).string.min_len = 1];
 }
 
-// Pending notification response.
-message GetNotificationResponse {
-  reserved 2;
-  reserved "server_name";
+// Notification occurrence after marking it read.
+message MarkNotificationReadResponse {
+  // Updated occurrence.
+  NotificationOccurrence occurrence = 1;
+}
 
-  // Requested notification.
-  NotificationItem notification = 1;
+// Request permanent deletion of one notification occurrence.
+message DeleteNotificationOccurrenceRequest {
+  // Required stable occurrence ID.
+  string notification_id = 1 [(buf.validate.field).string.min_len = 1];
+}
+
+// Result of deleting one notification occurrence.
+message DeleteNotificationOccurrenceResponse {
+  // True when this call replaced a visible occurrence with a deletion tombstone.
+  // False when the occurrence was already absent or deleted.
+  bool deleted = 1;
 }
 
-// Request pending notifications for a set of stable notification IDs.
-message BatchGetNotificationsRequest {
-  // Required notification IDs. Unknown or dismissed IDs are omitted from the
-  // response.
+// Request permanent deletion of an explicit bounded set of occurrences.
+message BatchDeleteNotificationOccurrencesRequest {
+  // Stable occurrence IDs to delete. Duplicates and already absent IDs are
+  // ignored. The server accepts at most 100 IDs.
   repeated string notification_ids = 1 [(buf.validate.field).repeated = {
     min_items: 1
     max_items: 100
@@ -132,92 +267,202 @@ message BatchGetNotificationsRequest {
   }];
 }
 
-// Batch pending notification response.
-message BatchGetNotificationsResponse {
-  reserved 2;
-  reserved "server_name";
+// Result of deleting exact notification occurrences.
+message BatchDeleteNotificationOccurrencesResponse {
+  // Number of visible occurrences replaced by deletion tombstones.
+  int32 deleted_count = 1;
+}
+
+// Request permanent deletion of every currently visible notification occurrence.
+message DeleteAllNotificationOccurrencesRequest {}
 
-  // Found notifications. The server preserves first-seen request order and
-  // de-duplicates repeated IDs.
-  repeated NotificationItem notifications = 1;
+// Result of deleting every currently visible notification occurrence.
+message DeleteAllNotificationOccurrencesResponse {
+  // Number of visible occurrences replaced by deletion tombstones.
+  int32 deleted_count = 1;
 }
 
-// Pending notification page scoped to one room.
-message ListRoomNotificationsResponse {
-  reserved 2, 3, 4;
-  reserved "total_count", "has_more", "server_name";
+// Explicit and effective delivery modes for one notification policy scope.
+message NotificationPolicy {
+  // Explicit modes at the requested scope. Missing fields mean Inherit.
+  NotificationDeliveryModes overrides = 1;
+  // Complete modes after applying product, server, and room inheritance.
+  NotificationDeliveryModes effective = 2;
+}
 
-  // Page notifications, newest first.
-  repeated NotificationItem notifications = 1;
-  // Page metadata.
-  PageInfo page = 5;
+// Request the authenticated viewer's notification policy.
+message GetNotificationPolicyRequest {
+  // Empty returns server-scoped preferences. A room ID returns the inherited
+  // effective policy for that room and requires current membership.
+  optional string room_id = 1 [(buf.validate.field).string.min_len = 1];
 }
 
-// Request for a lightweight pending notification check.
-message HasNotificationsRequest {}
+// Complete supported notification policy for one scope.
+message GetNotificationPolicyResponse {
+  // Room scope when requested; absent for server scope.
+  optional string room_id = 1;
+  // Explicit and effective policy for the requested scope.
+  NotificationPolicy policy = 2;
+}
 
-// Lightweight pending notification check.
-message HasNotificationsResponse {
-  // True when the authenticated viewer has at least one pending notification.
-  bool has_notifications = 1;
+// Sparsely update notification policy overrides.
+message UpdateNotificationPolicyRequest {
+  // Room scope to change; absent changes the server scope.
+  optional string room_id = 1 [(buf.validate.field).string.min_len = 1];
+  // Sparse override values. Fields selected by update_mask but absent here are
+  // cleared to Inherit.
+  NotificationDeliveryModes overrides = 2;
+  // Fields to set or clear, relative to overrides. The mask must contain at
+  // least one supported delivery-mode field.
+  google.protobuf.FieldMask update_mask = 3;
 }
 
-// Pending notification count for one room.
-message RoomNotificationCount {
-  // Room ID.
-  string room_id = 1;
-  // Pending notification count for the authenticated viewer in this room.
-  int32 total_count = 2;
+// Complete supported notification policy after a sparse update.
+message UpdateNotificationPolicyResponse {
+  // Room scope when changed; absent for server scope.
+  optional string room_id = 1;
+  // Explicit and effective policy after the update.
+  NotificationPolicy policy = 2;
 }
 
-// Request for pending notification counts grouped by room.
-message ListRoomNotificationCountsRequest {}
+// Explicit target for one notification policy. Clients must select exactly
+// one scope. Direct-message rooms use room scope and never inherit from a room
+// group.
+message NotificationPolicyScope {
+  oneof scope {
+    option (buf.validate.oneof).required = true;
+
+    // Selects the authenticated viewer's server-wide policy.
+    google.protobuf.Empty server = 1;
+    // Selects one existing room group's policy.
+    string room_group_id = 2 [(buf.validate.field).string.min_len = 1];
+    // Selects one room's policy and requires current room membership.
+    string room_id = 3 [(buf.validate.field).string.min_len = 1];
+  }
+}
 
-// Finite snapshot of pending notification counts grouped by room.
-message ListRoomNotificationCountsResponse {
-  // Counts for rooms with at least one pending notification.
-  repeated RoomNotificationCount room_counts = 1;
+// One policy together with the explicit scope that produced it.
+message ScopedNotificationPolicy {
+  // Exact policy scope.
+  NotificationPolicyScope scope = 1;
+  // Explicit and effective modes for the scope.
+  NotificationPolicy policy = 2;
 }
 
-// Request to dismiss one pending notification.
-message DismissNotificationRequest {
-  // Required. Notification to dismiss.
-  string notification_id = 1 [(buf.validate.field).string.min_len = 1];
+// Request one notification policy for the authenticated viewer.
+message NotificationPolicyServiceGetNotificationPolicyRequest {
+  // Required policy scope.
+  NotificationPolicyScope scope = 1 [(buf.validate.field).required = true];
+}
+
+// One resolved notification policy.
+message NotificationPolicyServiceGetNotificationPolicyResponse {
+  // Requested policy and its explicit scope.
+  ScopedNotificationPolicy policy = 1;
 }
 
-// Result of dismissing one pending notification.
-message DismissNotificationResponse {
-  // True when the notification is no longer pending.
-  bool dismissed = 1;
+// Request an explicit bounded set of notification policies.
+message BatchGetNotificationPoliciesRequest {
+  // Required scopes. Missing or inaccessible scopes are omitted. Repeated
+  // scopes are de-duplicated in first-seen request order.
+  repeated NotificationPolicyScope scopes = 1 [(buf.validate.field).repeated = {
+    min_items: 1
+    max_items: 100
+  }];
 }
 
-// Request to dismiss all pending notifications.
-message DismissAllNotificationsRequest {}
+// Resolved notification policies requested by scope.
+message BatchGetNotificationPoliciesResponse {
+  // Found policies in first-seen request order.
+  repeated ScopedNotificationPolicy policies = 1;
+}
 
-// Result of dismissing all pending notifications.
-message DismissAllNotificationsResponse {
-  // Number of notifications dismissed.
-  int32 dismissed_count = 1;
+// Sparsely update notification policy overrides at one explicit scope.
+message NotificationPolicyServiceUpdateNotificationPolicyRequest {
+  // Required policy scope.
+  NotificationPolicyScope scope = 1 [(buf.validate.field).required = true];
+  // Sparse override values. Fields selected by update_mask but absent here are
+  // cleared to Inherit.
+  NotificationDeliveryModes overrides = 2;
+  // Fields to set or clear, relative to overrides. The mask must contain at
+  // least one supported delivery-mode field.
+  google.protobuf.FieldMask update_mask = 3;
 }
 
-// Reads and dismisses pending notifications for the authenticated viewer.
+// Complete supported notification policy after a scoped sparse update.
+message NotificationPolicyServiceUpdateNotificationPolicyResponse {
+  // Updated policy and its explicit scope.
+  ScopedNotificationPolicy policy = 1;
+}
+
+// Reads, marks read, and deletes notifications for the authenticated viewer.
 service NotificationService {
-  // Lists the authenticated viewer's pending notifications.
-  rpc ListNotifications(ListNotificationsRequest) returns (ListNotificationsResponse);
-  // Gets one pending notification. Returns NOT_FOUND when the notification is
-  // unknown or has been dismissed.
-  rpc GetNotification(GetNotificationRequest) returns (GetNotificationResponse);
-  // Gets pending notifications by ID.
-  rpc BatchGetNotifications(BatchGetNotificationsRequest) returns (BatchGetNotificationsResponse);
-  // Lists pending notifications for one room. Non-members receive an empty page.
-  rpc ListRoomNotifications(ListRoomNotificationsRequest) returns (ListRoomNotificationsResponse);
-  // Lists pending notification counts grouped by room as a finite snapshot.
-  rpc ListRoomNotificationCounts(ListRoomNotificationCountsRequest) returns (ListRoomNotificationCountsResponse);
-  // Checks whether the authenticated viewer has any pending notifications.
-  rpc HasNotifications(HasNotificationsRequest) returns (HasNotificationsResponse);
-  // Dismisses one pending notification. Already-dismissed notifications are
-  // treated as idempotent success.
-  rpc DismissNotification(DismissNotificationRequest) returns (DismissNotificationResponse);
-  // Dismisses all pending notifications.
-  rpc DismissAllNotifications(DismissAllNotificationsRequest) returns (DismissAllNotificationsResponse);
+  // Gets one exact visible occurrence. Message-derived occurrences require
+  // current room membership. Channel-room occurrences also require message.read
+  // or a matching thread relationship with message.read-interactions. DM
+  // membership authorizes DM occurrences. Returns NOT_FOUND when the occurrence
+  // is absent, deleted, expired, or no longer visible to the authenticated
+  // viewer. Returns UNIMPLEMENTED when this server cannot validate its signal
+  // kind.
+  rpc GetNotificationOccurrence(GetNotificationOccurrenceRequest) returns (GetNotificationOccurrenceResponse);
+  // Gets an explicit bounded set of visible occurrences. Missing or
+  // inaccessible IDs are omitted and duplicate IDs are de-duplicated. Returns
+  // UNIMPLEMENTED rather than partially answering when this server cannot
+  // validate one requested signal kind.
+  rpc BatchGetNotificationOccurrences(BatchGetNotificationOccurrencesRequest) returns (BatchGetNotificationOccurrencesResponse);
+  // Lists exact Notifications 2.0 occurrences. Clients may derive temporary
+  // presentation groups without changing occurrence identity or counts.
+  // Message-derived occurrences require current room membership. Channel-room
+  // occurrences also require message.read or a matching thread relationship
+  // with message.read-interactions. DM membership authorizes DM occurrences.
+  // Returns UNIMPLEMENTED rather than silently omitting an occurrence whose
+  // signal kind this server version cannot validate and assemble.
+  rpc ListNotificationOccurrences(ListNotificationOccurrencesRequest) returns (ListNotificationOccurrencesResponse);
+  // Marks one occurrence read. Repeating the call is safe. Returns
+  // UNIMPLEMENTED when this server cannot validate the occurrence signal kind.
+  rpc MarkNotificationRead(MarkNotificationReadRequest) returns (MarkNotificationReadResponse) {
+    option idempotency_level = IDEMPOTENT;
+  }
+  // Permanently deletes one occurrence while retaining its anti-recreation
+  // tombstone through the original expiry. Repeating the call is safe. Returns
+  // UNIMPLEMENTED when this server cannot validate the occurrence signal kind.
+  rpc DeleteNotificationOccurrence(DeleteNotificationOccurrenceRequest) returns (DeleteNotificationOccurrenceResponse) {
+    option idempotency_level = IDEMPOTENT;
+  }
+  // Permanently deletes an exact set of occurrences. Repeating the call is
+  // safe because later activity receives different occurrence IDs. Returns
+  // UNIMPLEMENTED rather than partially deleting a signal kind this server
+  // cannot validate.
+  rpc BatchDeleteNotificationOccurrences(BatchDeleteNotificationOccurrencesRequest) returns (BatchDeleteNotificationOccurrencesResponse) {
+    option idempotency_level = IDEMPOTENT;
+  }
+  // Permanently deletes every visible occurrence current at the server's
+  // authoritative mutation boundary. Callers must not retry this mutation
+  // automatically because later activity may arrive after that boundary.
+  // Returns UNIMPLEMENTED rather than partially deleting a signal kind this
+  // server cannot validate.
+  rpc DeleteAllNotificationOccurrences(DeleteAllNotificationOccurrencesRequest) returns (DeleteAllNotificationOccurrencesResponse);
+  // Gets explicit and effective modes for every supported signal class.
+  rpc GetNotificationPolicy(GetNotificationPolicyRequest) returns (GetNotificationPolicyResponse);
+  // Atomically sets or clears selected server- or room-scoped signal-class overrides.
+  rpc UpdateNotificationPolicy(UpdateNotificationPolicyRequest) returns (UpdateNotificationPolicyResponse) {
+    option idempotency_level = IDEMPOTENT;
+  }
+
+}
+
+// Reads and updates the authenticated viewer's server, room-group, and room
+// notification delivery policies.
+service NotificationPolicyService {
+  // Gets explicit and effective modes for one policy scope. Returns NOT_FOUND
+  // for a missing room group or room, and PERMISSION_DENIED when the viewer is
+  // not a current member of a requested room.
+  rpc GetNotificationPolicy(NotificationPolicyServiceGetNotificationPolicyRequest) returns (NotificationPolicyServiceGetNotificationPolicyResponse);
+  // Gets an explicit bounded set of policies. Missing and inaccessible scopes
+  // are omitted, and duplicate scopes are de-duplicated in first-seen order.
+  rpc BatchGetNotificationPolicies(BatchGetNotificationPoliciesRequest) returns (BatchGetNotificationPoliciesResponse);
+  // Atomically sets or clears selected overrides at one explicit scope.
+  rpc UpdateNotificationPolicy(NotificationPolicyServiceUpdateNotificationPolicyRequest) returns (NotificationPolicyServiceUpdateNotificationPolicyResponse) {
+    option idempotency_level = IDEMPOTENT;
+  }
 }

+ 1 - 0
proto/chatto/api/v1/permissions.proto

@@ -17,6 +17,7 @@ message PermissionGrant {
 // - dm.start
 // - admin.view-users
 // - user.manage-accounts
+// - user.invite
 // - role.assign
 // - role.view
 // - role.manage

+ 37 - 3
proto/chatto/api/v1/push_notifications.proto

@@ -3,8 +3,9 @@ syntax = "proto3";
 package chatto.api.v1;
 
 import "buf/validate/validate.proto";
+import "google/protobuf/descriptor.proto";
 
-// Browser Web Push subscription commands for the current user.
+// Browser Web Push subscription commands for the authenticated user.
 service PushNotificationService {
   // Stores or updates the caller's browser push subscription.
   //
@@ -15,12 +16,20 @@ service PushNotificationService {
   // Removes the caller's browser push subscription by endpoint.
   //
   // The call is idempotent: removing an unknown endpoint still succeeds.
-  rpc Unsubscribe(UnsubscribePushRequest) returns (UnsubscribePushResponse);
+  rpc Unsubscribe(UnsubscribePushRequest) returns (UnsubscribePushResponse) {
+    option idempotency_level = IDEMPOTENT;
+  }
+
+  // Sends a test notification to the caller's registered browser subscriptions.
+  // Calls are rate-limited per account. Delivery failures return a generic
+  // unavailable error without exposing the push provider's response body.
+  rpc SendTestNotification(SendTestPushNotificationRequest) returns (SendTestPushNotificationResponse);
 }
 
 // Request to store a PushSubscription returned by the browser Push API.
 message SubscribePushRequest {
-  // Push service endpoint URL.
+  // Absolute HTTPS push service endpoint URL. The server rejects redirects and
+  // destinations that resolve to private or other special-use addresses.
   string endpoint = 1 [(buf.validate.field).string = {
     min_len: 1
     max_len: 4096
@@ -40,6 +49,22 @@ message SubscribePushRequest {
 
   // Optional browser user-agent string for device identification.
   optional string user_agent = 4 [(buf.validate.field).string.max_len = 512];
+
+  // URL host of the Chatto server that supplied the installed web app. The
+  // sending server combines this with its own hostname to reconstruct the
+  // client route. A host can include a port but no scheme, path, or user info.
+  string client_host = 5 [(buf.validate.field).string = {
+    min_len: 1
+    max_len: 255
+  }];
+
+  // Random per-save capability generated by the client. A cancelled save can
+  // later remove only the record carrying this exact token, even when the same
+  // browser PushSubscription has since moved to another account.
+  string cleanup_token = 6 [(buf.validate.field).string = {
+    min_len: 32
+    max_len: 128
+  }];
 }
 
 // Response from storing a browser push subscription.
@@ -62,3 +87,12 @@ message UnsubscribePushResponse {
   // True when the request completed.
   bool unsubscribed = 1;
 }
+
+// Request to test the current user's registered browser push subscriptions.
+message SendTestPushNotificationRequest {}
+
+// Result of sending a test Web Push notification.
+message SendTestPushNotificationResponse {
+  // True when the push provider accepted the notification.
+  bool sent = 1;
+}

+ 3 - 1
proto/chatto/api/v1/reactions.proto

@@ -9,7 +9,9 @@ import "chatto/api/v1/message_types.proto";
 //
 // When `message_event_id` names a channel echo of a thread reply, the server
 // treats it as an alias for the original thread reply and stores the reaction
-// on that original event.
+// on that original event. The server returns `RESOURCE_EXHAUSTED` when the
+// current user already has 20 distinct emoji reactions on the canonical
+// message; adding an emoji that already exists remains an idempotent success.
 message AddReactionRequest {
   // Required. Room containing the message event.
   string room_id = 1 [(buf.validate.field).string.min_len = 1];

+ 2 - 2
proto/chatto/api/v1/read_state.proto

@@ -17,8 +17,8 @@ message MarkRoomAsReadRequest {
 
 // Result of marking a room timeline as read.
 //
-// Clients can use the previous timestamp to decide whether unread badges or
-// local notification state need to be reconciled.
+// Clients can use the previous timestamp to place a local New messages
+// separator. Notification policy controls room Badge attention separately.
 message MarkRoomAsReadResponse {
   // New room read timestamp stored for the current user.
   google.protobuf.Timestamp last_read_at = 1;

+ 12 - 7
proto/chatto/api/v1/room_directory.proto

@@ -5,26 +5,31 @@ package chatto.api.v1;
 import "buf/validate/validate.proto";
 import "chatto/api/v1/permissions.proto";
 import "chatto/api/v1/rooms.proto";
+import "google/protobuf/timestamp.proto";
 
 // Viewer-specific state and permission decisions for one room.
 message RoomViewerState {
   // True when the current user is an effective room member.
   bool is_member = 1;
-  // True when the room has unread root messages for the current user.
+  // True when the room has neutral Badge attention for the current user.
+  // This state is independent of the user's last-read message cursor.
   bool has_unread = 2;
   // Effective room-scoped permission decisions after room state constraints.
   repeated PermissionGrant permissions = 3;
+  // Earliest time the current user may create another message. Absent when
+  // slow mode is disabled, expired, or bypassed by moderation permissions.
+  google.protobuf.Timestamp slow_mode_next_post_at = 4;
 }
 
 // Room kinds to include in directory responses.
 enum RoomDirectoryScope {
-  // Include both visible channel rooms and the caller's active DM rooms.
+  // Include visible channel rooms and readable active DM rooms.
   ROOM_DIRECTORY_SCOPE_UNSPECIFIED = 0;
-  // Include both visible channel rooms and the caller's active DM rooms.
+  // Include visible channel rooms and readable active DM rooms.
   ROOM_DIRECTORY_SCOPE_ALL = 1;
   // Include visible channel rooms only.
   ROOM_DIRECTORY_SCOPE_CHANNELS = 2;
-  // Include the caller's active DM rooms only.
+  // Include the caller's readable active DM rooms only.
   ROOM_DIRECTORY_SCOPE_DMS = 3;
 }
 
@@ -181,9 +186,9 @@ message BatchGetRoomsResponse {
 // Reads room directory data.
 service RoomDirectoryService {
   // Lists rooms visible to the current user. Channel rooms are non-archived
-  // rooms visible through membership or room.list. DM rooms are membership-only
-  // and empty DM conversations are omitted. Results are returned as a finite
-  // navigation snapshot.
+  // rooms visible through membership or room.list. DM membership authorizes DM
+  // reads. Empty DMs are omitted. Results are returned as a finite navigation
+  // snapshot.
   rpc ListRooms(ListRoomsRequest) returns (ListRoomsResponse);
   // Lists ordered channel room groups and sidebar items visible to the current
   // user as a finite navigation snapshot. Hidden room entries are omitted.

+ 26 - 1
proto/chatto/api/v1/room_timeline.proto

@@ -3,6 +3,7 @@ syntax = "proto3";
 package chatto.api.v1;
 
 import "buf/validate/validate.proto";
+import "chatto/api/v1/common.proto";
 import "chatto/api/v1/message_types.proto";
 import "chatto/api/v1/users.proto";
 import "google/protobuf/timestamp.proto";
@@ -24,6 +25,22 @@ message RoomTimelineRoomEvent {
   string room_id = 1;
 }
 
+// Payload for voice-call lifecycle timeline events.
+message RoomTimelineCallEvent {
+  // Room containing the call.
+  string room_id = 1;
+  // Stable identifier for this call session.
+  string call_id = 2;
+}
+
+// Payload for a room Threading Mode change shown in the room timeline.
+message RoomTimelineThreadingModeChangedEvent {
+  // Room whose Threading Mode changed.
+  string room_id = 1;
+  // Newly configured Threading Mode.
+  RoomThreadingMode threading_mode = 2;
+}
+
 // Payload for a message-posted timeline event.
 message RoomMessagePosted {
   // Renderable message created by this timeline event.
@@ -55,10 +72,16 @@ message RoomTimelineEvent {
     RoomTimelineRoomEvent room_archived = 23;
     // The room was unarchived.
     RoomTimelineRoomEvent room_unarchived = 24;
+    // The room's Threading Mode changed.
+    RoomTimelineThreadingModeChangedEvent room_threading_mode_changed = 25;
     // A user joined the room.
     RoomTimelineRoomEvent user_joined_room = 30;
     // A user left the room.
     RoomTimelineRoomEvent user_left_room = 31;
+    // A voice call started in the room.
+    RoomTimelineCallEvent call_started = 40;
+    // The voice call ended in the room.
+    RoomTimelineCallEvent call_ended = 41;
   }
 }
 
@@ -66,7 +89,9 @@ message RoomTimelineEvent {
 //
 // Use opaque start_cursor and end_cursor values with before/after requests to
 // continue paging in either direction. Clients must treat cursor values as
-// server-owned tokens and must not parse or construct them. The has_older and
+// server-owned tokens and must not parse, construct, share between users, or
+// reuse them for another room or thread. The server binds each cursor to the
+// authenticated viewer and exact timeline resource. The has_older and
 // has_newer flags tell clients whether another request can extend the current
 // window.
 message RoomTimelinePage {

+ 122 - 16
proto/chatto/api/v1/rooms.proto

@@ -9,6 +9,8 @@ import "chatto/api/v1/member_directory.proto";
 import "chatto/api/v1/pagination.proto";
 import "chatto/api/v1/read_state.proto";
 import "chatto/api/v1/room_timeline.proto";
+import "chatto/api/v1/message_types.proto";
+import "chatto/api/v1/users.proto";
 import "google/protobuf/timestamp.proto";
 
 // Kind of room represented by the public API.
@@ -38,6 +40,12 @@ message Room {
   string group_id = 6;
   // True when a channel grants effective membership to eligible server members.
   bool universal = 7;
+  // Minimum number of seconds a non-exempt member must wait between message
+  // posts. Channel rooms only; zero means slow mode is disabled.
+  uint32 slow_mode_seconds = 8;
+  // Threading policy for this channel room. Direct-message rooms report
+  // UNSPECIFIED because they do not support threads.
+  RoomThreadingMode threading_mode = 9;
 }
 
 // Lightweight room reference for cross-resource rows.
@@ -53,7 +61,9 @@ message RoomSummary {
 
 // Request to create a channel room.
 message CreateRoomRequest {
-  // Required. Name of the new channel room.
+  // Required. Visible 1–30-code-point Unicode name of the new channel room.
+  // The server trims and stores it in NFC form. Control characters and Unicode
+  // line or paragraph separators are rejected.
   string name = 1 [(buf.validate.field).string = {
     min_len: 1
     max_len: 30
@@ -65,6 +75,8 @@ message CreateRoomRequest {
   // Whether the new channel should grant effective membership to eligible
   // server members.
   bool universal = 4;
+  // Threading policy for the new channel. UNSPECIFIED selects ENABLED.
+  RoomThreadingMode threading_mode = 5;
 }
 
 // Result of creating a room.
@@ -77,7 +89,9 @@ message CreateRoomResponse {
 message UpdateRoomRequest {
   // Required. Room to update.
   string room_id = 1 [(buf.validate.field).string.min_len = 1];
-  // New room name, when changing it.
+  // New visible 1–30-code-point Unicode room name, when changing it. The server
+  // trims and stores it in NFC form. Control characters and Unicode line or
+  // paragraph separators are rejected.
   optional string name = 2 [(buf.validate.field).string = {
     min_len: 1
     max_len: 30
@@ -87,6 +101,13 @@ message UpdateRoomRequest {
   // New universal membership state, when changing it. Direct-message rooms
   // cannot be universal.
   optional bool universal = 4;
+  // New per-user posting interval in seconds. Zero disables slow mode. The
+  // maximum supported interval is six hours. Direct-message rooms cannot use
+  // slow mode.
+  optional uint32 slow_mode_seconds = 5 [(buf.validate.field).uint32.lte = 21600];
+  // New threading policy. Direct-message rooms cannot set this field, and an
+  // explicitly supplied UNSPECIFIED or unknown value is rejected.
+  optional RoomThreadingMode threading_mode = 6;
 }
 
 // Result of updating a room.
@@ -143,7 +164,10 @@ message JoinRoomGroupResponse {
   repeated string joined_room_ids = 1;
 }
 
-// Request to start or fetch a direct-message room.
+// Request to start a direct-message room for a human caller, or fetch an
+// existing one. A human caller can fetch an existing DM without message.post.
+// A valid request from a bot receives PERMISSION_DENIED, including when the
+// room already exists.
 message StartDMRequest {
   // Other participants to include in the direct-message room. The current user
   // is always included by the server. An empty list creates or fetches the
@@ -306,6 +330,60 @@ message ListRoomAttachmentsResponse {
   PageInfo page = 4;
 }
 
+// One current pinned message in a channel room.
+message PinnedMessage {
+  // Current renderable message. Retracted messages are omitted from pin lists.
+  Message message = 1;
+}
+
+// Request to list a channel room's current pinned messages.
+message ListPinnedMessagesRequest {
+  // Required channel room ID.
+  string room_id = 1 [(buf.validate.field).string.min_len = 1];
+  // Page request. Defaults are applied when absent or limit is zero.
+  PageRequest page = 2;
+}
+
+// Current pinned messages in one channel room.
+message ListPinnedMessagesResponse {
+  // Pinned messages in newest-pin-first order.
+  repeated PinnedMessage pinned_messages = 1;
+  // Page metadata.
+  PageInfo page = 2;
+  // Opaque marker for the latest pin fact observed for this room. The value
+  // remains stable when pins are removed so clients can detect new pins
+  // without comparing wall-clock timestamps. Clients must not interpret it.
+  string latest_pin_marker = 3;
+}
+
+// Request to pin a message in a channel room.
+message CreatePinnedMessageRequest {
+  // Required channel room ID.
+  string room_id = 1 [(buf.validate.field).string.min_len = 1];
+  // Required canonical message event ID. Thread replies are supported.
+  string message_event_id = 2 [(buf.validate.field).string.min_len = 1];
+}
+
+// Result of pinning a message.
+message CreatePinnedMessageResponse {
+  // Current pinned-message resource. Repeating an existing pin returns it.
+  PinnedMessage pinned_message = 1;
+}
+
+// Request to unpin a message from a channel room.
+message DeletePinnedMessageRequest {
+  // Required channel room ID.
+  string room_id = 1 [(buf.validate.field).string.min_len = 1];
+  // Required canonical message event ID.
+  string message_event_id = 2 [(buf.validate.field).string.min_len = 1];
+}
+
+// Result of unpinning a message.
+message DeletePinnedMessageResponse {
+  // True when the message is not pinned after this call.
+  bool deleted = 1;
+}
+
 // Request to refresh the current user's live-only typing indicator.
 message UpdateTypingIndicatorRequest {
   // Required. Room where the current user is typing.
@@ -345,19 +423,24 @@ service RoomService {
   // Joins every unarchived room in a group that the current user can join.
   // Already-joined and non-joinable rooms are skipped.
   rpc JoinRoomGroup(JoinRoomGroupRequest) returns (JoinRoomGroupResponse);
-  // Starts or fetches a direct-message room for the current user and the
-  // requested participant set. The caller must be allowed to start DMs.
+  // Starts a direct-message room for the current human user and the requested
+  // participant set, or fetches its existing DM. message.post is required
+  // only when the DM must be created. A valid request from a bot receives
+  // PERMISSION_DENIED and cannot use this RPC to fetch an existing DM.
   rpc StartDM(StartDMRequest) returns (StartDMResponse);
   // Leaves the room as the current user. Direct-message and universal rooms
   // cannot be left.
   rpc LeaveRoom(LeaveRoomRequest) returns (LeaveRoomResponse);
-  // Lists explicit members of a room. The caller must be a member of the room.
+  // Lists effective room members. Existing members and room.manage holders may
+  // list a channel room; other nonmembers need both room.list and room.join.
   rpc ListMembers(ListRoomMembersRequest) returns (ListRoomMembersResponse);
-  // Gets one explicit member of a room. The caller must be a member of the
-  // room. Returns NOT_FOUND when the target is unknown or not a room member.
+  // Gets one explicit member of a room. Existing members and room.manage
+  // holders may read channel-room members; DMs remain membership-only. Returns
+  // NOT_FOUND when the target is unknown or not a room member.
   rpc GetMember(GetRoomMemberRequest) returns (GetRoomMemberResponse);
-  // Gets explicit room member rows for multiple users. The caller must be a
-  // member of the room.
+  // Gets explicit room member rows for multiple users. Existing members and
+  // room.manage holders may read channel-room members; DMs remain
+  // membership-only.
   rpc BatchGetMembers(BatchGetRoomMembersRequest) returns (BatchGetRoomMembersResponse);
   // Adds a user as an explicit member of a channel room. The caller must be
   // allowed to manage the room. Direct-message and universal rooms cannot be
@@ -371,22 +454,45 @@ service RoomService {
   // membership bans.
   rpc ListBans(ListBansRequest) returns (ListBansResponse);
   // Lists current message-owned room attachments. Authentication and room
-  // membership are required. Returns PERMISSION_DENIED when the room is
+  // membership are required. Channel-room attachments also require message.read
+  // or a matching thread relationship with message.read-interactions. DM
+  // membership authorizes DM attachments. The server omits attachments from
+  // inaccessible threads. Returns PERMISSION_DENIED when the room is
   // inaccessible to the caller.
   rpc ListRoomAttachments(ListRoomAttachmentsRequest) returns (ListRoomAttachmentsResponse);
+  // Lists current pinned messages in a channel room. Room membership plus
+  // message.read or message.read-interactions are required. The server omits
+  // pins from threads that the caller cannot read. Direct-message rooms do not
+  // support pins.
+  rpc ListPinnedMessages(ListPinnedMessagesRequest) returns (ListPinnedMessagesResponse);
+  // Pins a current message. The caller must have room.manage and must be able
+  // to read the message. Repeating an existing pin is idempotent. Direct-message
+  // rooms are rejected.
+  rpc CreatePinnedMessage(CreatePinnedMessageRequest) returns (CreatePinnedMessageResponse);
+  // Removes a current pin. The caller must have room.manage. Removing a
+  // missing pin is idempotent. Direct-message rooms are rejected.
+  rpc DeletePinnedMessage(DeletePinnedMessageRequest) returns (DeletePinnedMessageResponse);
   // Refreshes the current user's live-only typing indicator for a room or
   // thread. Room membership is required; message posting permission is not.
   rpc UpdateTypingIndicator(UpdateTypingIndicatorRequest) returns (UpdateTypingIndicatorResponse);
-  // Returns one page of room timeline events, including related user data needed
-  // to render the page.
+  // Returns one page of room timeline events, including related user data
+  // needed to render the page. Room membership is required. Channel-room reads
+  // also require message.read or message.read-interactions. The server returns
+  // only related thread roots for an interaction-scoped caller. DM membership
+  // authorizes DM reads.
   rpc GetRoomEvents(GetRoomEventsRequest) returns (GetRoomEventsResponse);
   // Returns a room timeline window centered around a specific event. Use this to
   // open a permalink, search result, or notification target in context. Returns
   // NOT_FOUND when the anchor event is missing or not visible in the room
-  // timeline and PERMISSION_DENIED when the room is inaccessible.
+  // timeline. Returns PERMISSION_DENIED when room membership or both read modes
+  // are missing, or when the anchor is in an unrelated thread. DM membership
+  // authorizes DM reads.
   rpc GetRoomEventsAround(GetRoomEventsAroundRequest) returns (GetRoomEventsAroundResponse);
-  // Marks a room timeline as read through the supplied event. If no event is
-  // supplied, the server marks through the room's latest root event. Clients
+  // Marks a room timeline as read through the supplied event. Room membership
+  // is required. Channel-room reads also require message.read or
+  // message.read-interactions. DM membership authorizes DM reads. If no event
+  // is supplied, the server marks through the latest root event that the caller
+  // can read. Clients
   // usually call this after the user has viewed the latest visible event in the
   // room.
   rpc MarkRoomAsRead(MarkRoomAsReadRequest) returns (MarkRoomAsReadResponse);

+ 15 - 0
proto/chatto/api/v1/server.proto

@@ -20,6 +20,16 @@ message ServerPublicProfile {
   optional string description = 6;
 }
 
+// Policy controlling whether self-service account creation requires an invitation.
+enum AccountCreationPolicy {
+  // Compatibility value used by older servers. Clients treat it as open.
+  ACCOUNT_CREATION_POLICY_UNSPECIFIED = 0;
+  // Self-service account creation does not require an invitation.
+  ACCOUNT_CREATION_POLICY_OPEN = 1;
+  // Every self-service account creation path requires a valid invitation.
+  ACCOUNT_CREATION_POLICY_INVITE_ONLY = 2;
+}
+
 // Login and registration options exposed before authentication.
 message ServerLogin {
   // Whether users can create accounts through the public UI.
@@ -28,4 +38,9 @@ message ServerLogin {
   repeated ProviderMetadata providers = 2;
   // URL for the legacy authorization flow, when enabled.
   string authorize_url = 3;
+  // Admission policy for direct registration and provider auto-provisioning.
+  AccountCreationPolicy account_creation_policy = 4;
+  // Whether users can sign in directly with a username or email address and password.
+  // Absent on older servers, where clients should treat direct login as enabled.
+  optional bool direct_login_enabled = 5;
 }

+ 26 - 15
proto/chatto/api/v1/threads.proto

@@ -71,8 +71,6 @@ message ListFollowedThreadsRequest {
 
   // Page request. Defaults to 20 results when absent or limit is zero.
   PageRequest page = 3;
-  // When true, returns only threads with unread replies.
-  bool unread_only = 4;
 }
 
 // Response containing one followed-thread page.
@@ -87,30 +85,43 @@ message ListFollowedThreadsResponse {
   RoomTimelineIncludes includes = 4;
   // Page metadata.
   PageInfo page = 5;
-  // True when the response is restricted to threads with unread replies.
-  bool unread_only = 6;
 }
 
 // Manages thread follow state for the current user.
 service ThreadService {
-  // Returns followed threads for the current user, including enough root-message
-  // data for clients to render the list without extra per-field fetches.
+  // Returns followed threads in rooms where the current user is a member.
+  // Channel-room threads also require message.read or an active relationship
+  // with message.read-interactions. Historical DM threads use DM membership.
+  // The result includes enough root-message data for clients to render the
+  // list without extra per-field fetches.
   rpc ListFollowedThreads(ListFollowedThreadsRequest) returns (ListFollowedThreadsResponse);
-  // Follows a thread for the current user. Followed threads can be surfaced in
-  // clients and can participate in thread notification behavior.
+  // Follows a thread for the current user. Room membership plus message.read or
+  // an active relationship with message.read-interactions are required. DMs do
+  // not support current thread actions.
+  // Followed threads can be surfaced in clients and can participate in thread
+  // notification behavior.
   rpc FollowThread(FollowThreadRequest) returns (FollowThreadResponse);
-  // Stops following a thread for the current user. The response reports the
-  // resulting follow state so clients can update local UI immediately.
+  // Stops following a thread for the current user. Room membership plus
+  // message.read or an active relationship with message.read-interactions are
+  // required. DMs do not support current thread actions. The response reports
+  // the resulting follow state so clients can update local UI immediately.
   rpc UnfollowThread(UnfollowThreadRequest) returns (UnfollowThreadResponse);
-  // Returns one page of events in a message thread. Initial pages include the
-  // thread root message; cursor pages return replies in the requested direction.
+  // Returns one page of events in a message thread. Room membership is
+  // required. Channel-room reads also require message.read or an active
+  // relationship with message.read-interactions. Historical DM threads use DM
+  // membership. Initial pages include the thread root message; cursor pages
+  // return replies in the requested direction.
   rpc GetThreadEvents(GetThreadEventsRequest) returns (GetThreadEventsResponse);
   // Returns a thread timeline window centered around a specific event. Use this
   // to open a reply from a notification or search result in context. Returns
-  // NOT_FOUND when the thread root or anchor event is missing or hidden and
-  // PERMISSION_DENIED when the room is inaccessible.
+  // NOT_FOUND when the thread root or anchor event is missing or hidden.
+  // Returns PERMISSION_DENIED when room membership is missing, both read modes
+  // are missing, or an interaction-scoped account has no relationship with the
+  // thread. Historical DM threads use DM membership.
   rpc GetThreadEventsAround(GetThreadEventsAroundRequest) returns (GetThreadEventsAroundResponse);
   // Marks a thread timeline as read through the supplied event without changing
-  // the room-level read marker.
+  // the room-level read marker. Room membership plus message.read or an active
+  // relationship with message.read-interactions are required. DMs do not
+  // support current thread actions.
   rpc MarkThreadAsRead(MarkThreadAsReadRequest) returns (MarkThreadAsReadResponse);
 }

+ 8 - 0
proto/chatto/api/v1/users.proto

@@ -21,4 +21,12 @@ message User {
   PresenceStatus presence_status = 6;
   // Custom profile status, when set.
   CustomUserStatus custom_status = 7;
+  // True when this identity represents an automated bot.
+  bool is_bot = 8;
+  // Short self-authored biography shown on the user's profile, when set.
+  optional string bio = 9;
+  // IANA time zone the user shares on their profile (for example
+  // "Europe/Berlin"), when set. Absent means the user has not chosen a
+  // shareable time zone.
+  optional string timezone = 10;
 }

+ 4 - 19
proto/chatto/api/v1/viewer.proto

@@ -2,7 +2,6 @@ syntax = "proto3";
 
 package chatto.api.v1;
 
-import "chatto/api/v1/notification_preferences.proto";
 import "chatto/api/v1/permissions.proto";
 import "chatto/api/v1/users.proto";
 import "google/protobuf/timestamp.proto";
@@ -53,8 +52,6 @@ message ViewerCapabilities {
   repeated CapabilityGrant grants = 1;
   // Whether the user has unread followed threads.
   bool has_unread_followed_threads = 2;
-  // Whether a followed channel thread has a pending notification.
-  bool has_pending_followed_thread_notifications = 3;
 }
 
 // Effective server/channel permission decisions for the authenticated user.
@@ -65,34 +62,22 @@ message ServerViewerPermissions {
 
 // Non-permission server state for the authenticated user.
 message ServerViewerState {
-  // Whether any joined channel room has unread messages.
+  // Whether any joined channel room has neutral Badge attention.
   bool has_unread_rooms = 1;
 }
 
-// Room notification preference for one joined room.
-message RoomNotificationPreference {
-  reserved 2, 3;
-  reserved "level", "effective_level";
-
-  // Room whose preference is represented.
-  string room_id = 1;
-  // Stored and effective notification preference.
-  NotificationPreference preference = 4;
-}
-
 // Request for the authenticated viewer snapshot.
 message GetViewerRequest {}
 
 // Authenticated viewer snapshot needed by application shells.
 message GetViewerResponse {
+  reserved 3, 4;
+  reserved "server_notification_preference", "room_notification_preferences";
+
   // Current authenticated user.
   ViewerUser user = 1;
   // Permission-derived capabilities.
   ViewerCapabilities capabilities = 2;
-  // Server-wide notification preference.
-  NotificationPreference server_notification_preference = 3;
-  // Notification preferences for rooms the user participates in.
-  repeated RoomNotificationPreference room_notification_preferences = 4;
   // Effective server/channel permission decisions for the authenticated user.
   ServerViewerPermissions viewer_permissions = 5;
   // Non-permission server state for the authenticated user.

+ 41 - 3
proto/chatto/api/v1/voice_calls.proto

@@ -9,7 +9,7 @@ import "google/protobuf/timestamp.proto";
 
 // Voice-call request/response APIs backed by Chatto call-state projections.
 service VoiceCallService {
-  // Lists member channel rooms that currently have active calls as a finite
+  // Lists member rooms that currently have active calls as a finite
   // runtime snapshot. Rooms the caller is not a member of are omitted.
   //
   // Returns an empty list when LiveKit is not configured.
@@ -51,16 +51,25 @@ service VoiceCallService {
   // or voice and video calls are not configured.
   rpc GetCallToken(GetCallTokenRequest) returns (GetCallTokenResponse);
 
+  // Issues a short-lived LiveKit token for a native companion publisher owned
+  // by the caller. Companion publishers contribute media to the caller's
+  // logical participant without becoming call participants themselves.
+  //
+  // The caller must already participate in the active call. Returns
+  // FAILED_PRECONDITION when no call is active, the caller has not joined it,
+  // or voice and video calls are not configured.
+  rpc CreateCallMediaPublisherToken(CreateCallMediaPublisherTokenRequest) returns (CreateCallMediaPublisherTokenResponse);
+
   // Records the caller's intent to leave a room call.
   //
   // Returns left=false when LiveKit is not configured.
   rpc LeaveCall(LeaveCallRequest) returns (LeaveCallResponse);
 }
 
-// Request for active channel call snapshots.
+// Request for active room call snapshots.
 message ListActiveCallsRequest {}
 
-// Finite runtime snapshot of active channel calls.
+// Finite runtime snapshot of active room calls.
 message ListActiveCallsResponse {
   // Active calls in room order returned by the call-state projection.
   repeated ActiveCall calls = 1;
@@ -159,6 +168,35 @@ message GetCallTokenResponse {
   string call_id = 3;
 }
 
+// Native companion publisher kind.
+enum CallMediaPublisherKind {
+  // The publisher kind was not specified.
+  CALL_MEDIA_PUBLISHER_KIND_UNSPECIFIED = 0;
+  // Native game-window video and application audio.
+  CALL_MEDIA_PUBLISHER_KIND_GAME_SHARE = 1;
+}
+
+// Request for a native companion publisher credential.
+message CreateCallMediaPublisherTokenRequest {
+  // Required. Room whose active call receives the media.
+  string room_id = 1 [(buf.validate.field).string.min_len = 1];
+  // Required. Kind of native media publisher being started.
+  CallMediaPublisherKind kind = 2 [(buf.validate.field).enum = {defined_only: true not_in: [0]}];
+}
+
+// Credential for one native companion publisher connection.
+message CreateCallMediaPublisherTokenResponse {
+  reserved 4;
+  reserved "publisher_identity";
+
+  // Short-lived LiveKit JWT token.
+  string token = 1;
+  // Shared E2EE key for this active call.
+  string e2ee_key = 2;
+  // Active call session ID.
+  string call_id = 3;
+}
+
 // Request to record leaving a room call.
 message LeaveCallRequest {
   // Required. Room whose call is being left.

+ 11 - 1
proto/chatto/auth/v1/external_identity_auth.proto

@@ -59,6 +59,9 @@ message CreateExternalIdentityAccountRequest {
     min_len: 2
     max_len: 32
   }];
+  // Desired display name. When omitted, the server uses a valid provider hint
+  // and otherwise falls back to the requested username.
+  string display_name = 3 [(buf.validate.field).string.max_len = 32];
 }
 
 // Result of creating an account from a pending provider identity.
@@ -67,8 +70,15 @@ message CreateExternalIdentityAccountResponse {
   string user_id = 1;
   // Newly created username.
   string login = 2;
-  // Bearer token for the new session.
+  // Short-lived bearer access token for the new session.
   string token = 3;
+  // Rotating refresh credential for renewing the bearer session.
+  string refresh_token = 4;
+  // Lifetime of the access token in seconds.
+  int64 expires_in = 5;
+  // Remaining lifetime of the current renewable-session window in seconds.
+  // A successful refresh can advance this window for an active session.
+  int64 refresh_token_expires_in = 6;
 }
 
 // Request to cancel a pending external identity flow.

+ 4 - 1
proto/chatto/discovery/v1/server.proto

@@ -3,6 +3,7 @@ syntax = "proto3";
 package chatto.discovery.v1;
 
 import "chatto/api/v1/server.proto";
+import "google/protobuf/descriptor.proto";
 
 // Request for public server metadata.
 message GetServerRequest {}
@@ -21,5 +22,7 @@ service ServerDiscoveryService {
   // Returns public server metadata, branding, registration status, and login
   // provider information. This fully public RPC is available before login so
   // clients can render the first server screen and authentication choices.
-  rpc GetServer(GetServerRequest) returns (GetServerResponse);
+  rpc GetServer(GetServerRequest) returns (GetServerResponse) {
+    option idempotency_level = NO_SIDE_EFFECTS;
+  }
 }

+ 337 - 425
proto/chatto/realtime/v1/realtime.proto

@@ -2,27 +2,35 @@ syntax = "proto3";
 
 package chatto.realtime.v1;
 
-import "chatto/api/v1/notification_preferences.proto";
+import "chatto/api/v1/notifications.proto";
+import "chatto/api/v1/member_directory.proto";
+import "chatto/api/v1/message_types.proto";
 import "chatto/api/v1/presence.proto";
+import "chatto/api/v1/room_directory.proto";
+import "chatto/api/v1/room_timeline.proto";
+import "chatto/api/v1/server.proto";
+import "chatto/api/v1/server_state.proto";
 import "chatto/api/v1/viewer.proto";
+import "chatto/api/v1/voice_calls.proto";
 import "google/protobuf/timestamp.proto";
 
 // Client-to-server frame for Chatto's protobuf WebSocket realtime protocol.
 //
 // Clients send binary protobuf frames to `/api/realtime`. The first frame must
 // be `hello`; after the server replies with `hello`, clients send
-// `subscribe_events` to start the authenticated live event stream.
+// `subscribe_events` to start the authenticated server-projection stream.
 message RealtimeClientFrame {
-  reserved 4;
   reserved "ack";
 
   oneof frame {
     // Opens the protocol session and optionally carries a bearer token.
     RealtimeClientHello hello = 1;
-    // Starts the caller's authorized live-event stream.
+    // Starts the caller's authorized server-projection stream.
     RealtimeSubscribeEvents subscribe_events = 2;
     // Application-level ping. The server replies with `pong`.
     RealtimePing ping = 3;
+    // Adds one joined room's recent timeline to the retained projection.
+    RealtimeHydrateRoom hydrate_room = 4;
   }
 }
 
@@ -31,9 +39,9 @@ message RealtimeServerFrame {
   oneof frame {
     // Confirms protocol version, server version, and capabilities.
     RealtimeServerHello hello = 1;
-    // Confirms the live-event stream has started.
+    // Confirms the server-projection stream has started.
     RealtimeSubscribed subscribed = 2;
-    // A live event authorized for the connected user.
+    // An authorized transient, non-replayable event.
     RealtimeEventEnvelope event = 3;
     // Application-level heartbeat used for liveness checks.
     RealtimeHeartbeat heartbeat = 4;
@@ -43,6 +51,10 @@ message RealtimeServerFrame {
     RealtimeClose close = 6;
     // Reply to a client ping.
     RealtimePong pong = 7;
+    // Confirms that durable replay reached the live-stream boundary.
+    RealtimeCaughtUp caught_up = 8;
+    // One idempotent mutation of the caller's server projection.
+    RealtimeProjectionEvent projection_event = 9;
   }
 }
 
@@ -51,7 +63,7 @@ message RealtimeClientHello {
   reserved 3;
   reserved "resume_cursor";
 
-  // Protocol version requested by the client. The current version is 1.
+  // Protocol version requested by the client. The only supported version is 2.
   uint32 protocol_version = 1;
   // Optional bearer token. When present, it takes precedence over cookie auth.
   optional string bearer_token = 2;
@@ -69,18 +81,315 @@ message RealtimeServerHello {
   // Approximate heartbeat interval clients should expect.
   uint32 heartbeat_interval_seconds = 4;
   // Stable protocol capability keys supported by this server. Current keys:
-  // `chatto.realtime.events.live.v1`, `chatto.realtime.heartbeat.v1`, and
-  // `chatto.realtime.ping.v1`.
+  // `chatto.realtime.events.live.v1`, `chatto.realtime.heartbeat.v1`,
+  // `chatto.realtime.ping.v1`, `chatto.realtime.events.resume.v1`, and
+  // `chatto.realtime.projection.v1`.
   repeated string capabilities = 5;
 }
 
-// Request to start the caller's authorized live event stream.
-message RealtimeSubscribeEvents {}
+// Request to start the caller's authorized server-projection stream.
+message RealtimeSubscribeEvents {
+  // Opaque cursor from a previously received durable event or caught-up
+  // frame. The server replays authorized durable projection changes after
+  // this cursor before continuing with live delivery. Omit it to receive a
+  // compacted reset of the current authorised server projection. Cursors
+  // expire 24 hours after issue; an expired cursor also receives a compacted
+  // reset rather than historical replay.
+  optional string resume_cursor = 1;
+  // Joined rooms whose timeline windows the client already retains. A fresh
+  // compacted projection includes only these timelines, and resumed delivery
+  // emits timeline mutations only for these rooms. Lightweight room, unread,
+  // notification, and call state remains server-wide. At most 64 room IDs
+  // may be supplied in one subscription.
+  repeated string retained_room_ids = 2;
+}
+
+// Requests lazy materialisation of one joined room's recent timeline.
+//
+// The server replies on the same ordered projection stream with a
+// `room_timeline_replace` operation. Once hydrated, later timeline mutations
+// for the room are included for the lifetime of this connection. Clients send
+// all still-retained room IDs again in their next subscription. A connection
+// may retain at most 64 distinct room IDs; excess requests receive a
+// non-fatal `too_many_retained_rooms` error.
+message RealtimeHydrateRoom {
+  // Joined room to materialise. Repeating a retained room is idempotent.
+  string room_id = 1;
+}
 
-// Confirms event streaming has started.
+// Confirms projection streaming has started.
 message RealtimeSubscribed {
   reserved 1;
   reserved "cursor";
+
+  // Opaque cursor from which this subscription starts. When the request
+  // supplied a resume cursor, this is that validated cursor. Otherwise it is
+  // the current live boundary.
+  optional string start_cursor = 2;
+}
+
+// Confirms that durable replay is complete and live delivery has begun.
+//
+// Clients can retain `cursor` after applying every preceding event. A cursor
+// must not outlive the projection state it describes. Events after this frame
+// are live. Transient events are never replayed. A cursor is usable for up to
+// 24 hours after issue and should be replaced by each later caught-up cursor.
+message RealtimeCaughtUp {
+  // Opaque cursor at the replay-to-live handoff boundary.
+  string cursor = 1;
+}
+
+// One ordered, idempotent mutation of the caller's authorised server view.
+//
+// A fresh subscription starts with a reset operation followed by current
+// resource upserts. A resumed subscription derives operations from EVT after
+// the supplied cursor. Both paths use this envelope and the same client
+// reducer. The stream is a convergence feed, not an immutable audit log:
+// replay uses current authorisation, deletion, and erasure state.
+message RealtimeProjectionEvent {
+  // Stable event ID. Synthetic compacted-replay events use server-generated IDs.
+  string id = 1;
+  // Time the source fact occurred or the compacted event was generated.
+  google.protobuf.Timestamp created_at = 2;
+  // User or system actor that caused the source fact, when known.
+  optional string actor_id = 3;
+  // Opaque cursor safe to retain with the resulting projection state after
+  // applying every operation in this event.
+  optional string resume_cursor = 4;
+  // Operations applied atomically and in order by the client reducer.
+  repeated RealtimeProjectionOperation operations = 5;
+}
+
+// One mutation of a server-scoped client projection.
+message RealtimeProjectionOperation {
+  reserved 13;
+  reserved "notifications_replace";
+
+  oneof operation {
+    // Clears all canonical server projection state before compacted replay.
+    RealtimeProjectionReset reset = 1;
+    // Replaces the public server profile.
+    chatto.api.v1.ServerPublicProfile server_upsert = 2;
+    // Replaces the authenticated viewer resource.
+    chatto.api.v1.GetViewerResponse viewer_upsert = 3;
+    // Adds or replaces one server directory member.
+    chatto.api.v1.DirectoryMember user_upsert = 4;
+    // Removes one server directory member.
+    RealtimeProjectionUserRemove user_remove = 5;
+    // Adds or replaces one room and its viewer state. Channel membership is
+    // complete only after that room's timeline has been materialised.
+    RealtimeProjectionRoom room_upsert = 6;
+    // Removes one room and all locally retained room state.
+    RealtimeProjectionRoomRemove room_remove = 7;
+    // Replaces the complete visible room-group layout.
+    RealtimeProjectionRoomGroupsReplace room_groups_replace = 8;
+    // Replaces the lazily retained recent timeline window for one joined room.
+    RealtimeProjectionRoomTimelineReplace room_timeline_replace = 9;
+    // Adds or replaces one renderable event in a retained room timeline.
+    RealtimeProjectionRoomTimelineEventUpsert room_timeline_event_upsert = 10;
+    // Replaces authenticated server-wide presentation and runtime state.
+    RealtimeProjectionServerState server_state_upsert = 11;
+    // Removes one event from a retained room timeline. This is distinct from
+    // a message tombstone: projection-only channel echoes disappear when the
+    // underlying thread reply is no longer echoed into the room.
+    RealtimeProjectionRoomTimelineEventRemove room_timeline_event_remove = 12;
+    // Replaces one room's current viewer state without
+    // retransmitting room metadata or membership.
+    RealtimeProjectionRoomViewerStateReplace room_viewer_state_replace = 14;
+    // Replaces every active call visible to the caller.
+    RealtimeProjectionActiveCallsReplace active_calls_replace = 15;
+    // Replaces the latest presence status for every server member. Presence
+    // is transient and not replayed from EVT, so every subscription emits
+    // this reconciliation before caught_up.
+    RealtimeProjectionPresencesReplace presences_replace = 16;
+    // Replaces viewer-specific follow and unread state for every followed
+    // thread. Missing entries authoritatively mean not followed and not
+    // unread for retained thread roots.
+    RealtimeProjectionThreadViewerStatesReplace thread_viewer_states_replace = 17;
+    // Signals root-message activity for lightweight room ordering even when
+    // that room's timeline is not retained.
+    RealtimeProjectionRoomActivity room_activity = 18;
+    // Replaces the viewer's current notification-occurrence page and exact
+    // unread occurrence counts. This uses a new top-level operation tag rather
+    // than reinterpreting the released Notifications 1.0 replacement.
+    RealtimeProjectionNotificationOccurrencesReplace notification_occurrences_replace = 19;
+  }
+}
+
+// Reset marker for a compacted projection replay.
+message RealtimeProjectionReset {}
+
+// Authenticated server state required by every signed-in client.
+message RealtimeProjectionServerState {
+  // Optional message of the day shown to authenticated members.
+  optional string motd = 1;
+  // Runtime capabilities and limits advertised to the client.
+  chatto.api.v1.ServerRuntimeConfig runtime = 2;
+  // Live pin transition that caused this replacement, when applicable.
+  // Bootstrap and replay reconciliation omit this field.
+  optional RealtimeProjectionPinnedMessageChange pinned_message_change = 3;
+}
+
+// Content-free pinned-message transition used to refresh room pin stores and
+// drive the client-local unseen indicator.
+message RealtimeProjectionPinnedMessageChange {
+  RealtimeProjectionPinnedMessageAction action = 1;
+  string room_id = 2;
+  string message_event_id = 3;
+}
+
+enum RealtimeProjectionPinnedMessageAction {
+  REALTIME_PROJECTION_PINNED_MESSAGE_ACTION_UNSPECIFIED = 0;
+  REALTIME_PROJECTION_PINNED_MESSAGE_ACTION_CREATED = 1;
+  REALTIME_PROJECTION_PINNED_MESSAGE_ACTION_DELETED = 2;
+}
+
+// Canonical lightweight room state retained by a server-scoped projection.
+message RealtimeProjectionRoom {
+  // Public room metadata and viewer-specific state.
+  chatto.api.v1.RoomWithViewerState room = 1;
+  // Current room membership expressed as references into the projection's
+  // server-wide user directory. DM participants are always present. Channel
+  // membership is empty in a cold room summary and complete after that room's
+  // timeline has been materialised.
+  repeated string member_user_ids = 2;
+  reserved 3;
+  reserved "viewer_notification_count";
+  // Whether this DM room has ever received a root message. False means the
+  // durable room exists only so its initiator can compose the first message.
+  // Once true, message deletion does not reset it. Absent for channel rooms
+  // and servers that predate this field.
+  optional bool has_message_history = 4;
+}
+
+// Root-message activity that affects lightweight room navigation state.
+//
+// This deliberately carries no message content. Retained rooms receive the
+// corresponding timeline mutation separately; unretained rooms still use this
+// operation to keep DM ordering current without materialising their timeline.
+message RealtimeProjectionRoomActivity {
+  string room_id = 1;
+}
+
+// Removes one user from the client projection. Reducers must also purge copied
+// render data for this user from retained rooms, timelines, notifications, and
+// calls while preserving stable IDs on historical facts.
+message RealtimeProjectionUserRemove {
+  // Stable user ID to remove.
+  string user_id = 1;
+}
+
+// Complete latest-value presence state for the projected user directory.
+// Missing map entries are offline; the server normally includes every member
+// explicitly so reducers can converge without retaining an older value.
+message RealtimeProjectionPresencesReplace {
+  map<string, chatto.api.v1.PresenceStatus> statuses = 1;
+}
+
+// Complete current viewer state for followed threads. This reconciles durable
+// follow changes and RUNTIME_STATE read markers that may change while a client
+// is dormant without retransmitting room timelines.
+message RealtimeProjectionThreadViewerStatesReplace {
+  repeated RealtimeProjectionThreadViewerState states = 1;
+}
+
+// Viewer-specific state for one followed thread root.
+message RealtimeProjectionThreadViewerState {
+  string room_id = 1;
+  string thread_root_event_id = 2;
+  chatto.api.v1.ThreadViewerState viewer_state = 3;
+}
+
+// Removes one room from the client projection.
+message RealtimeProjectionRoomRemove {
+  // Stable room ID to remove.
+  string room_id = 1;
+}
+
+// Complete ordered room-group layout visible to the caller.
+message RealtimeProjectionRoomGroupsReplace {
+  // Visible room groups in sidebar order.
+  repeated chatto.api.v1.RoomGroup groups = 1;
+}
+
+// Complete retained recent timeline window for one room.
+message RealtimeProjectionRoomTimelineReplace {
+  // Room whose retained window is replaced.
+  string room_id = 1;
+  // Renderable current timeline state. Deleted and crypto-shredded message
+  // bodies are absent and represented by their normal tombstone fields.
+  chatto.api.v1.RoomTimelinePage page = 2;
+  // Opaque cursor for every retained row, keyed by timeline event ID.
+  map<string, string> event_cursors = 3;
+}
+
+// Current renderable state for one timeline event.
+message RealtimeProjectionRoomTimelineEventUpsert {
+  // Room containing the event.
+  string room_id = 1;
+  // Current renderable event state.
+  chatto.api.v1.RoomTimelineEvent event = 2;
+  // Related users needed to render this event.
+  chatto.api.v1.RoomTimelineIncludes includes = 3;
+  // Reaction transition that caused this upsert, when applicable.
+  optional RealtimeProjectionReactionChange reaction_change = 4;
+  // Preserve a deleted echo row as a tombstone. Directly deleted echoes use a
+  // remove operation instead; this flag identifies canonical-reply deletion.
+  bool retain_deleted_row = 5;
+  // Opaque room-timeline cursor for this canonical row. Clients use retained
+  // row cursors to advance a bounded window without a separate refresh read.
+  string event_cursor = 6;
+}
+
+// Removes one projection-only row from a retained room timeline.
+message RealtimeProjectionRoomTimelineEventRemove {
+  // Room containing the retained row.
+  string room_id = 1;
+  // Stable event ID of the row to remove.
+  string event_id = 2;
+}
+
+// Finite current notification state emitted on bootstrap and every resume.
+message RealtimeProjectionNotificationOccurrencesReplace {
+  // True only when this live replacement should produce a one-shot local
+  // notification sound. Bootstrap and reconciliation replacements are false.
+  bool play_notification_sound = 1;
+  // Authoritative Notifications 2.0 occurrences and exact unread count.
+  chatto.api.v1.ListNotificationOccurrencesResponse occurrences = 2;
+}
+
+// Lightweight current viewer state for one projected room.
+message RealtimeProjectionRoomViewerStateReplace {
+  string room_id = 1;
+  chatto.api.v1.RoomViewerState viewer_state = 2;
+}
+
+// Finite current active-call state visible to the caller.
+message RealtimeProjectionActiveCallsReplace {
+  repeated chatto.api.v1.ActiveCall calls = 1;
+}
+
+// Reaction transition retained for integrations while the containing timeline
+// row carries the authoritative aggregate reaction state.
+message RealtimeProjectionReactionChange {
+  // Whether the actor added or removed the reaction.
+  RealtimeProjectionReactionAction action = 1;
+  // Canonical reacted-to message event ID.
+  string message_event_id = 2;
+  // Reaction emoji.
+  string emoji = 3;
+  // User who changed the reaction.
+  string user_id = 4;
+}
+
+// Kind of reaction transition.
+enum RealtimeProjectionReactionAction {
+  // Unknown action.
+  REALTIME_PROJECTION_REACTION_ACTION_UNSPECIFIED = 0;
+  // A reaction was added.
+  REALTIME_PROJECTION_REACTION_ACTION_ADDED = 1;
+  // A reaction was removed.
+  REALTIME_PROJECTION_REACTION_ACTION_REMOVED = 2;
 }
 
 // Application-level ping.
@@ -111,6 +420,12 @@ message RealtimeError {
   string message = 2;
   // True when the server will close the socket after sending this error.
   bool fatal = 3;
+  // Suggested retry delay for a non-fatal rejected request. Omitted when the
+  // request should not be retried automatically.
+  optional uint32 retry_after_ms = 4;
+  // Room associated with a room-hydration error. Omitted for errors that are
+  // not caused by `hydrate_room`.
+  optional string room_id = 5;
 }
 
 // Close instruction sent before the socket is closed when possible.
@@ -125,15 +440,13 @@ message RealtimeClose {
   uint32 retry_after_ms = 4;
 }
 
-// One authorized live event delivered over the realtime WebSocket.
+// One authorized transient event delivered over the realtime WebSocket.
 //
-// Realtime events are invalidation signals first: payloads carry stable IDs and
-// small inline hints, while durable resource state is hydrated through
-// chatto.api.v1 ConnectRPC services. Event-specific messages document the
-// intended hydration path when a referenced resource may need refreshing.
+// These events have no resume cursor and are never replayed. Durable state is
+// delivered only as RealtimeProjectionEvent operations.
 message RealtimeEventEnvelope {
-  reserved 4;
-  reserved "cursor";
+  reserved 4, 5, 10 to 12, 20 to 21, 40 to 47, 60 to 76, 80 to 89;
+  reserved "cursor", "resume_cursor", "message_posted", "message_edited", "message_retracted", "reaction_added", "reaction_removed", "room_created", "room_updated", "room_deleted", "room_archived", "room_unarchived", "user_joined_room", "user_left_room", "room_universal_changed", "thread_created", "user_custom_status_set", "user_custom_status_cleared", "asset_processing_started", "asset_processing_succeeded", "asset_processing_failed", "asset_deleted", "notification_created", "notification_dismissed", "notification_level_changed", "thread_follow_changed", "room_marked_as_read", "server_updated", "user_profile_updated", "server_user_preferences_updated", "room_groups_updated", "server_member_deleted", "call_started", "call_participant_joined", "call_participant_left", "call_ended", "mention_notification", "new_direct_message_notification";
 
   // Stable event ID.
   string id = 1;
@@ -141,152 +454,22 @@ message RealtimeEventEnvelope {
   google.protobuf.Timestamp created_at = 2;
   // User or system actor that caused the event, when known.
   optional string actor_id = 3;
-
   oneof event {
-    // A message was posted in a room or thread.
-    RealtimeMessagePostedEvent message_posted = 10;
-    // A message body or renderable metadata changed.
-    RealtimeMessageEditedEvent message_edited = 11;
-    // A message was retracted.
-    RealtimeMessageRetractedEvent message_retracted = 12;
-    // A user added a reaction to a message.
-    RealtimeReactionEvent reaction_added = 20;
-    // A user removed a reaction from a message.
-    RealtimeReactionEvent reaction_removed = 21;
-    // A user is typing in a room or thread.
+    // A user is typing in a room or thread that the viewer can read.
     RealtimeTypingEvent user_typing = 30;
     // A user's live presence changed.
     RealtimePresenceChangedEvent presence_changed = 31;
-    // A room was created.
-    RealtimeRoomEvent room_created = 40;
-    // A room's profile or settings changed.
-    RealtimeRoomEvent room_updated = 41;
-    // A room was deleted.
-    RealtimeRoomEvent room_deleted = 42;
-    // A room was archived.
-    RealtimeRoomEvent room_archived = 43;
-    // A room was restored from the archive.
-    RealtimeRoomEvent room_unarchived = 44;
-    // A user joined a room visible to the current user.
-    RealtimeRoomEvent user_joined_room = 45;
-    // A user left a room visible to the current user.
-    RealtimeRoomEvent user_left_room = 46;
-    // A room's universal visibility flag changed.
-    RealtimeRoomUniversalChangedEvent room_universal_changed = 47;
-    // A notification was created for the current user.
-    RealtimeNotificationCreatedEvent notification_created = 60;
-    // A notification was dismissed for the current user.
-    RealtimeNotificationDismissedEvent notification_dismissed = 61;
-    // The current user's notification level changed.
-    RealtimeNotificationLevelChangedEvent notification_level_changed = 62;
-    // The current user's thread follow state changed.
-    RealtimeThreadFollowChangedEvent thread_follow_changed = 63;
-    // The current user's room read state changed.
-    RealtimeRoomMarkedAsReadEvent room_marked_as_read = 64;
-    // A thread was created in a room visible to the current user.
-    RealtimeThreadCreatedEvent thread_created = 65;
-    // The server profile/config changed.
-    RealtimeServerUpdatedEvent server_updated = 70;
-    // A user profile changed.
-    RealtimeUserProfileUpdatedEvent user_profile_updated = 71;
-    // A user's custom status was set or changed.
-    RealtimeUserCustomStatusSetEvent user_custom_status_set = 72;
-    // A user's custom status was cleared.
-    RealtimeUserCustomStatusClearedEvent user_custom_status_cleared = 73;
-    // The current user's display preferences changed.
-    RealtimeServerUserPreferencesUpdatedEvent server_user_preferences_updated = 74;
-    // Room-group layout changed.
-    RealtimeRoomGroupsUpdatedEvent room_groups_updated = 75;
-    // A server member was deleted.
-    RealtimeServerMemberDeletedEvent server_member_deleted = 76;
-    // Asset processing started for a message attachment.
-    RealtimeAssetProcessingEvent asset_processing_started = 80;
-    // Asset processing completed successfully for a message attachment.
-    RealtimeAssetProcessingEvent asset_processing_succeeded = 81;
-    // Asset processing failed for a message attachment.
-    RealtimeAssetProcessingEvent asset_processing_failed = 82;
-    // An asset attached to a message was deleted.
-    RealtimeAssetDeletedEvent asset_deleted = 83;
-    // A voice call started in a room.
-    RealtimeCallEvent call_started = 84;
-    // A participant joined a voice call.
-    RealtimeCallEvent call_participant_joined = 85;
-    // A participant left a voice call.
-    RealtimeCallEvent call_participant_left = 86;
-    // A voice call ended.
-    RealtimeCallEvent call_ended = 87;
-    // The current user was mentioned in a room.
-    RealtimeMentionNotificationEvent mention_notification = 88;
-    // The current user received a new direct message.
-    RealtimeNewDirectMessageNotificationEvent new_direct_message_notification = 89;
     // The current user's session was terminated.
     RealtimeSessionTerminatedEvent session_terminated = 90;
   }
 }
 
-// Message-posted signal.
-//
-// Hydrate the affected timeline with `RoomService.GetRoomEventsAround`
-// for room messages, or `ThreadService.GetThreadEventsAround` when
-// `thread_root_event_id` is set.
-message RealtimeMessagePostedEvent {
-  // Room containing the message.
-  string room_id = 1;
-  // Event ID of the posted message.
-  string message_event_id = 2;
-  // Root message event ID when the message was posted in a thread.
-  optional string thread_root_event_id = 3;
-}
-
-// Message-edited signal.
-//
-// Refresh the affected timeline window with
-// `RoomService.GetRoomEventsAround` for room messages, or
-// `ThreadService.GetThreadEventsAround` when the local message belongs to
-// a thread.
-message RealtimeMessageEditedEvent {
-  // Room containing the message.
-  string room_id = 1;
-  // Event ID of the edited message.
-  string message_event_id = 2;
-}
-
-// Message-retracted signal.
-//
-// Refresh or patch the affected timeline window with
-// `RoomService.GetRoomEventsAround` for room messages, or
-// `ThreadService.GetThreadEventsAround` when the local message belongs to
-// a thread.
-message RealtimeMessageRetractedEvent {
-  // Room containing the message.
-  string room_id = 1;
-  // Event ID of the retracted message.
-  string message_event_id = 2;
-  // Retraction reason, when one was supplied.
-  optional string reason = 3;
-}
-
-// Reaction signal.
-//
-// Refresh or patch the affected message in its timeline window. Use
-// `RoomService.GetRoomEventsAround` for room messages, or
-// `ThreadService.GetThreadEventsAround` when the local message belongs to
-// a thread. `message_event_id` is canonical: reactions made through a channel
-// echo of a thread reply report the original reply event ID. Clients that show
-// channel echoes should also refresh or patch visible echo rows whose
-// `echo_of_event_id` matches this ID.
-message RealtimeReactionEvent {
-  // Room containing the reacted-to message.
-  string room_id = 1;
-  // Canonical event ID of the reacted-to message.
-  string message_event_id = 2;
-  // Reaction emoji.
-  string emoji = 3;
-}
-
 // Typing signal.
 //
-// This is an ephemeral signal. `room_id` and `thread_root_event_id` identify
+// This is an ephemeral signal. The viewer must be a room member. Main-room
+// typing requires `message.read`. Thread typing requires `message.read` or a
+// matching thread relationship with `message.read-interactions`. DM membership
+// authorizes DM typing delivery. `room_id` and `thread_root_event_id` identify
 // where to display typing state; clients normally do not hydrate it.
 message RealtimeTypingEvent {
   // Room where the actor is typing.
@@ -306,277 +489,6 @@ message RealtimePresenceChangedEvent {
   chatto.api.v1.PresenceStatus status = 2;
 }
 
-// Room lifecycle or membership signal.
-//
-// Hydrate visible room state with `RoomDirectoryService.GetRoom` or
-// `RoomDirectoryService.BatchGetRooms`. Unknown/deleted rooms return
-// `NOT_FOUND` from singular reads, hidden rooms return `PERMISSION_DENIED`, and
-// batch reads omit unknown, deleted, hidden, and inaccessible rooms.
-message RealtimeRoomEvent {
-  // Room affected by the event.
-  string room_id = 1;
-}
-
-// Room universal-visibility signal.
-//
-// The latest universal flag is inline. Hydrate the rest of the visible room with
-// `RoomDirectoryService.GetRoom` or `RoomDirectoryService.BatchGetRooms`.
-message RealtimeRoomUniversalChangedEvent {
-  // Room whose universal setting changed.
-  string room_id = 1;
-  // Latest universal setting.
-  bool universal = 2;
-}
-
-// Notification-created signal for the connected user.
-//
-// Hydrate full notification rows with `NotificationService.GetNotification` or
-// `NotificationService.BatchGetNotifications`. Use `RoomService` or
-// `ThreadService` anchor reads when `event_id` or `in_reply_to_id` must be
-// opened in context.
-message RealtimeNotificationCreatedEvent {
-  // Notification ID.
-  string notification_id = 1;
-  // Room associated with the notification, when the notification navigates to a room.
-  optional string room_id = 2;
-  // Event ID associated with the notification, when the notification navigates to an event.
-  optional string event_id = 3;
-  // Parent event ID for reply notifications, when known.
-  optional string in_reply_to_id = 4;
-  // True when the notification should not alert.
-  bool silent = 5;
-}
-
-// Notification-dismissed signal for the connected user.
-//
-// Remove the local notification row by ID. A later singular notification read
-// returns `NOT_FOUND`; batch reads omit dismissed or missing notifications.
-message RealtimeNotificationDismissedEvent {
-  // Notification ID.
-  string notification_id = 1;
-}
-
-// Notification-level signal for the connected user.
-//
-// The updated level is inline. Use
-// `NotificationPreferencesService.GetRoomNotificationPreference` when clients
-// need the complete preference resource.
-message RealtimeNotificationLevelChangedEvent {
-  // Room whose notification level changed.
-  string room_id = 1;
-  // Explicit room notification level.
-  chatto.api.v1.NotificationLevel level = 2;
-  // Effective notification level after defaults are applied.
-  chatto.api.v1.NotificationLevel effective_level = 3;
-}
-
-// Thread-follow signal for the connected user.
-//
-// The updated follow state is inline. Use `ThreadService.GetThreadEvents`
-// or `ThreadService.GetThreadEventsAround` when the thread itself needs
-// refreshing.
-message RealtimeThreadFollowChangedEvent {
-  // Room containing the thread.
-  string room_id = 1;
-  // Root message event ID of the thread.
-  string thread_root_event_id = 2;
-  // True when the user now follows the thread.
-  bool following = 3;
-}
-
-// Thread-created signal.
-//
-// Hydrate the new thread with `ThreadService.GetThreadEventsAround` using
-// `thread_root_event_id` as the anchor.
-message RealtimeThreadCreatedEvent {
-  // Room containing the thread.
-  string room_id = 1;
-  // Root message event ID that identifies the thread.
-  string thread_root_event_id = 2;
-}
-
-// Room-read signal for the connected user.
-//
-// This is a current-user read-state invalidation signal. Clients can patch local
-// unread state for the room; no separate hydration is normally needed.
-message RealtimeRoomMarkedAsReadEvent {
-  // Room marked read.
-  string room_id = 1;
-}
-
-// Server-profile signal.
-//
-// Public server profile fields are inline. Use `ServerDiscoveryService.GetServer`
-// for public metadata and `ServerService.GetMotd` for the authenticated MOTD.
-message RealtimeServerUpdatedEvent {
-  // Server display name.
-  string name = 1;
-  // Server description.
-  string description = 2;
-  // Server logo URL, when configured.
-  optional string logo_url = 3;
-  // Server banner URL, when configured.
-  optional string banner_url = 4;
-}
-
-// User-profile signal.
-//
-// Basic profile fields are inline. Use `UserService.GetUser` or
-// `UserService.BatchGetUsers` for complete user-profile hydration.
-message RealtimeUserProfileUpdatedEvent {
-  // User whose profile changed.
-  string user_id = 1;
-  // Login name.
-  string login = 2;
-  // Display name.
-  string display_name = 3;
-  // Avatar URL, when the user has an avatar.
-  optional string avatar_url = 4;
-}
-
-// User-custom-status set signal.
-//
-// The latest custom status is inline. Use `UserService.GetUser` when
-// clients need to refresh the complete user profile.
-message RealtimeUserCustomStatusSetEvent {
-  // User whose status changed.
-  string user_id = 1;
-  // Emoji part of the status.
-  string emoji = 2;
-  // Text part of the status.
-  string text = 3;
-  // Expiration time, when the status expires automatically.
-  google.protobuf.Timestamp expires_at = 4;
-}
-
-// User-custom-status cleared signal.
-//
-// Clear local custom status for `user_id`. Use `UserService.GetUser`
-// when clients need to refresh the complete user profile.
-message RealtimeUserCustomStatusClearedEvent {
-  // User whose status was cleared.
-  string user_id = 1;
-}
-
-// User display-preferences signal for the connected user.
-//
-// The updated display preferences are inline for the connected user.
-message RealtimeServerUserPreferencesUpdatedEvent {
-  // IANA timezone name, when the user has selected a fixed timezone.
-  optional string timezone = 1;
-  // Preferred time display format.
-  chatto.api.v1.TimeFormat time_format = 2;
-}
-
-// Room-group layout signal.
-//
-// Refetch room-group layout with `RoomDirectoryService.ListRoomGroups`. If a
-// client already knows specific group IDs, it can use
-// `RoomDirectoryService.GetRoomGroup` or
-// `RoomDirectoryService.BatchGetRoomGroups`.
-message RealtimeRoomGroupsUpdatedEvent {
-  // Always true; present for event-bus compatibility.
-  bool changed = 1;
-}
-
-// Server-member deleted signal.
-//
-// Remove or invalidate local member/user rows for `user_id`. Admin clients can
-// hydrate remaining member rows through `AdminUserService.GetMember` or
-// `AdminUserService.BatchGetMembers`.
-message RealtimeServerMemberDeletedEvent {
-  // Deleted user ID.
-  string user_id = 1;
-}
-
-// Asset-processing signal.
-//
-// Message attachments are message-owned subresources. When
-// `message_event_id` is known, refetch message state with
-// `MessageService.GetMessage` or `MessageService.BatchGetMessages`; otherwise
-// refetch the affected attachment list or timeline window.
-message RealtimeAssetProcessingEvent {
-  // Room containing the message that owns the asset, when known.
-  optional string room_id = 1;
-  // Asset ID.
-  string asset_id = 2;
-  // Event ID of the message that owns the asset, when known.
-  optional string message_event_id = 3;
-}
-
-// Asset-deleted signal.
-//
-// Remove local attachment state by `asset_id`. When the owning message is known,
-// clients can refetch that message through `MessageService.GetMessage` or
-// refetch the timeline window.
-message RealtimeAssetDeletedEvent {
-  // Room containing the message that owned the asset, when known.
-  optional string room_id = 1;
-  // Asset ID.
-  string asset_id = 2;
-}
-
-// Voice-call state signal.
-//
-// Hydrate current room-scoped call state with `VoiceCallService.GetActiveCall`
-// or `VoiceCallService.BatchGetActiveCalls`. Compare `call_id` to ignore stale
-// transitions from a previous call in the same room.
-message RealtimeCallEvent {
-  // Room containing the call.
-  string room_id = 1;
-  // Call ID.
-  string call_id = 2;
-  // Source that produced the call transition.
-  RealtimeCallEventSource source = 3;
-}
-
-// Source that produced a realtime voice-call transition.
-enum RealtimeCallEventSource {
-  // No call event source was specified.
-  REALTIME_CALL_EVENT_SOURCE_UNSPECIFIED = 0;
-  // The user explicitly requested the transition through Chatto.
-  REALTIME_CALL_EVENT_SOURCE_USER = 1;
-  // LiveKit reported the transition through a webhook.
-  REALTIME_CALL_EVENT_SOURCE_LIVEKIT = 2;
-  // Chatto reconciled its local state against LiveKit's current room state.
-  REALTIME_CALL_EVENT_SOURCE_RECONCILIATION = 3;
-}
-
-// Mention attention signal for the connected user.
-//
-// Inline names are display hints. Hydrate referenced rooms through
-// `RoomDirectoryService.BatchGetRooms` and users through
-// `UserService.BatchGetUsers` when local caches are missing or stale.
-message RealtimeMentionNotificationEvent {
-  // Room where the mention occurred.
-  string room_id = 1;
-  // Author user ID.
-  string actor_user_id = 2;
-  // Display name of the room where the mention occurred, when hydrated for the caller.
-  optional string room_name = 3;
-  // Display name of the author who mentioned the connected user, when hydrated.
-  optional string actor_display_name = 4;
-}
-
-// New-DM attention signal for the connected user.
-//
-// Inline names and avatar URLs are display hints. Hydrate the DM room through
-// `RoomDirectoryService.GetRoom` or `RoomDirectoryService.BatchGetRooms`, and
-// the sender through `UserService.GetUser` or `UserService.BatchGetUsers` when
-// local caches are missing or stale.
-message RealtimeNewDirectMessageNotificationEvent {
-  // DM room ID.
-  string room_id = 1;
-  // Sender user ID.
-  string sender_id = 2;
-  // Display name of the sender, when hydrated.
-  optional string sender_display_name = 3;
-  // Avatar URL of the sender, when one is available.
-  optional string sender_avatar_url = 4;
-  // Display name for the DM conversation from the connected user's perspective, when hydrated.
-  optional string conversation_name = 5;
-}
-
 // Session-terminated signal for the connected user's current session.
 message RealtimeSessionTerminatedEvent {
   // Termination reason.

+ 1 - 1
pyproject.toml

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

+ 1 - 1
scripts/generate_pb.sh

@@ -63,7 +63,7 @@ echo "Fetching proto sources ..."
 for f in \
     account.proto attachments.proto asset_uploads.proto common.proto \
     external_identities.proto link_previews.proto member_directory.proto \
-    messages.proto message_types.proto notification_preferences.proto \
+    messages.proto message_types.proto \
     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 \

+ 18 - 4
src/chattolib/__init__.py

@@ -32,11 +32,17 @@ from chattolib.types import (
     Message,
     MessageAttachment,
     MessageReaction,
-    Notification,
     NotificationLevel,
+    NotificationMessageReference,
+    NotificationOccurrence,
+    NotificationOccurrencesPage,
+    NotificationPolicy,
     NotificationPreference,
-    NotificationsPage,
+    NotificationRoomUnreadCount,
+    NotificationSignal,
     Page,
+    PinnedMessage,
+    PinnedMessagesPage,
     PresenceStatus,
     ProviderMetadata,
     Role,
@@ -46,6 +52,7 @@ from chattolib.types import (
     RoomGroup,
     RoomKind,
     RoomSummary,
+    RoomThreadingMode,
     RoomViewerState,
     RoomWithViewerState,
     ServerConfig,
@@ -97,11 +104,17 @@ __all__ = [
     "Message",
     "MessageAttachment",
     "MessageReaction",
-    "Notification",
     "NotificationLevel",
+    "NotificationMessageReference",
+    "NotificationOccurrence",
+    "NotificationOccurrencesPage",
+    "NotificationPolicy",
     "NotificationPreference",
-    "NotificationsPage",
+    "NotificationRoomUnreadCount",
+    "NotificationSignal",
     "Page",
+    "PinnedMessage",
+    "PinnedMessagesPage",
     "PresenceStatus",
     "ProviderMetadata",
     "Role",
@@ -111,6 +124,7 @@ __all__ = [
     "RoomGroup",
     "RoomKind",
     "RoomSummary",
+    "RoomThreadingMode",
     "RoomViewerState",
     "RoomWithViewerState",
     "ServerConfig",

+ 3 - 3
src/chattolib/_pb/buf/validate/validate_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: buf/validate/validate.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'buf/validate/validate.proto'
 )

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 4 - 3
src/chattolib/_pb/chatto/admin/v1/diagnostics_pb2.py


+ 3 - 3
src/chattolib/_pb/chatto/admin/v1/event_log_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/admin/v1/event_log.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/admin/v1/event_log.proto'
 )

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 3 - 3
src/chattolib/_pb/chatto/admin/v1/members_pb2.py


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 3 - 3
src/chattolib/_pb/chatto/admin/v1/permissions_pb2.py


+ 3 - 3
src/chattolib/_pb/chatto/admin/v1/roles_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/admin/v1/roles.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/admin/v1/roles.proto'
 )

+ 130 - 0
src/chattolib/_pb/chatto/admin/v1/room_layout_connect.py

@@ -17,6 +17,12 @@ import chatto.admin.v1.room_layout_pb2 as chatto_dot_admin_dot_v1_dot_room__layo
 
 
 class AdminRoomLayoutService(Protocol):
+    async def get_room(self, request: chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomRequest, ctx: RequestContext) -> chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+
+    async def get_room_group(self, request: chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupRequest, ctx: RequestContext) -> chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+
     async def list_room_groups(self, request: chatto_dot_admin_dot_v1_dot_room__layout__pb2.ListRoomGroupsRequest, ctx: RequestContext) -> chatto_dot_admin_dot_v1_dot_room__layout__pb2.ListRoomGroupsResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
@@ -56,6 +62,26 @@ class AdminRoomLayoutServiceASGIApplication(ConnectASGIApplication[AdminRoomLayo
         super().__init__(
             service=service,
             endpoints=lambda svc: {
+                "/chatto.admin.v1.AdminRoomLayoutService/GetRoom": Endpoint.unary(
+                    method=MethodInfo(
+                        name="GetRoom",
+                        service_name="chatto.admin.v1.AdminRoomLayoutService",
+                        input=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomRequest,
+                        output=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=svc.get_room,
+                ),
+                "/chatto.admin.v1.AdminRoomLayoutService/GetRoomGroup": Endpoint.unary(
+                    method=MethodInfo(
+                        name="GetRoomGroup",
+                        service_name="chatto.admin.v1.AdminRoomLayoutService",
+                        input=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupRequest,
+                        output=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=svc.get_room_group,
+                ),
                 "/chatto.admin.v1.AdminRoomLayoutService/ListRoomGroups": Endpoint.unary(
                     method=MethodInfo(
                         name="ListRoomGroups",
@@ -179,6 +205,46 @@ class AdminRoomLayoutServiceASGIApplication(ConnectASGIApplication[AdminRoomLayo
 
 
 class AdminRoomLayoutServiceClient(ConnectClient):
+    async def get_room(
+        self,
+        request: chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="GetRoom",
+                service_name="chatto.admin.v1.AdminRoomLayoutService",
+                input=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomRequest,
+                output=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomResponse,
+                idempotency_level=IdempotencyLevel.UNKNOWN,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
+    async def get_room_group(
+        self,
+        request: chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="GetRoomGroup",
+                service_name="chatto.admin.v1.AdminRoomLayoutService",
+                input=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupRequest,
+                output=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupResponse,
+                idempotency_level=IdempotencyLevel.UNKNOWN,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
     async def list_room_groups(
         self,
         request: chatto_dot_admin_dot_v1_dot_room__layout__pb2.ListRoomGroupsRequest,
@@ -401,6 +467,10 @@ class AdminRoomLayoutServiceClient(ConnectClient):
 
 
 class AdminRoomLayoutServiceSync(Protocol):
+    def get_room(self, request: chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomRequest, ctx: RequestContext) -> chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+    def get_room_group(self, request: chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupRequest, ctx: RequestContext) -> chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
     def list_room_groups(self, request: chatto_dot_admin_dot_v1_dot_room__layout__pb2.ListRoomGroupsRequest, ctx: RequestContext) -> chatto_dot_admin_dot_v1_dot_room__layout__pb2.ListRoomGroupsResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
     def create_room_group(self, request: chatto_dot_admin_dot_v1_dot_room__layout__pb2.CreateRoomGroupRequest, ctx: RequestContext) -> chatto_dot_admin_dot_v1_dot_room__layout__pb2.CreateRoomGroupResponse:
@@ -429,6 +499,26 @@ class AdminRoomLayoutServiceWSGIApplication(ConnectWSGIApplication):
     def __init__(self, service: AdminRoomLayoutServiceSync, interceptors: Iterable[InterceptorSync]=(), read_max_bytes: int | None = None, compressions: Iterable[Compression] | None = None) -> None:
         super().__init__(
             endpoints={
+                "/chatto.admin.v1.AdminRoomLayoutService/GetRoom": EndpointSync.unary(
+                    method=MethodInfo(
+                        name="GetRoom",
+                        service_name="chatto.admin.v1.AdminRoomLayoutService",
+                        input=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomRequest,
+                        output=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=service.get_room,
+                ),
+                "/chatto.admin.v1.AdminRoomLayoutService/GetRoomGroup": EndpointSync.unary(
+                    method=MethodInfo(
+                        name="GetRoomGroup",
+                        service_name="chatto.admin.v1.AdminRoomLayoutService",
+                        input=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupRequest,
+                        output=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=service.get_room_group,
+                ),
                 "/chatto.admin.v1.AdminRoomLayoutService/ListRoomGroups": EndpointSync.unary(
                     method=MethodInfo(
                         name="ListRoomGroups",
@@ -552,6 +642,46 @@ class AdminRoomLayoutServiceWSGIApplication(ConnectWSGIApplication):
 
 
 class AdminRoomLayoutServiceClientSync(ConnectClientSync):
+    def get_room(
+        self,
+        request: chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="GetRoom",
+                service_name="chatto.admin.v1.AdminRoomLayoutService",
+                input=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomRequest,
+                output=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomResponse,
+                idempotency_level=IdempotencyLevel.UNKNOWN,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
+    def get_room_group(
+        self,
+        request: chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="GetRoomGroup",
+                service_name="chatto.admin.v1.AdminRoomLayoutService",
+                input=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupRequest,
+                output=chatto_dot_admin_dot_v1_dot_room__layout__pb2.GetRoomGroupResponse,
+                idempotency_level=IdempotencyLevel.UNKNOWN,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
     def list_room_groups(
         self,
         request: chatto_dot_admin_dot_v1_dot_room__layout__pb2.ListRoomGroupsRequest,

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 3 - 3
src/chattolib/_pb/chatto/admin/v1/room_layout_pb2.py


+ 3 - 3
src/chattolib/_pb/chatto/admin/v1/server_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/admin/v1/server.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/admin/v1/server.proto'
 )

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

@@ -91,7 +91,7 @@ class MyAccountServiceASGIApplication(ConnectASGIApplication[MyAccountService]):
                         service_name="chatto.api.v1.MyAccountService",
                         input=chatto_dot_api_dot_v1_dot_account__pb2.DeleteAvatarRequest,
                         output=chatto_dot_api_dot_v1_dot_account__pb2.DeleteAvatarResponse,
-                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
                     ),
                     function=svc.delete_avatar,
                 ),
@@ -171,7 +171,7 @@ class MyAccountServiceASGIApplication(ConnectASGIApplication[MyAccountService]):
                         service_name="chatto.api.v1.MyAccountService",
                         input=chatto_dot_api_dot_v1_dot_user__status__pb2.DeleteCustomStatusRequest,
                         output=chatto_dot_api_dot_v1_dot_user__status__pb2.DeleteCustomStatusResponse,
-                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
                     ),
                     function=svc.delete_custom_status,
                 ),
@@ -262,7 +262,7 @@ class MyAccountServiceClient(ConnectClient):
                 service_name="chatto.api.v1.MyAccountService",
                 input=chatto_dot_api_dot_v1_dot_account__pb2.DeleteAvatarRequest,
                 output=chatto_dot_api_dot_v1_dot_account__pb2.DeleteAvatarResponse,
-                idempotency_level=IdempotencyLevel.UNKNOWN,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
@@ -422,7 +422,7 @@ class MyAccountServiceClient(ConnectClient):
                 service_name="chatto.api.v1.MyAccountService",
                 input=chatto_dot_api_dot_v1_dot_user__status__pb2.DeleteCustomStatusRequest,
                 output=chatto_dot_api_dot_v1_dot_user__status__pb2.DeleteCustomStatusResponse,
-                idempotency_level=IdempotencyLevel.UNKNOWN,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
@@ -528,7 +528,7 @@ class MyAccountServiceWSGIApplication(ConnectWSGIApplication):
                         service_name="chatto.api.v1.MyAccountService",
                         input=chatto_dot_api_dot_v1_dot_account__pb2.DeleteAvatarRequest,
                         output=chatto_dot_api_dot_v1_dot_account__pb2.DeleteAvatarResponse,
-                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
                     ),
                     function=service.delete_avatar,
                 ),
@@ -608,7 +608,7 @@ class MyAccountServiceWSGIApplication(ConnectWSGIApplication):
                         service_name="chatto.api.v1.MyAccountService",
                         input=chatto_dot_api_dot_v1_dot_user__status__pb2.DeleteCustomStatusRequest,
                         output=chatto_dot_api_dot_v1_dot_user__status__pb2.DeleteCustomStatusResponse,
-                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
                     ),
                     function=service.delete_custom_status,
                 ),
@@ -699,7 +699,7 @@ class MyAccountServiceClientSync(ConnectClientSync):
                 service_name="chatto.api.v1.MyAccountService",
                 input=chatto_dot_api_dot_v1_dot_account__pb2.DeleteAvatarRequest,
                 output=chatto_dot_api_dot_v1_dot_account__pb2.DeleteAvatarResponse,
-                idempotency_level=IdempotencyLevel.UNKNOWN,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
@@ -859,7 +859,7 @@ class MyAccountServiceClientSync(ConnectClientSync):
                 service_name="chatto.api.v1.MyAccountService",
                 input=chatto_dot_api_dot_v1_dot_user__status__pb2.DeleteCustomStatusRequest,
                 output=chatto_dot_api_dot_v1_dot_user__status__pb2.DeleteCustomStatusResponse,
-                idempotency_level=IdempotencyLevel.UNKNOWN,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
             ),
             headers=headers,
             timeout_ms=timeout_ms,

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 4 - 3
src/chattolib/_pb/chatto/api/v1/account_pb2.py


+ 3 - 3
src/chattolib/_pb/chatto/api/v1/asset_uploads_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/asset_uploads.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/asset_uploads.proto'
 )

+ 3 - 3
src/chattolib/_pb/chatto/api/v1/attachments_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/attachments.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/attachments.proto'
 )

+ 10 - 8
src/chattolib/_pb/chatto/api/v1/common_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/common.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/common.proto'
 )
@@ -25,7 +25,7 @@ _sym_db = _symbol_database.Default()
 from buf.validate import validate_pb2 as buf_dot_validate_dot_validate__pb2
 
 
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1a\x63hatto/api/v1/common.proto\x12\rchatto.api.v1\x1a\x1b\x62uf/validate/validate.proto\"\x82\x01\n\x15ImageTransformOptions\x12\x19\n\x05width\x18\x01 \x01(\x05\x42\n\xbaH\x07\x1a\x05\x18\x80 (\x01\x12\x1a\n\x06height\x18\x02 \x01(\x05\x42\n\xbaH\x07\x1a\x05\x18\x80 (\x01\x12\x32\n\x03\x66it\x18\x03 \x01(\x0e\x32\x1b.chatto.api.v1.ImageFitModeB\x08\xbaH\x05\x82\x01\x02\x10\x01\"D\n\x0bImageUpload\x12\r\n\x05image\x18\x01 \x01(\x0c\x12\x10\n\x08\x66ilename\x18\x02 \x01(\t\x12\x14\n\x0c\x63ontent_type\x18\x03 \x01(\t\"N\n\x10ProviderMetadata\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\r\n\x05label\x18\x03 \x01(\t\x12\x11\n\tlogin_url\x18\x04 \x01(\t*d\n\x0cImageFitMode\x12\x1e\n\x1aIMAGE_FIT_MODE_UNSPECIFIED\x10\x00\x12\x1a\n\x16IMAGE_FIT_MODE_CONTAIN\x10\x01\x12\x18\n\x14IMAGE_FIT_MODE_COVER\x10\x02\x62\x06proto3')
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1a\x63hatto/api/v1/common.proto\x12\rchatto.api.v1\x1a\x1b\x62uf/validate/validate.proto\"\x82\x01\n\x15ImageTransformOptions\x12\x19\n\x05width\x18\x01 \x01(\x05\x42\n\xbaH\x07\x1a\x05\x18\x80 (\x01\x12\x1a\n\x06height\x18\x02 \x01(\x05\x42\n\xbaH\x07\x1a\x05\x18\x80 (\x01\x12\x32\n\x03\x66it\x18\x03 \x01(\x0e\x32\x1b.chatto.api.v1.ImageFitModeB\x08\xbaH\x05\x82\x01\x02\x10\x01\"D\n\x0bImageUpload\x12\r\n\x05image\x18\x01 \x01(\x0c\x12\x10\n\x08\x66ilename\x18\x02 \x01(\t\x12\x14\n\x0c\x63ontent_type\x18\x03 \x01(\t\"\xa6\x01\n\x10ProviderMetadata\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\r\n\x05label\x18\x03 \x01(\t\x12\x11\n\tlogin_url\x18\x04 \x01(\t\x12\x17\n\nissuer_url\x18\x05 \x01(\tH\x00\x88\x01\x01\x12\x1b\n\x0e\x61uto_provision\x18\x06 \x01(\x08H\x01\x88\x01\x01\x42\r\n\x0b_issuer_urlB\x11\n\x0f_auto_provision*d\n\x0cImageFitMode\x12\x1e\n\x1aIMAGE_FIT_MODE_UNSPECIFIED\x10\x00\x12\x1a\n\x16IMAGE_FIT_MODE_CONTAIN\x10\x01\x12\x18\n\x14IMAGE_FIT_MODE_COVER\x10\x02*\xc1\x01\n\x11RoomThreadingMode\x12#\n\x1fROOM_THREADING_MODE_UNSPECIFIED\x10\x00\x12 \n\x1cROOM_THREADING_MODE_REQUIRED\x10\x01\x12\"\n\x1eROOM_THREADING_MODE_ENCOURAGED\x10\x02\x12\x1f\n\x1bROOM_THREADING_MODE_ENABLED\x10\x03\x12 \n\x1cROOM_THREADING_MODE_DISABLED\x10\x04\x62\x06proto3')
 
 _globals = globals()
 _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -38,12 +38,14 @@ if not _descriptor._USE_C_DESCRIPTORS:
   _globals['_IMAGETRANSFORMOPTIONS'].fields_by_name['height']._serialized_options = b'\272H\007\032\005\030\200 (\001'
   _globals['_IMAGETRANSFORMOPTIONS'].fields_by_name['fit']._loaded_options = None
   _globals['_IMAGETRANSFORMOPTIONS'].fields_by_name['fit']._serialized_options = b'\272H\005\202\001\002\020\001'
-  _globals['_IMAGEFITMODE']._serialized_start=357
-  _globals['_IMAGEFITMODE']._serialized_end=457
+  _globals['_IMAGEFITMODE']._serialized_start=446
+  _globals['_IMAGEFITMODE']._serialized_end=546
+  _globals['_ROOMTHREADINGMODE']._serialized_start=549
+  _globals['_ROOMTHREADINGMODE']._serialized_end=742
   _globals['_IMAGETRANSFORMOPTIONS']._serialized_start=75
   _globals['_IMAGETRANSFORMOPTIONS']._serialized_end=205
   _globals['_IMAGEUPLOAD']._serialized_start=207
   _globals['_IMAGEUPLOAD']._serialized_end=275
-  _globals['_PROVIDERMETADATA']._serialized_start=277
-  _globals['_PROVIDERMETADATA']._serialized_end=355
+  _globals['_PROVIDERMETADATA']._serialized_start=278
+  _globals['_PROVIDERMETADATA']._serialized_end=444
 # @@protoc_insertion_point(module_scope)

+ 3 - 3
src/chattolib/_pb/chatto/api/v1/external_identities_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/external_identities.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/external_identities.proto'
 )

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 4 - 4
src/chattolib/_pb/chatto/api/v1/link_previews_pb2.py


+ 3 - 3
src/chattolib/_pb/chatto/api/v1/member_directory_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/member_directory.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/member_directory.proto'
 )

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 3 - 3
src/chattolib/_pb/chatto/api/v1/message_types_pb2.py


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 3 - 3
src/chattolib/_pb/chatto/api/v1/messages_pb2.py


+ 3 - 3
src/chattolib/_pb/chatto/api/v1/notification_preferences_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/notification_preferences.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/notification_preferences.proto'
 )

+ 504 - 202
src/chattolib/_pb/chatto/api/v1/notifications_connect.py

@@ -17,28 +17,31 @@ import chatto.api.v1.notifications_pb2 as chatto_dot_api_dot_v1_dot_notification
 
 
 class NotificationService(Protocol):
-    async def list_notifications(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsResponse:
+    async def get_notification_occurrence(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
-    async def get_notification(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationResponse:
+    async def batch_get_notification_occurrences(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
-    async def batch_get_notifications(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsResponse:
+    async def list_notification_occurrences(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
-    async def list_room_notifications(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsResponse:
+    async def mark_notification_read(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
-    async def list_room_notification_counts(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsResponse:
+    async def delete_notification_occurrence(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
-    async def has_notifications(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsResponse:
+    async def batch_delete_notification_occurrences(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
-    async def dismiss_notification(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationResponse:
+    async def delete_all_notification_occurrences(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
-    async def dismiss_all_notifications(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsResponse:
+    async def get_notification_policy(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+
+    async def update_notification_policy(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
 
@@ -47,85 +50,95 @@ class NotificationServiceASGIApplication(ConnectASGIApplication[NotificationServ
         super().__init__(
             service=service,
             endpoints=lambda svc: {
-                "/chatto.api.v1.NotificationService/ListNotifications": Endpoint.unary(
+                "/chatto.api.v1.NotificationService/GetNotificationOccurrence": Endpoint.unary(
                     method=MethodInfo(
-                        name="ListNotifications",
+                        name="GetNotificationOccurrence",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsResponse,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceResponse,
                         idempotency_level=IdempotencyLevel.UNKNOWN,
                     ),
-                    function=svc.list_notifications,
+                    function=svc.get_notification_occurrence,
                 ),
-                "/chatto.api.v1.NotificationService/GetNotification": Endpoint.unary(
+                "/chatto.api.v1.NotificationService/BatchGetNotificationOccurrences": Endpoint.unary(
                     method=MethodInfo(
-                        name="GetNotification",
+                        name="BatchGetNotificationOccurrences",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationResponse,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesResponse,
                         idempotency_level=IdempotencyLevel.UNKNOWN,
                     ),
-                    function=svc.get_notification,
+                    function=svc.batch_get_notification_occurrences,
                 ),
-                "/chatto.api.v1.NotificationService/BatchGetNotifications": Endpoint.unary(
+                "/chatto.api.v1.NotificationService/ListNotificationOccurrences": Endpoint.unary(
                     method=MethodInfo(
-                        name="BatchGetNotifications",
+                        name="ListNotificationOccurrences",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsResponse,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesResponse,
                         idempotency_level=IdempotencyLevel.UNKNOWN,
                     ),
-                    function=svc.batch_get_notifications,
+                    function=svc.list_notification_occurrences,
                 ),
-                "/chatto.api.v1.NotificationService/ListRoomNotifications": Endpoint.unary(
+                "/chatto.api.v1.NotificationService/MarkNotificationRead": Endpoint.unary(
                     method=MethodInfo(
-                        name="ListRoomNotifications",
+                        name="MarkNotificationRead",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsResponse,
-                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadResponse,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
                     ),
-                    function=svc.list_room_notifications,
+                    function=svc.mark_notification_read,
                 ),
-                "/chatto.api.v1.NotificationService/ListRoomNotificationCounts": Endpoint.unary(
+                "/chatto.api.v1.NotificationService/DeleteNotificationOccurrence": Endpoint.unary(
                     method=MethodInfo(
-                        name="ListRoomNotificationCounts",
+                        name="DeleteNotificationOccurrence",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsResponse,
-                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceResponse,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
                     ),
-                    function=svc.list_room_notification_counts,
+                    function=svc.delete_notification_occurrence,
                 ),
-                "/chatto.api.v1.NotificationService/HasNotifications": Endpoint.unary(
+                "/chatto.api.v1.NotificationService/BatchDeleteNotificationOccurrences": Endpoint.unary(
                     method=MethodInfo(
-                        name="HasNotifications",
+                        name="BatchDeleteNotificationOccurrences",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsResponse,
-                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesResponse,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
                     ),
-                    function=svc.has_notifications,
+                    function=svc.batch_delete_notification_occurrences,
                 ),
-                "/chatto.api.v1.NotificationService/DismissNotification": Endpoint.unary(
+                "/chatto.api.v1.NotificationService/DeleteAllNotificationOccurrences": Endpoint.unary(
                     method=MethodInfo(
-                        name="DismissNotification",
+                        name="DeleteAllNotificationOccurrences",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationResponse,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesResponse,
                         idempotency_level=IdempotencyLevel.UNKNOWN,
                     ),
-                    function=svc.dismiss_notification,
+                    function=svc.delete_all_notification_occurrences,
                 ),
-                "/chatto.api.v1.NotificationService/DismissAllNotifications": Endpoint.unary(
+                "/chatto.api.v1.NotificationService/GetNotificationPolicy": Endpoint.unary(
                     method=MethodInfo(
-                        name="DismissAllNotifications",
+                        name="GetNotificationPolicy",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsResponse,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyResponse,
                         idempotency_level=IdempotencyLevel.UNKNOWN,
                     ),
-                    function=svc.dismiss_all_notifications,
+                    function=svc.get_notification_policy,
+                ),
+                "/chatto.api.v1.NotificationService/UpdateNotificationPolicy": Endpoint.unary(
+                    method=MethodInfo(
+                        name="UpdateNotificationPolicy",
+                        service_name="chatto.api.v1.NotificationService",
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyResponse,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
+                    ),
+                    function=svc.update_notification_policy,
                 ),
             },
             interceptors=interceptors,
@@ -140,183 +153,326 @@ class NotificationServiceASGIApplication(ConnectASGIApplication[NotificationServ
 
 
 class NotificationServiceClient(ConnectClient):
-    async def list_notifications(
+    async def get_notification_occurrence(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="ListNotifications",
+                name="GetNotificationOccurrence",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsResponse,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceResponse,
                 idempotency_level=IdempotencyLevel.UNKNOWN,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
-    async def get_notification(
+    async def batch_get_notification_occurrences(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="GetNotification",
+                name="BatchGetNotificationOccurrences",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationResponse,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesResponse,
                 idempotency_level=IdempotencyLevel.UNKNOWN,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
-    async def batch_get_notifications(
+    async def list_notification_occurrences(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="BatchGetNotifications",
+                name="ListNotificationOccurrences",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsResponse,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesResponse,
                 idempotency_level=IdempotencyLevel.UNKNOWN,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
-    async def list_room_notifications(
+    async def mark_notification_read(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="ListRoomNotifications",
+                name="MarkNotificationRead",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsResponse,
-                idempotency_level=IdempotencyLevel.UNKNOWN,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadResponse,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
-    async def list_room_notification_counts(
+    async def delete_notification_occurrence(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="ListRoomNotificationCounts",
+                name="DeleteNotificationOccurrence",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsResponse,
-                idempotency_level=IdempotencyLevel.UNKNOWN,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceResponse,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
+    async def batch_delete_notification_occurrences(
+        self,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="BatchDeleteNotificationOccurrences",
+                service_name="chatto.api.v1.NotificationService",
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesResponse,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
-    async def has_notifications(
+    async def delete_all_notification_occurrences(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="HasNotifications",
+                name="DeleteAllNotificationOccurrences",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsResponse,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesResponse,
                 idempotency_level=IdempotencyLevel.UNKNOWN,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
-    async def dismiss_notification(
+    async def get_notification_policy(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="DismissNotification",
+                name="GetNotificationPolicy",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationResponse,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyResponse,
                 idempotency_level=IdempotencyLevel.UNKNOWN,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
-    async def dismiss_all_notifications(
+    async def update_notification_policy(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="DismissAllNotifications",
+                name="UpdateNotificationPolicy",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsResponse,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyResponse,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
+
+
+class NotificationPolicyService(Protocol):
+    async def get_notification_policy(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+
+    async def batch_get_notification_policies(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+
+    async def update_notification_policy(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+
+
+class NotificationPolicyServiceASGIApplication(ConnectASGIApplication[NotificationPolicyService]):
+    def __init__(self, service: NotificationPolicyService | AsyncGenerator[NotificationPolicyService], *, interceptors: Iterable[Interceptor]=(), read_max_bytes: int | None = None, compressions: Iterable[Compression] | None = None) -> None:
+        super().__init__(
+            service=service,
+            endpoints=lambda svc: {
+                "/chatto.api.v1.NotificationPolicyService/GetNotificationPolicy": Endpoint.unary(
+                    method=MethodInfo(
+                        name="GetNotificationPolicy",
+                        service_name="chatto.api.v1.NotificationPolicyService",
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=svc.get_notification_policy,
+                ),
+                "/chatto.api.v1.NotificationPolicyService/BatchGetNotificationPolicies": Endpoint.unary(
+                    method=MethodInfo(
+                        name="BatchGetNotificationPolicies",
+                        service_name="chatto.api.v1.NotificationPolicyService",
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=svc.batch_get_notification_policies,
+                ),
+                "/chatto.api.v1.NotificationPolicyService/UpdateNotificationPolicy": Endpoint.unary(
+                    method=MethodInfo(
+                        name="UpdateNotificationPolicy",
+                        service_name="chatto.api.v1.NotificationPolicyService",
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyResponse,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
+                    ),
+                    function=svc.update_notification_policy,
+                ),
+            },
+            interceptors=interceptors,
+            read_max_bytes=read_max_bytes,
+            compressions=compressions,
+        )
+
+    @property
+    def path(self) -> str:
+        """Returns the URL path to mount the application to when serving multiple applications."""
+        return "/chatto.api.v1.NotificationPolicyService"
+
+
+class NotificationPolicyServiceClient(ConnectClient):
+    async def get_notification_policy(
+        self,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="GetNotificationPolicy",
+                service_name="chatto.api.v1.NotificationPolicyService",
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyResponse,
                 idempotency_level=IdempotencyLevel.UNKNOWN,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
+    async def batch_get_notification_policies(
+        self,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="BatchGetNotificationPolicies",
+                service_name="chatto.api.v1.NotificationPolicyService",
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesResponse,
+                idempotency_level=IdempotencyLevel.UNKNOWN,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
+    async def update_notification_policy(
+        self,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="UpdateNotificationPolicy",
+                service_name="chatto.api.v1.NotificationPolicyService",
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyResponse,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
 
 class NotificationServiceSync(Protocol):
-    def list_notifications(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsResponse:
+    def get_notification_occurrence(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
-    def get_notification(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationResponse:
+    def batch_get_notification_occurrences(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
-    def batch_get_notifications(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsResponse:
+    def list_notification_occurrences(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
-    def list_room_notifications(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsResponse:
+    def mark_notification_read(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
-    def list_room_notification_counts(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsResponse:
+    def delete_notification_occurrence(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
-    def has_notifications(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsResponse:
+    def batch_delete_notification_occurrences(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
-    def dismiss_notification(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationResponse:
+    def delete_all_notification_occurrences(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
-    def dismiss_all_notifications(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsResponse:
+    def get_notification_policy(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+    def update_notification_policy(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
 
@@ -324,85 +480,95 @@ class NotificationServiceWSGIApplication(ConnectWSGIApplication):
     def __init__(self, service: NotificationServiceSync, interceptors: Iterable[InterceptorSync]=(), read_max_bytes: int | None = None, compressions: Iterable[Compression] | None = None) -> None:
         super().__init__(
             endpoints={
-                "/chatto.api.v1.NotificationService/ListNotifications": EndpointSync.unary(
+                "/chatto.api.v1.NotificationService/GetNotificationOccurrence": EndpointSync.unary(
                     method=MethodInfo(
-                        name="ListNotifications",
+                        name="GetNotificationOccurrence",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsResponse,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceResponse,
                         idempotency_level=IdempotencyLevel.UNKNOWN,
                     ),
-                    function=service.list_notifications,
+                    function=service.get_notification_occurrence,
                 ),
-                "/chatto.api.v1.NotificationService/GetNotification": EndpointSync.unary(
+                "/chatto.api.v1.NotificationService/BatchGetNotificationOccurrences": EndpointSync.unary(
                     method=MethodInfo(
-                        name="GetNotification",
+                        name="BatchGetNotificationOccurrences",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationResponse,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesResponse,
                         idempotency_level=IdempotencyLevel.UNKNOWN,
                     ),
-                    function=service.get_notification,
+                    function=service.batch_get_notification_occurrences,
                 ),
-                "/chatto.api.v1.NotificationService/BatchGetNotifications": EndpointSync.unary(
+                "/chatto.api.v1.NotificationService/ListNotificationOccurrences": EndpointSync.unary(
                     method=MethodInfo(
-                        name="BatchGetNotifications",
+                        name="ListNotificationOccurrences",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsResponse,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesResponse,
                         idempotency_level=IdempotencyLevel.UNKNOWN,
                     ),
-                    function=service.batch_get_notifications,
+                    function=service.list_notification_occurrences,
                 ),
-                "/chatto.api.v1.NotificationService/ListRoomNotifications": EndpointSync.unary(
+                "/chatto.api.v1.NotificationService/MarkNotificationRead": EndpointSync.unary(
                     method=MethodInfo(
-                        name="ListRoomNotifications",
+                        name="MarkNotificationRead",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsResponse,
-                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadResponse,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
                     ),
-                    function=service.list_room_notifications,
+                    function=service.mark_notification_read,
                 ),
-                "/chatto.api.v1.NotificationService/ListRoomNotificationCounts": EndpointSync.unary(
+                "/chatto.api.v1.NotificationService/DeleteNotificationOccurrence": EndpointSync.unary(
                     method=MethodInfo(
-                        name="ListRoomNotificationCounts",
+                        name="DeleteNotificationOccurrence",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsResponse,
-                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceResponse,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
                     ),
-                    function=service.list_room_notification_counts,
+                    function=service.delete_notification_occurrence,
                 ),
-                "/chatto.api.v1.NotificationService/HasNotifications": EndpointSync.unary(
+                "/chatto.api.v1.NotificationService/BatchDeleteNotificationOccurrences": EndpointSync.unary(
                     method=MethodInfo(
-                        name="HasNotifications",
+                        name="BatchDeleteNotificationOccurrences",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsResponse,
-                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesResponse,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
                     ),
-                    function=service.has_notifications,
+                    function=service.batch_delete_notification_occurrences,
                 ),
-                "/chatto.api.v1.NotificationService/DismissNotification": EndpointSync.unary(
+                "/chatto.api.v1.NotificationService/DeleteAllNotificationOccurrences": EndpointSync.unary(
                     method=MethodInfo(
-                        name="DismissNotification",
+                        name="DeleteAllNotificationOccurrences",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationResponse,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesResponse,
                         idempotency_level=IdempotencyLevel.UNKNOWN,
                     ),
-                    function=service.dismiss_notification,
+                    function=service.delete_all_notification_occurrences,
                 ),
-                "/chatto.api.v1.NotificationService/DismissAllNotifications": EndpointSync.unary(
+                "/chatto.api.v1.NotificationService/GetNotificationPolicy": EndpointSync.unary(
                     method=MethodInfo(
-                        name="DismissAllNotifications",
+                        name="GetNotificationPolicy",
                         service_name="chatto.api.v1.NotificationService",
-                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsRequest,
-                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsResponse,
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyResponse,
                         idempotency_level=IdempotencyLevel.UNKNOWN,
                     ),
-                    function=service.dismiss_all_notifications,
+                    function=service.get_notification_policy,
+                ),
+                "/chatto.api.v1.NotificationService/UpdateNotificationPolicy": EndpointSync.unary(
+                    method=MethodInfo(
+                        name="UpdateNotificationPolicy",
+                        service_name="chatto.api.v1.NotificationService",
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyResponse,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
+                    ),
+                    function=service.update_notification_policy,
                 ),
             },
             interceptors=interceptors,
@@ -417,162 +583,298 @@ class NotificationServiceWSGIApplication(ConnectWSGIApplication):
 
 
 class NotificationServiceClientSync(ConnectClientSync):
-    def list_notifications(
+    def get_notification_occurrence(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="ListNotifications",
+                name="GetNotificationOccurrence",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationsResponse,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationOccurrenceResponse,
                 idempotency_level=IdempotencyLevel.UNKNOWN,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
-    def get_notification(
+    def batch_get_notification_occurrences(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="GetNotification",
+                name="BatchGetNotificationOccurrences",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationResponse,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationOccurrencesResponse,
                 idempotency_level=IdempotencyLevel.UNKNOWN,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
-    def batch_get_notifications(
+    def list_notification_occurrences(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="BatchGetNotifications",
+                name="ListNotificationOccurrences",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationsResponse,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListNotificationOccurrencesResponse,
                 idempotency_level=IdempotencyLevel.UNKNOWN,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
-    def list_room_notifications(
+    def mark_notification_read(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="ListRoomNotifications",
+                name="MarkNotificationRead",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationsResponse,
-                idempotency_level=IdempotencyLevel.UNKNOWN,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.MarkNotificationReadResponse,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
-    def list_room_notification_counts(
+    def delete_notification_occurrence(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="ListRoomNotificationCounts",
+                name="DeleteNotificationOccurrence",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.ListRoomNotificationCountsResponse,
-                idempotency_level=IdempotencyLevel.UNKNOWN,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteNotificationOccurrenceResponse,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
+    def batch_delete_notification_occurrences(
+        self,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="BatchDeleteNotificationOccurrences",
+                service_name="chatto.api.v1.NotificationService",
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchDeleteNotificationOccurrencesResponse,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
-    def has_notifications(
+    def delete_all_notification_occurrences(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="HasNotifications",
+                name="DeleteAllNotificationOccurrences",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.HasNotificationsResponse,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.DeleteAllNotificationOccurrencesResponse,
                 idempotency_level=IdempotencyLevel.UNKNOWN,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
-    def dismiss_notification(
+    def get_notification_policy(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="DismissNotification",
+                name="GetNotificationPolicy",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissNotificationResponse,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.GetNotificationPolicyResponse,
                 idempotency_level=IdempotencyLevel.UNKNOWN,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
 
-    def dismiss_all_notifications(
+    def update_notification_policy(
         self,
-        request: chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsRequest,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyRequest,
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
-    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsResponse:
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
-                name="DismissAllNotifications",
+                name="UpdateNotificationPolicy",
                 service_name="chatto.api.v1.NotificationService",
-                input=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsRequest,
-                output=chatto_dot_api_dot_v1_dot_notifications__pb2.DismissAllNotificationsResponse,
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.UpdateNotificationPolicyResponse,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
+class NotificationPolicyServiceSync(Protocol):
+    def get_notification_policy(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+    def batch_get_notification_policies(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+    def update_notification_policy(self, request: chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+
+
+class NotificationPolicyServiceWSGIApplication(ConnectWSGIApplication):
+    def __init__(self, service: NotificationPolicyServiceSync, interceptors: Iterable[InterceptorSync]=(), read_max_bytes: int | None = None, compressions: Iterable[Compression] | None = None) -> None:
+        super().__init__(
+            endpoints={
+                "/chatto.api.v1.NotificationPolicyService/GetNotificationPolicy": EndpointSync.unary(
+                    method=MethodInfo(
+                        name="GetNotificationPolicy",
+                        service_name="chatto.api.v1.NotificationPolicyService",
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=service.get_notification_policy,
+                ),
+                "/chatto.api.v1.NotificationPolicyService/BatchGetNotificationPolicies": EndpointSync.unary(
+                    method=MethodInfo(
+                        name="BatchGetNotificationPolicies",
+                        service_name="chatto.api.v1.NotificationPolicyService",
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=service.batch_get_notification_policies,
+                ),
+                "/chatto.api.v1.NotificationPolicyService/UpdateNotificationPolicy": EndpointSync.unary(
+                    method=MethodInfo(
+                        name="UpdateNotificationPolicy",
+                        service_name="chatto.api.v1.NotificationPolicyService",
+                        input=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyRequest,
+                        output=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyResponse,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
+                    ),
+                    function=service.update_notification_policy,
+                ),
+            },
+            interceptors=interceptors,
+            read_max_bytes=read_max_bytes,
+            compressions=compressions,
+        )
+
+    @property
+    def path(self) -> str:
+        """Returns the URL path to mount the application to when serving multiple applications."""
+        return "/chatto.api.v1.NotificationPolicyService"
+
+
+class NotificationPolicyServiceClientSync(ConnectClientSync):
+    def get_notification_policy(
+        self,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="GetNotificationPolicy",
+                service_name="chatto.api.v1.NotificationPolicyService",
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceGetNotificationPolicyResponse,
+                idempotency_level=IdempotencyLevel.UNKNOWN,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
+    def batch_get_notification_policies(
+        self,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="BatchGetNotificationPolicies",
+                service_name="chatto.api.v1.NotificationPolicyService",
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.BatchGetNotificationPoliciesResponse,
                 idempotency_level=IdempotencyLevel.UNKNOWN,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
         )
+
+    def update_notification_policy(
+        self,
+        request: chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="UpdateNotificationPolicy",
+                service_name="chatto.api.v1.NotificationPolicyService",
+                input=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyRequest,
+                output=chatto_dot_api_dot_v1_dot_notifications__pb2.NotificationPolicyServiceUpdateNotificationPolicyResponse,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 6 - 3
src/chattolib/_pb/chatto/api/v1/notifications_pb2.py


+ 3 - 3
src/chattolib/_pb/chatto/api/v1/pagination_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/pagination.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/pagination.proto'
 )

+ 3 - 3
src/chattolib/_pb/chatto/api/v1/permissions_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/permissions.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/permissions.proto'
 )

+ 3 - 3
src/chattolib/_pb/chatto/api/v1/presence_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/presence.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/presence.proto'
 )

+ 67 - 2
src/chattolib/_pb/chatto/api/v1/push_notifications_connect.py

@@ -23,6 +23,9 @@ class PushNotificationService(Protocol):
     async def unsubscribe(self, request: chatto_dot_api_dot_v1_dot_push__notifications__pb2.UnsubscribePushRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_push__notifications__pb2.UnsubscribePushResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
+    async def send_test_notification(self, request: chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+
 
 class PushNotificationServiceASGIApplication(ConnectASGIApplication[PushNotificationService]):
     def __init__(self, service: PushNotificationService | AsyncGenerator[PushNotificationService], *, interceptors: Iterable[Interceptor]=(), read_max_bytes: int | None = None, compressions: Iterable[Compression] | None = None) -> None:
@@ -45,10 +48,20 @@ class PushNotificationServiceASGIApplication(ConnectASGIApplication[PushNotifica
                         service_name="chatto.api.v1.PushNotificationService",
                         input=chatto_dot_api_dot_v1_dot_push__notifications__pb2.UnsubscribePushRequest,
                         output=chatto_dot_api_dot_v1_dot_push__notifications__pb2.UnsubscribePushResponse,
-                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
                     ),
                     function=svc.unsubscribe,
                 ),
+                "/chatto.api.v1.PushNotificationService/SendTestNotification": Endpoint.unary(
+                    method=MethodInfo(
+                        name="SendTestNotification",
+                        service_name="chatto.api.v1.PushNotificationService",
+                        input=chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationRequest,
+                        output=chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=svc.send_test_notification,
+                ),
             },
             interceptors=interceptors,
             read_max_bytes=read_max_bytes,
@@ -96,6 +109,26 @@ class PushNotificationServiceClient(ConnectClient):
                 service_name="chatto.api.v1.PushNotificationService",
                 input=chatto_dot_api_dot_v1_dot_push__notifications__pb2.UnsubscribePushRequest,
                 output=chatto_dot_api_dot_v1_dot_push__notifications__pb2.UnsubscribePushResponse,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
+    async def send_test_notification(
+        self,
+        request: chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="SendTestNotification",
+                service_name="chatto.api.v1.PushNotificationService",
+                input=chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationRequest,
+                output=chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationResponse,
                 idempotency_level=IdempotencyLevel.UNKNOWN,
             ),
             headers=headers,
@@ -108,6 +141,8 @@ class PushNotificationServiceSync(Protocol):
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
     def unsubscribe(self, request: chatto_dot_api_dot_v1_dot_push__notifications__pb2.UnsubscribePushRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_push__notifications__pb2.UnsubscribePushResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+    def send_test_notification(self, request: chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
 
 class PushNotificationServiceWSGIApplication(ConnectWSGIApplication):
@@ -130,10 +165,20 @@ class PushNotificationServiceWSGIApplication(ConnectWSGIApplication):
                         service_name="chatto.api.v1.PushNotificationService",
                         input=chatto_dot_api_dot_v1_dot_push__notifications__pb2.UnsubscribePushRequest,
                         output=chatto_dot_api_dot_v1_dot_push__notifications__pb2.UnsubscribePushResponse,
-                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                        idempotency_level=IdempotencyLevel.IDEMPOTENT,
                     ),
                     function=service.unsubscribe,
                 ),
+                "/chatto.api.v1.PushNotificationService/SendTestNotification": EndpointSync.unary(
+                    method=MethodInfo(
+                        name="SendTestNotification",
+                        service_name="chatto.api.v1.PushNotificationService",
+                        input=chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationRequest,
+                        output=chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=service.send_test_notification,
+                ),
             },
             interceptors=interceptors,
             read_max_bytes=read_max_bytes,
@@ -181,6 +226,26 @@ class PushNotificationServiceClientSync(ConnectClientSync):
                 service_name="chatto.api.v1.PushNotificationService",
                 input=chatto_dot_api_dot_v1_dot_push__notifications__pb2.UnsubscribePushRequest,
                 output=chatto_dot_api_dot_v1_dot_push__notifications__pb2.UnsubscribePushResponse,
+                idempotency_level=IdempotencyLevel.IDEMPOTENT,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
+    def send_test_notification(
+        self,
+        request: chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="SendTestNotification",
+                service_name="chatto.api.v1.PushNotificationService",
+                input=chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationRequest,
+                output=chatto_dot_api_dot_v1_dot_push__notifications__pb2.SendTestPushNotificationResponse,
                 idempotency_level=IdempotencyLevel.UNKNOWN,
             ),
             headers=headers,

+ 25 - 14
src/chattolib/_pb/chatto/api/v1/push_notifications_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/push_notifications.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/push_notifications.proto'
 )
@@ -23,9 +23,10 @@ _sym_db = _symbol_database.Default()
 
 
 from buf.validate import validate_pb2 as buf_dot_validate_dot_validate__pb2
+from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2
 
 
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n&chatto/api/v1/push_notifications.proto\x12\rchatto.api.v1\x1a\x1b\x62uf/validate/validate.proto\"\x9c\x01\n\x14SubscribePushRequest\x12\x1c\n\x08\x65ndpoint\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80 \x12\x1a\n\x06p256dh\x18\x02 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x02\x12\x18\n\x04\x61uth\x18\x03 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x01\x12!\n\nuser_agent\x18\x04 \x01(\tB\x08\xbaH\x05r\x03\x18\x80\x04H\x00\x88\x01\x01\x42\r\n\x0b_user_agent\"+\n\x15SubscribePushResponse\x12\x12\n\nsubscribed\x18\x01 \x01(\x08\"6\n\x16UnsubscribePushRequest\x12\x1c\n\x08\x65ndpoint\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80 \"/\n\x17UnsubscribePushResponse\x12\x14\n\x0cunsubscribed\x18\x01 \x01(\x08\x32\xcf\x01\n\x17PushNotificationService\x12V\n\tSubscribe\x12#.chatto.api.v1.SubscribePushRequest\x1a$.chatto.api.v1.SubscribePushResponse\x12\\\n\x0bUnsubscribe\x12%.chatto.api.v1.UnsubscribePushRequest\x1a&.chatto.api.v1.UnsubscribePushResponseb\x06proto3')
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n&chatto/api/v1/push_notifications.proto\x12\rchatto.api.v1\x1a\x1b\x62uf/validate/validate.proto\x1a google/protobuf/descriptor.proto\"\xe0\x01\n\x14SubscribePushRequest\x12\x1c\n\x08\x65ndpoint\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80 \x12\x1a\n\x06p256dh\x18\x02 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x02\x12\x18\n\x04\x61uth\x18\x03 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x01\x12!\n\nuser_agent\x18\x04 \x01(\tB\x08\xbaH\x05r\x03\x18\x80\x04H\x00\x88\x01\x01\x12\x1f\n\x0b\x63lient_host\x18\x05 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\xff\x01\x12!\n\rcleanup_token\x18\x06 \x01(\tB\n\xbaH\x07r\x05\x10 \x18\x80\x01\x42\r\n\x0b_user_agent\"+\n\x15SubscribePushResponse\x12\x12\n\nsubscribed\x18\x01 \x01(\x08\"6\n\x16UnsubscribePushRequest\x12\x1c\n\x08\x65ndpoint\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80 \"/\n\x17UnsubscribePushResponse\x12\x14\n\x0cunsubscribed\x18\x01 \x01(\x08\"!\n\x1fSendTestPushNotificationRequest\"0\n SendTestPushNotificationResponse\x12\x0c\n\x04sent\x18\x01 \x01(\x08\x32\xcd\x02\n\x17PushNotificationService\x12V\n\tSubscribe\x12#.chatto.api.v1.SubscribePushRequest\x1a$.chatto.api.v1.SubscribePushResponse\x12\x61\n\x0bUnsubscribe\x12%.chatto.api.v1.UnsubscribePushRequest\x1a&.chatto.api.v1.UnsubscribePushResponse\"\x03\x90\x02\x02\x12w\n\x14SendTestNotification\x12..chatto.api.v1.SendTestPushNotificationRequest\x1a/.chatto.api.v1.SendTestPushNotificationResponseb\x06proto3')
 
 _globals = globals()
 _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -40,16 +41,26 @@ if not _descriptor._USE_C_DESCRIPTORS:
   _globals['_SUBSCRIBEPUSHREQUEST'].fields_by_name['auth']._serialized_options = b'\272H\007r\005\020\001\030\200\001'
   _globals['_SUBSCRIBEPUSHREQUEST'].fields_by_name['user_agent']._loaded_options = None
   _globals['_SUBSCRIBEPUSHREQUEST'].fields_by_name['user_agent']._serialized_options = b'\272H\005r\003\030\200\004'
+  _globals['_SUBSCRIBEPUSHREQUEST'].fields_by_name['client_host']._loaded_options = None
+  _globals['_SUBSCRIBEPUSHREQUEST'].fields_by_name['client_host']._serialized_options = b'\272H\007r\005\020\001\030\377\001'
+  _globals['_SUBSCRIBEPUSHREQUEST'].fields_by_name['cleanup_token']._loaded_options = None
+  _globals['_SUBSCRIBEPUSHREQUEST'].fields_by_name['cleanup_token']._serialized_options = b'\272H\007r\005\020 \030\200\001'
   _globals['_UNSUBSCRIBEPUSHREQUEST'].fields_by_name['endpoint']._loaded_options = None
   _globals['_UNSUBSCRIBEPUSHREQUEST'].fields_by_name['endpoint']._serialized_options = b'\272H\007r\005\020\001\030\200 '
-  _globals['_SUBSCRIBEPUSHREQUEST']._serialized_start=87
-  _globals['_SUBSCRIBEPUSHREQUEST']._serialized_end=243
-  _globals['_SUBSCRIBEPUSHRESPONSE']._serialized_start=245
-  _globals['_SUBSCRIBEPUSHRESPONSE']._serialized_end=288
-  _globals['_UNSUBSCRIBEPUSHREQUEST']._serialized_start=290
-  _globals['_UNSUBSCRIBEPUSHREQUEST']._serialized_end=344
-  _globals['_UNSUBSCRIBEPUSHRESPONSE']._serialized_start=346
-  _globals['_UNSUBSCRIBEPUSHRESPONSE']._serialized_end=393
-  _globals['_PUSHNOTIFICATIONSERVICE']._serialized_start=396
-  _globals['_PUSHNOTIFICATIONSERVICE']._serialized_end=603
+  _globals['_PUSHNOTIFICATIONSERVICE'].methods_by_name['Unsubscribe']._loaded_options = None
+  _globals['_PUSHNOTIFICATIONSERVICE'].methods_by_name['Unsubscribe']._serialized_options = b'\220\002\002'
+  _globals['_SUBSCRIBEPUSHREQUEST']._serialized_start=121
+  _globals['_SUBSCRIBEPUSHREQUEST']._serialized_end=345
+  _globals['_SUBSCRIBEPUSHRESPONSE']._serialized_start=347
+  _globals['_SUBSCRIBEPUSHRESPONSE']._serialized_end=390
+  _globals['_UNSUBSCRIBEPUSHREQUEST']._serialized_start=392
+  _globals['_UNSUBSCRIBEPUSHREQUEST']._serialized_end=446
+  _globals['_UNSUBSCRIBEPUSHRESPONSE']._serialized_start=448
+  _globals['_UNSUBSCRIBEPUSHRESPONSE']._serialized_end=495
+  _globals['_SENDTESTPUSHNOTIFICATIONREQUEST']._serialized_start=497
+  _globals['_SENDTESTPUSHNOTIFICATIONREQUEST']._serialized_end=530
+  _globals['_SENDTESTPUSHNOTIFICATIONRESPONSE']._serialized_start=532
+  _globals['_SENDTESTPUSHNOTIFICATIONRESPONSE']._serialized_end=580
+  _globals['_PUSHNOTIFICATIONSERVICE']._serialized_start=583
+  _globals['_PUSHNOTIFICATIONSERVICE']._serialized_end=916
 # @@protoc_insertion_point(module_scope)

+ 3 - 3
src/chattolib/_pb/chatto/api/v1/reactions_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/reactions.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/reactions.proto'
 )

+ 3 - 3
src/chattolib/_pb/chatto/api/v1/read_state_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/read_state.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/read_state.proto'
 )

+ 3 - 3
src/chattolib/_pb/chatto/api/v1/roles_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/roles.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/roles.proto'
 )

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 4 - 3
src/chattolib/_pb/chatto/api/v1/room_directory_pb2.py


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 4 - 3
src/chattolib/_pb/chatto/api/v1/room_timeline_pb2.py


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

@@ -65,6 +65,15 @@ class RoomService(Protocol):
     async def list_room_attachments(self, request: chatto_dot_api_dot_v1_dot_rooms__pb2.ListRoomAttachmentsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_rooms__pb2.ListRoomAttachmentsResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
+    async def list_pinned_messages(self, request: chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+
+    async def create_pinned_message(self, request: chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+
+    async def delete_pinned_message(self, request: chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+
     async def update_typing_indicator(self, request: chatto_dot_api_dot_v1_dot_rooms__pb2.UpdateTypingIndicatorRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_rooms__pb2.UpdateTypingIndicatorResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
@@ -239,6 +248,36 @@ class RoomServiceASGIApplication(ConnectASGIApplication[RoomService]):
                     ),
                     function=svc.list_room_attachments,
                 ),
+                "/chatto.api.v1.RoomService/ListPinnedMessages": Endpoint.unary(
+                    method=MethodInfo(
+                        name="ListPinnedMessages",
+                        service_name="chatto.api.v1.RoomService",
+                        input=chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesRequest,
+                        output=chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=svc.list_pinned_messages,
+                ),
+                "/chatto.api.v1.RoomService/CreatePinnedMessage": Endpoint.unary(
+                    method=MethodInfo(
+                        name="CreatePinnedMessage",
+                        service_name="chatto.api.v1.RoomService",
+                        input=chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageRequest,
+                        output=chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=svc.create_pinned_message,
+                ),
+                "/chatto.api.v1.RoomService/DeletePinnedMessage": Endpoint.unary(
+                    method=MethodInfo(
+                        name="DeletePinnedMessage",
+                        service_name="chatto.api.v1.RoomService",
+                        input=chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageRequest,
+                        output=chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=svc.delete_pinned_message,
+                ),
                 "/chatto.api.v1.RoomService/UpdateTypingIndicator": Endpoint.unary(
                     method=MethodInfo(
                         name="UpdateTypingIndicator",
@@ -612,6 +651,66 @@ class RoomServiceClient(ConnectClient):
             timeout_ms=timeout_ms,
         )
 
+    async def list_pinned_messages(
+        self,
+        request: chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="ListPinnedMessages",
+                service_name="chatto.api.v1.RoomService",
+                input=chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesRequest,
+                output=chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesResponse,
+                idempotency_level=IdempotencyLevel.UNKNOWN,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
+    async def create_pinned_message(
+        self,
+        request: chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="CreatePinnedMessage",
+                service_name="chatto.api.v1.RoomService",
+                input=chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageRequest,
+                output=chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageResponse,
+                idempotency_level=IdempotencyLevel.UNKNOWN,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
+    async def delete_pinned_message(
+        self,
+        request: chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="DeletePinnedMessage",
+                service_name="chatto.api.v1.RoomService",
+                input=chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageRequest,
+                output=chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageResponse,
+                idempotency_level=IdempotencyLevel.UNKNOWN,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
     async def update_typing_indicator(
         self,
         request: chatto_dot_api_dot_v1_dot_rooms__pb2.UpdateTypingIndicatorRequest,
@@ -764,6 +863,12 @@ class RoomServiceSync(Protocol):
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
     def list_room_attachments(self, request: chatto_dot_api_dot_v1_dot_rooms__pb2.ListRoomAttachmentsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_rooms__pb2.ListRoomAttachmentsResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+    def list_pinned_messages(self, request: chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+    def create_pinned_message(self, request: chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+    def delete_pinned_message(self, request: chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
     def update_typing_indicator(self, request: chatto_dot_api_dot_v1_dot_rooms__pb2.UpdateTypingIndicatorRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_rooms__pb2.UpdateTypingIndicatorResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
     def get_room_events(self, request: chatto_dot_api_dot_v1_dot_room__timeline__pb2.GetRoomEventsRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_room__timeline__pb2.GetRoomEventsResponse:
@@ -932,6 +1037,36 @@ class RoomServiceWSGIApplication(ConnectWSGIApplication):
                     ),
                     function=service.list_room_attachments,
                 ),
+                "/chatto.api.v1.RoomService/ListPinnedMessages": EndpointSync.unary(
+                    method=MethodInfo(
+                        name="ListPinnedMessages",
+                        service_name="chatto.api.v1.RoomService",
+                        input=chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesRequest,
+                        output=chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=service.list_pinned_messages,
+                ),
+                "/chatto.api.v1.RoomService/CreatePinnedMessage": EndpointSync.unary(
+                    method=MethodInfo(
+                        name="CreatePinnedMessage",
+                        service_name="chatto.api.v1.RoomService",
+                        input=chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageRequest,
+                        output=chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=service.create_pinned_message,
+                ),
+                "/chatto.api.v1.RoomService/DeletePinnedMessage": EndpointSync.unary(
+                    method=MethodInfo(
+                        name="DeletePinnedMessage",
+                        service_name="chatto.api.v1.RoomService",
+                        input=chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageRequest,
+                        output=chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=service.delete_pinned_message,
+                ),
                 "/chatto.api.v1.RoomService/UpdateTypingIndicator": EndpointSync.unary(
                     method=MethodInfo(
                         name="UpdateTypingIndicator",
@@ -1305,6 +1440,66 @@ class RoomServiceClientSync(ConnectClientSync):
             timeout_ms=timeout_ms,
         )
 
+    def list_pinned_messages(
+        self,
+        request: chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="ListPinnedMessages",
+                service_name="chatto.api.v1.RoomService",
+                input=chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesRequest,
+                output=chatto_dot_api_dot_v1_dot_rooms__pb2.ListPinnedMessagesResponse,
+                idempotency_level=IdempotencyLevel.UNKNOWN,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
+    def create_pinned_message(
+        self,
+        request: chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="CreatePinnedMessage",
+                service_name="chatto.api.v1.RoomService",
+                input=chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageRequest,
+                output=chatto_dot_api_dot_v1_dot_rooms__pb2.CreatePinnedMessageResponse,
+                idempotency_level=IdempotencyLevel.UNKNOWN,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
+    def delete_pinned_message(
+        self,
+        request: chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="DeletePinnedMessage",
+                service_name="chatto.api.v1.RoomService",
+                input=chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageRequest,
+                output=chatto_dot_api_dot_v1_dot_rooms__pb2.DeletePinnedMessageResponse,
+                idempotency_level=IdempotencyLevel.UNKNOWN,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
     def update_typing_indicator(
         self,
         request: chatto_dot_api_dot_v1_dot_rooms__pb2.UpdateTypingIndicatorRequest,

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 5 - 3
src/chattolib/_pb/chatto/api/v1/rooms_pb2.py


+ 8 - 6
src/chattolib/_pb/chatto/api/v1/server_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/server.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/server.proto'
 )
@@ -25,15 +25,17 @@ _sym_db = _symbol_database.Default()
 from chatto.api.v1 import common_pb2 as chatto_dot_api_dot_v1_dot_common__pb2
 
 
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1a\x63hatto/api/v1/server.proto\x12\rchatto.api.v1\x1a\x1a\x63hatto/api/v1/common.proto\"\xdc\x01\n\x13ServerPublicProfile\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x15\n\x08logo_url\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x17\n\nbanner_url\x18\x04 \x01(\tH\x01\x88\x01\x01\x12\x1c\n\x0fwelcome_message\x18\x05 \x01(\tH\x02\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x06 \x01(\tH\x03\x88\x01\x01\x42\x0b\n\t_logo_urlB\r\n\x0b_banner_urlB\x12\n\x10_welcome_messageB\x0e\n\x0c_description\"}\n\x0bServerLogin\x12#\n\x1b\x64irect_registration_enabled\x18\x01 \x01(\x08\x12\x32\n\tproviders\x18\x02 \x03(\x0b\x32\x1f.chatto.api.v1.ProviderMetadata\x12\x15\n\rauthorize_url\x18\x03 \x01(\tb\x06proto3')
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1a\x63hatto/api/v1/server.proto\x12\rchatto.api.v1\x1a\x1a\x63hatto/api/v1/common.proto\"\xdc\x01\n\x13ServerPublicProfile\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x15\n\x08logo_url\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x17\n\nbanner_url\x18\x04 \x01(\tH\x01\x88\x01\x01\x12\x1c\n\x0fwelcome_message\x18\x05 \x01(\tH\x02\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x06 \x01(\tH\x03\x88\x01\x01\x42\x0b\n\t_logo_urlB\r\n\x0b_banner_urlB\x12\n\x10_welcome_messageB\x0e\n\x0c_description\"\x80\x02\n\x0bServerLogin\x12#\n\x1b\x64irect_registration_enabled\x18\x01 \x01(\x08\x12\x32\n\tproviders\x18\x02 \x03(\x0b\x32\x1f.chatto.api.v1.ProviderMetadata\x12\x15\n\rauthorize_url\x18\x03 \x01(\t\x12\x45\n\x17\x61\x63\x63ount_creation_policy\x18\x04 \x01(\x0e\x32$.chatto.api.v1.AccountCreationPolicy\x12!\n\x14\x64irect_login_enabled\x18\x05 \x01(\x08H\x00\x88\x01\x01\x42\x17\n\x15_direct_login_enabled*\x8b\x01\n\x15\x41\x63\x63ountCreationPolicy\x12\'\n#ACCOUNT_CREATION_POLICY_UNSPECIFIED\x10\x00\x12 \n\x1c\x41\x43\x43OUNT_CREATION_POLICY_OPEN\x10\x01\x12\'\n#ACCOUNT_CREATION_POLICY_INVITE_ONLY\x10\x02\x62\x06proto3')
 
 _globals = globals()
 _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
 _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'chatto.api.v1.server_pb2', _globals)
 if not _descriptor._USE_C_DESCRIPTORS:
   DESCRIPTOR._loaded_options = None
+  _globals['_ACCOUNTCREATIONPOLICY']._serialized_start=556
+  _globals['_ACCOUNTCREATIONPOLICY']._serialized_end=695
   _globals['_SERVERPUBLICPROFILE']._serialized_start=74
   _globals['_SERVERPUBLICPROFILE']._serialized_end=294
-  _globals['_SERVERLOGIN']._serialized_start=296
-  _globals['_SERVERLOGIN']._serialized_end=421
+  _globals['_SERVERLOGIN']._serialized_start=297
+  _globals['_SERVERLOGIN']._serialized_end=553
 # @@protoc_insertion_point(module_scope)

+ 3 - 3
src/chattolib/_pb/chatto/api/v1/server_state_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/server_state.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/server_state.proto'
 )

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 3 - 3
src/chattolib/_pb/chatto/api/v1/threads_pb2.py


+ 3 - 3
src/chattolib/_pb/chatto/api/v1/user_status_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/user_status.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/user_status.proto'
 )

+ 5 - 5
src/chattolib/_pb/chatto/api/v1/users_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/users.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/api/v1/users.proto'
 )
@@ -26,7 +26,7 @@ from chatto.api.v1 import presence_pb2 as chatto_dot_api_dot_v1_dot_presence__pb
 from chatto.api.v1 import user_status_pb2 as chatto_dot_api_dot_v1_dot_user__status__pb2
 
 
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19\x63hatto/api/v1/users.proto\x12\rchatto.api.v1\x1a\x1c\x63hatto/api/v1/presence.proto\x1a\x1f\x63hatto/api/v1/user_status.proto\"\xe0\x01\n\x04User\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05login\x18\x02 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x03 \x01(\t\x12\x0f\n\x07\x64\x65leted\x18\x04 \x01(\x08\x12\x17\n\navatar_url\x18\x05 \x01(\tH\x00\x88\x01\x01\x12\x36\n\x0fpresence_status\x18\x06 \x01(\x0e\x32\x1d.chatto.api.v1.PresenceStatus\x12\x36\n\rcustom_status\x18\x07 \x01(\x0b\x32\x1f.chatto.api.v1.CustomUserStatusB\r\n\x0b_avatar_urlb\x06proto3')
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19\x63hatto/api/v1/users.proto\x12\rchatto.api.v1\x1a\x1c\x63hatto/api/v1/presence.proto\x1a\x1f\x63hatto/api/v1/user_status.proto\"\xae\x02\n\x04User\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05login\x18\x02 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x03 \x01(\t\x12\x0f\n\x07\x64\x65leted\x18\x04 \x01(\x08\x12\x17\n\navatar_url\x18\x05 \x01(\tH\x00\x88\x01\x01\x12\x36\n\x0fpresence_status\x18\x06 \x01(\x0e\x32\x1d.chatto.api.v1.PresenceStatus\x12\x36\n\rcustom_status\x18\x07 \x01(\x0b\x32\x1f.chatto.api.v1.CustomUserStatus\x12\x0e\n\x06is_bot\x18\x08 \x01(\x08\x12\x10\n\x03\x62io\x18\t \x01(\tH\x01\x88\x01\x01\x12\x15\n\x08timezone\x18\n \x01(\tH\x02\x88\x01\x01\x42\r\n\x0b_avatar_urlB\x06\n\x04_bioB\x0b\n\t_timezoneb\x06proto3')
 
 _globals = globals()
 _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -34,5 +34,5 @@ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'chatto.api.v1.users_pb2', _
 if not _descriptor._USE_C_DESCRIPTORS:
   DESCRIPTOR._loaded_options = None
   _globals['_USER']._serialized_start=108
-  _globals['_USER']._serialized_end=332
+  _globals['_USER']._serialized_end=410
 # @@protoc_insertion_point(module_scope)

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 3 - 4
src/chattolib/_pb/chatto/api/v1/viewer_pb2.py


+ 65 - 0
src/chattolib/_pb/chatto/api/v1/voice_calls_connect.py

@@ -35,6 +35,9 @@ class VoiceCallService(Protocol):
     async def get_call_token(self, request: chatto_dot_api_dot_v1_dot_voice__calls__pb2.GetCallTokenRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_voice__calls__pb2.GetCallTokenResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
+    async def create_call_media_publisher_token(self, request: chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+
     async def leave_call(self, request: chatto_dot_api_dot_v1_dot_voice__calls__pb2.LeaveCallRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_voice__calls__pb2.LeaveCallResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
@@ -104,6 +107,16 @@ class VoiceCallServiceASGIApplication(ConnectASGIApplication[VoiceCallService]):
                     ),
                     function=svc.get_call_token,
                 ),
+                "/chatto.api.v1.VoiceCallService/CreateCallMediaPublisherToken": Endpoint.unary(
+                    method=MethodInfo(
+                        name="CreateCallMediaPublisherToken",
+                        service_name="chatto.api.v1.VoiceCallService",
+                        input=chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenRequest,
+                        output=chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=svc.create_call_media_publisher_token,
+                ),
                 "/chatto.api.v1.VoiceCallService/LeaveCall": Endpoint.unary(
                     method=MethodInfo(
                         name="LeaveCall",
@@ -247,6 +260,26 @@ class VoiceCallServiceClient(ConnectClient):
             timeout_ms=timeout_ms,
         )
 
+    async def create_call_media_publisher_token(
+        self,
+        request: chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="CreateCallMediaPublisherToken",
+                service_name="chatto.api.v1.VoiceCallService",
+                input=chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenRequest,
+                output=chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenResponse,
+                idempotency_level=IdempotencyLevel.UNKNOWN,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
     async def leave_call(
         self,
         request: chatto_dot_api_dot_v1_dot_voice__calls__pb2.LeaveCallRequest,
@@ -281,6 +314,8 @@ class VoiceCallServiceSync(Protocol):
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
     def get_call_token(self, request: chatto_dot_api_dot_v1_dot_voice__calls__pb2.GetCallTokenRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_voice__calls__pb2.GetCallTokenResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
+    def create_call_media_publisher_token(self, request: chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenResponse:
+        raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
     def leave_call(self, request: chatto_dot_api_dot_v1_dot_voice__calls__pb2.LeaveCallRequest, ctx: RequestContext) -> chatto_dot_api_dot_v1_dot_voice__calls__pb2.LeaveCallResponse:
         raise ConnectError(Code.UNIMPLEMENTED, "Not implemented")
 
@@ -349,6 +384,16 @@ class VoiceCallServiceWSGIApplication(ConnectWSGIApplication):
                     ),
                     function=service.get_call_token,
                 ),
+                "/chatto.api.v1.VoiceCallService/CreateCallMediaPublisherToken": EndpointSync.unary(
+                    method=MethodInfo(
+                        name="CreateCallMediaPublisherToken",
+                        service_name="chatto.api.v1.VoiceCallService",
+                        input=chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenRequest,
+                        output=chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenResponse,
+                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                    ),
+                    function=service.create_call_media_publisher_token,
+                ),
                 "/chatto.api.v1.VoiceCallService/LeaveCall": EndpointSync.unary(
                     method=MethodInfo(
                         name="LeaveCall",
@@ -492,6 +537,26 @@ class VoiceCallServiceClientSync(ConnectClientSync):
             timeout_ms=timeout_ms,
         )
 
+    def create_call_media_publisher_token(
+        self,
+        request: chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenRequest,
+        *,
+        headers: Headers | Mapping[str, str] | None = None,
+        timeout_ms: int | None = None,
+    ) -> chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="CreateCallMediaPublisherToken",
+                service_name="chatto.api.v1.VoiceCallService",
+                input=chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenRequest,
+                output=chatto_dot_api_dot_v1_dot_voice__calls__pb2.CreateCallMediaPublisherTokenResponse,
+                idempotency_level=IdempotencyLevel.UNKNOWN,
+            ),
+            headers=headers,
+            timeout_ms=timeout_ms,
+        )
+
     def leave_call(
         self,
         request: chatto_dot_api_dot_v1_dot_voice__calls__pb2.LeaveCallRequest,

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 3 - 3
src/chattolib/_pb/chatto/api/v1/voice_calls_pb2.py


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 3 - 3
src/chattolib/_pb/chatto/auth/v1/external_identity_auth_pb2.py


+ 8 - 4
src/chattolib/_pb/chatto/discovery/v1/server_connect.py

@@ -32,7 +32,7 @@ class ServerDiscoveryServiceASGIApplication(ConnectASGIApplication[ServerDiscove
                         service_name="chatto.discovery.v1.ServerDiscoveryService",
                         input=chatto_dot_discovery_dot_v1_dot_server__pb2.GetServerRequest,
                         output=chatto_dot_discovery_dot_v1_dot_server__pb2.GetServerResponse,
-                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                        idempotency_level=IdempotencyLevel.NO_SIDE_EFFECTS,
                     ),
                     function=svc.get_server,
                 ),
@@ -55,6 +55,7 @@ class ServerDiscoveryServiceClient(ConnectClient):
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
+        use_get: bool = False,
     ) -> chatto_dot_discovery_dot_v1_dot_server__pb2.GetServerResponse:
         return await self.execute_unary(
             request=request,
@@ -63,10 +64,11 @@ class ServerDiscoveryServiceClient(ConnectClient):
                 service_name="chatto.discovery.v1.ServerDiscoveryService",
                 input=chatto_dot_discovery_dot_v1_dot_server__pb2.GetServerRequest,
                 output=chatto_dot_discovery_dot_v1_dot_server__pb2.GetServerResponse,
-                idempotency_level=IdempotencyLevel.UNKNOWN,
+                idempotency_level=IdempotencyLevel.NO_SIDE_EFFECTS,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
+            use_get=use_get,
         )
 
 
@@ -85,7 +87,7 @@ class ServerDiscoveryServiceWSGIApplication(ConnectWSGIApplication):
                         service_name="chatto.discovery.v1.ServerDiscoveryService",
                         input=chatto_dot_discovery_dot_v1_dot_server__pb2.GetServerRequest,
                         output=chatto_dot_discovery_dot_v1_dot_server__pb2.GetServerResponse,
-                        idempotency_level=IdempotencyLevel.UNKNOWN,
+                        idempotency_level=IdempotencyLevel.NO_SIDE_EFFECTS,
                     ),
                     function=service.get_server,
                 ),
@@ -108,6 +110,7 @@ class ServerDiscoveryServiceClientSync(ConnectClientSync):
         *,
         headers: Headers | Mapping[str, str] | None = None,
         timeout_ms: int | None = None,
+        use_get: bool = False,
     ) -> chatto_dot_discovery_dot_v1_dot_server__pb2.GetServerResponse:
         return self.execute_unary(
             request=request,
@@ -116,8 +119,9 @@ class ServerDiscoveryServiceClientSync(ConnectClientSync):
                 service_name="chatto.discovery.v1.ServerDiscoveryService",
                 input=chatto_dot_discovery_dot_v1_dot_server__pb2.GetServerRequest,
                 output=chatto_dot_discovery_dot_v1_dot_server__pb2.GetServerResponse,
-                idempotency_level=IdempotencyLevel.UNKNOWN,
+                idempotency_level=IdempotencyLevel.NO_SIDE_EFFECTS,
             ),
             headers=headers,
             timeout_ms=timeout_ms,
+            use_get=use_get,
         )

+ 13 - 10
src/chattolib/_pb/chatto/discovery/v1/server_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/discovery/v1/server.proto
-# Protobuf Python Version: 7.34.1
+# Protobuf Python Version: 7.36.0
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,8 +12,8 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    34,
-    1,
+    36,
+    0,
     '',
     'chatto/discovery/v1/server.proto'
 )
@@ -23,19 +23,22 @@ _sym_db = _symbol_database.Default()
 
 
 from chatto.api.v1 import server_pb2 as chatto_dot_api_dot_v1_dot_server__pb2
+from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2
 
 
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n chatto/discovery/v1/server.proto\x12\x13\x63hatto.discovery.v1\x1a\x1a\x63hatto/api/v1/server.proto\"\x12\n\x10GetServerRequest\"s\n\x11GetServerResponse\x12\x33\n\x07profile\x18\x01 \x01(\x0b\x32\".chatto.api.v1.ServerPublicProfile\x12)\n\x05login\x18\x02 \x01(\x0b\x32\x1a.chatto.api.v1.ServerLogin2t\n\x16ServerDiscoveryService\x12Z\n\tGetServer\x12%.chatto.discovery.v1.GetServerRequest\x1a&.chatto.discovery.v1.GetServerResponseb\x06proto3')
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n chatto/discovery/v1/server.proto\x12\x13\x63hatto.discovery.v1\x1a\x1a\x63hatto/api/v1/server.proto\x1a google/protobuf/descriptor.proto\"\x12\n\x10GetServerRequest\"s\n\x11GetServerResponse\x12\x33\n\x07profile\x18\x01 \x01(\x0b\x32\".chatto.api.v1.ServerPublicProfile\x12)\n\x05login\x18\x02 \x01(\x0b\x32\x1a.chatto.api.v1.ServerLogin2y\n\x16ServerDiscoveryService\x12_\n\tGetServer\x12%.chatto.discovery.v1.GetServerRequest\x1a&.chatto.discovery.v1.GetServerResponse\"\x03\x90\x02\x01\x62\x06proto3')
 
 _globals = globals()
 _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
 _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'chatto.discovery.v1.server_pb2', _globals)
 if not _descriptor._USE_C_DESCRIPTORS:
   DESCRIPTOR._loaded_options = None
-  _globals['_GETSERVERREQUEST']._serialized_start=85
-  _globals['_GETSERVERREQUEST']._serialized_end=103
-  _globals['_GETSERVERRESPONSE']._serialized_start=105
-  _globals['_GETSERVERRESPONSE']._serialized_end=220
-  _globals['_SERVERDISCOVERYSERVICE']._serialized_start=222
-  _globals['_SERVERDISCOVERYSERVICE']._serialized_end=338
+  _globals['_SERVERDISCOVERYSERVICE'].methods_by_name['GetServer']._loaded_options = None
+  _globals['_SERVERDISCOVERYSERVICE'].methods_by_name['GetServer']._serialized_options = b'\220\002\001'
+  _globals['_GETSERVERREQUEST']._serialized_start=119
+  _globals['_GETSERVERREQUEST']._serialized_end=137
+  _globals['_GETSERVERRESPONSE']._serialized_start=139
+  _globals['_GETSERVERRESPONSE']._serialized_end=254
+  _globals['_SERVERDISCOVERYSERVICE']._serialized_start=256
+  _globals['_SERVERDISCOVERYSERVICE']._serialized_end=377
 # @@protoc_insertion_point(module_scope)

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 11 - 4
src/chattolib/_pb/chatto/realtime/v1/realtime_pb2.py


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 200 - 302
src/chattolib/client.py


+ 99 - 6
src/chattolib/realtime.py

@@ -30,7 +30,7 @@ if TYPE_CHECKING:
     from chattolib.client import ChattoClient
 
 REALTIME_PATH = "/api/realtime"
-REALTIME_PROTOCOL_VERSION = 1
+REALTIME_PROTOCOL_VERSION = 2
 
 
 class ChattoRealtimeError(ChattoError):
@@ -81,6 +81,41 @@ class ServerHello:
     capabilities: list[str]
 
 
+@dataclass
+class RealtimeProjectionOperation:
+    """One durable state change inside a :class:`RealtimeProjectionEvent`.
+
+    ``operation`` names the ``oneof operation`` case set on the operation
+    (``reset``, ``server_upsert``, ``viewer_upsert``, ``user_upsert``,
+    ``user_remove``, ``room_upsert``, ``room_remove``,
+    ``room_groups_replace``, ``room_timeline_replace``,
+    ``room_timeline_event_upsert``, ``server_state_upsert``,
+    ``room_timeline_event_remove``, ``room_viewer_state_replace``,
+    ``active_calls_replace``, ``presences_replace``,
+    ``thread_viewer_states_replace``, ``room_activity``,
+    ``notification_occurrences_replace``). ``payload`` is the concrete
+    protobuf sub-message.
+    """
+
+    operation: str
+    payload: Any
+    raw: Any  # the full RealtimeProjectionOperation
+
+
+@dataclass
+class RealtimeProjectionEvent:
+    """A server-projection frame: the durable state the server wants clients
+    to converge on, as a batch of :class:`RealtimeProjectionOperation` s.
+    """
+
+    id: str
+    created_at: datetime | None
+    actor_id: str | None
+    resume_cursor: str | None
+    operations: list[RealtimeProjectionOperation]
+    raw: Any  # the full RealtimeProjectionEvent
+
+
 @dataclass
 class RealtimeEvent:
     """One live event delivered over the realtime WebSocket.
@@ -114,9 +149,13 @@ class RealtimeConnection:
         client: ChattoClient,
         *,
         protocol_version: int = REALTIME_PROTOCOL_VERSION,
+        resume_cursor: str | None = None,
+        retained_room_ids: list[str] | None = None,
     ) -> None:
         self._client = client
         self._protocol_version = protocol_version
+        self._resume_cursor = resume_cursor
+        self._retained_room_ids = list(retained_room_ids or [])
         self._ws: Any = None
         self._server_hello: ServerHello | None = None
 
@@ -174,6 +213,9 @@ class RealtimeConnection:
 
         subscribe = realtime_pb2.RealtimeClientFrame()
         subscribe.subscribe_events.SetInParent()
+        if self._resume_cursor:
+            subscribe.subscribe_events.resume_cursor = self._resume_cursor
+        subscribe.subscribe_events.retained_room_ids.extend(self._retained_room_ids)
         await self._ws.send(subscribe.SerializeToString())
 
     async def close(self) -> None:
@@ -194,8 +236,21 @@ class RealtimeConnection:
         frame.ping.nonce = nonce
         await self._ws.send(frame.SerializeToString())
 
-    async def events(self) -> AsyncIterator[RealtimeEvent]:
-        """Yield decoded live events until the connection closes."""
+    async def hydrate_room(self, room_id: str) -> None:
+        """Ask the server to re-send the projection state for a room."""
+        if self._ws is None:
+            raise ChattoError("realtime connection is closed")
+        from chattolib._pb.chatto.realtime.v1 import realtime_pb2
+
+        frame = realtime_pb2.RealtimeClientFrame()
+        frame.hydrate_room.room_id = room_id
+        await self._ws.send(frame.SerializeToString())
+
+    async def events(
+        self,
+    ) -> AsyncIterator[RealtimeEvent | RealtimeProjectionEvent]:
+        """Yield decoded live events and projection events until the
+        connection closes."""
         if self._ws is None:
             raise ChattoError("realtime connection is not open")
         from chattolib._pb.chatto.realtime.v1 import realtime_pb2
@@ -212,8 +267,10 @@ class RealtimeConnection:
             case = frame.WhichOneof("frame")
             if case == "event":
                 yield _wrap_event(frame.event)
-            elif case in ("heartbeat", "pong", "subscribed"):
+            elif case in ("heartbeat", "pong", "subscribed", "caught_up"):
                 continue
+            elif case == "projection_event":
+                yield _wrap_projection_event(frame.projection_event)
             elif case == "error":
                 err = frame.error
                 exc = ChattoRealtimeError(err.code, err.message, fatal=err.fatal)
@@ -254,6 +311,33 @@ def _raise_for_control(frame: Any) -> None:
         )
 
 
+def _wrap_projection_event(envelope: Any) -> RealtimeProjectionEvent:
+    created_at = None
+    if envelope.HasField("created_at"):
+        created_at = parse_datetime(envelope.created_at.ToJsonString())
+    actor_id: str | None = None
+    if envelope.HasField("actor_id"):
+        actor_id = envelope.actor_id
+    resume_cursor: str | None = None
+    if envelope.HasField("resume_cursor"):
+        resume_cursor = envelope.resume_cursor
+    operations = [_wrap_projection_operation(op) for op in envelope.operations]
+    return RealtimeProjectionEvent(
+        id=envelope.id,
+        created_at=created_at,
+        actor_id=actor_id,
+        resume_cursor=resume_cursor,
+        operations=operations,
+        raw=envelope,
+    )
+
+
+def _wrap_projection_operation(op: Any) -> RealtimeProjectionOperation:
+    kind = op.WhichOneof("operation") or ""
+    payload = getattr(op, kind, None) if kind else None
+    return RealtimeProjectionOperation(operation=kind, payload=payload, raw=op)
+
+
 def _wrap_event(envelope: Any) -> RealtimeEvent:
     kind = envelope.WhichOneof("event") or ""
     payload = getattr(envelope, kind, None) if kind else None
@@ -277,14 +361,21 @@ async def stream_events(
     client: ChattoClient,
     *,
     protocol_version: int = REALTIME_PROTOCOL_VERSION,
-) -> AsyncIterator[RealtimeEvent]:
+    resume_cursor: str | None = None,
+    retained_room_ids: list[str] | None = None,
+) -> AsyncIterator[RealtimeEvent | RealtimeProjectionEvent]:
     """Open a realtime connection and yield events until the server closes.
 
     Raises :class:`ChattoRealtimeCloseError` when the server sends a close frame,
     :class:`ChattoRealtimeError` on fatal protocol errors, or
     :class:`ChattoConnectError` if the initial HTTP handshake fails.
     """
-    conn = RealtimeConnection(client, protocol_version=protocol_version)
+    conn = RealtimeConnection(
+        client,
+        protocol_version=protocol_version,
+        resume_cursor=resume_cursor,
+        retained_room_ids=retained_room_ids,
+    )
     try:
         await conn.connect()
         async for event in conn.events():
@@ -300,6 +391,8 @@ __all__ = [
     "ChattoRealtimeError",
     "RealtimeConnection",
     "RealtimeEvent",
+    "RealtimeProjectionEvent",
+    "RealtimeProjectionOperation",
     "ServerHello",
     "realtime_url",
     "stream_events",

+ 299 - 35
src/chattolib/types.py

@@ -60,6 +60,14 @@ class RoomKind(StrEnum):
     DM = "ROOM_KIND_DM"
 
 
+class RoomThreadingMode(StrEnum):
+    UNSPECIFIED = "ROOM_THREADING_MODE_UNSPECIFIED"
+    REQUIRED = "ROOM_THREADING_MODE_REQUIRED"
+    ENCOURAGED = "ROOM_THREADING_MODE_ENCOURAGED"
+    ENABLED = "ROOM_THREADING_MODE_ENABLED"
+    DISABLED = "ROOM_THREADING_MODE_DISABLED"
+
+
 class RoomDirectoryScope(StrEnum):
     UNSPECIFIED = "ROOM_DIRECTORY_SCOPE_UNSPECIFIED"
     ALL = "ROOM_DIRECTORY_SCOPE_ALL"
@@ -155,6 +163,9 @@ class User:
     avatar_url: str | None = None
     deleted: bool = False
     custom_status: CustomUserStatus | None = None
+    is_bot: bool = False
+    bio: str | None = None
+    timezone: str | None = None
 
     @classmethod
     def parse(cls, data: dict[str, Any] | None) -> User | None:
@@ -170,6 +181,9 @@ class User:
             avatar_url=data.get("avatarUrl"),
             deleted=bool(data.get("deleted", False)),
             custom_status=CustomUserStatus.parse(data.get("customStatus")),
+            is_bot=bool(data.get("isBot", False)),
+            bio=data.get("bio"),
+            timezone=data.get("timezone"),
         )
 
 
@@ -183,9 +197,7 @@ class UserSettings:
         data = data or {}
         return cls(
             timezone=data.get("timezone"),
-            time_format=_parse_enum(
-                TimeFormat, data.get("timeFormat"), TimeFormat.UNSPECIFIED
-            ),  # type: ignore[arg-type]
+            time_format=_parse_enum(TimeFormat, data.get("timeFormat"), TimeFormat.UNSPECIFIED),  # type: ignore[arg-type]
         )
 
 
@@ -331,6 +343,8 @@ class Room:
     archived: bool = False
     group_id: str = ""
     universal: bool = False
+    slow_mode_seconds: int = 0
+    threading_mode: RoomThreadingMode = RoomThreadingMode.UNSPECIFIED
 
     @classmethod
     def parse(cls, data: dict[str, Any] | None) -> Room | None:
@@ -344,6 +358,12 @@ class Room:
             archived=bool(data.get("archived", False)),
             group_id=data.get("groupId", ""),
             universal=bool(data.get("universal", False)),
+            slow_mode_seconds=int(data.get("slowModeSeconds") or 0),
+            threading_mode=_parse_enum(
+                RoomThreadingMode,
+                data.get("threadingMode"),
+                RoomThreadingMode.UNSPECIFIED,
+            ),  # type: ignore[arg-type]
         )
 
 
@@ -557,6 +577,100 @@ class MessageAttachment:
         )
 
 
+@dataclass
+class SocialPostAuthor:
+    display_name: str = ""
+    handle: str = ""
+    avatar_url: str | None = None
+    avatar_asset_id: str | None = None
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> SocialPostAuthor | None:
+        if not data:
+            return None
+        return cls(
+            display_name=data.get("displayName", ""),
+            handle=data.get("handle", ""),
+            avatar_url=data.get("avatarUrl"),
+            avatar_asset_id=data.get("avatarAssetId"),
+        )
+
+
+@dataclass
+class SocialPostImage:
+    url: str = ""
+    asset_id: str = ""
+    alt: str = ""
+    width: int = 0
+    height: int = 0
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> SocialPostImage | None:
+        if not data:
+            return None
+        return cls(
+            url=data.get("url", ""),
+            asset_id=data.get("assetId", ""),
+            alt=data.get("alt", ""),
+            width=int(data.get("width") or 0),
+            height=int(data.get("height") or 0),
+        )
+
+
+@dataclass
+class SocialPostExternalLink:
+    url: str = ""
+    title: str | None = None
+    description: str | None = None
+    image_url: str | None = None
+    image_asset_id: str | None = None
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> SocialPostExternalLink | None:
+        if not data:
+            return None
+        return cls(
+            url=data.get("url", ""),
+            title=data.get("title"),
+            description=data.get("description"),
+            image_url=data.get("imageUrl"),
+            image_asset_id=data.get("imageAssetId"),
+        )
+
+
+@dataclass
+class SocialPostPreview:
+    provider: str = ""
+    author: SocialPostAuthor | None = None
+    text: str | None = None
+    published_at: datetime | None = None
+    images: list[SocialPostImage] = field(default_factory=list)
+    external_link: SocialPostExternalLink | None = None
+    content_warning: str | None = None
+    url: str | None = None
+    quoted_post: SocialPostPreview | None = None
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> SocialPostPreview | None:
+        if not data:
+            return None
+        return cls(
+            provider=data.get("provider", ""),
+            author=SocialPostAuthor.parse(data.get("author")),
+            text=data.get("text"),
+            published_at=parse_datetime(data.get("publishedAt")),
+            images=[
+                i
+                for i in (SocialPostImage.parse(i) for i in data.get("images") or [])
+                if i is not None
+            ],
+            external_link=SocialPostExternalLink.parse(data.get("externalLink")),
+            content_warning=data.get("contentWarning"),
+            url=data.get("url"),
+            quoted_post=SocialPostPreview.parse(data.get("quotedPost")),
+        )
+
+
 @dataclass
 class LinkPreview:
     url: str
@@ -567,6 +681,7 @@ class LinkPreview:
     site_name: str | None = None
     embed_type: str | None = None
     embed_id: str | None = None
+    social_post: SocialPostPreview | None = None
 
     @classmethod
     def parse(cls, data: dict[str, Any] | None) -> LinkPreview | None:
@@ -581,6 +696,39 @@ class LinkPreview:
             site_name=data.get("siteName"),
             embed_type=data.get("embedType"),
             embed_id=data.get("embedId"),
+            social_post=SocialPostPreview.parse(data.get("socialPost")),
+        )
+
+
+@dataclass
+class PinnedMessage:
+    """A pinned message in a room (wraps the pinned :class:`Message")."""
+
+    message: Message | None = None
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> PinnedMessage | None:
+        if not data:
+            return None
+        return cls(message=Message.parse(data.get("message")))
+
+
+@dataclass
+class PinnedMessagesPage:
+    pinned_messages: list[PinnedMessage] = field(default_factory=list)
+    page: Page = field(default_factory=Page)
+    latest_pin_marker: str | None = None
+
+    @classmethod
+    def parse(cls, data: dict[str, Any]) -> PinnedMessagesPage:
+        return cls(
+            pinned_messages=[
+                p
+                for p in (PinnedMessage.parse(m) for m in data.get("pinnedMessages") or [])
+                if p is not None
+            ],
+            page=Page.parse(data.get("page")),
+            latest_pin_marker=data.get("latestPinMarker"),
         )
 
 
@@ -620,9 +768,7 @@ class ThreadSummary:
             thread_root_event_id=data.get("threadRootEventId", ""),
             reply_count=int(data.get("replyCount", 0)),
             last_reply_at=parse_datetime(data.get("lastReplyAt")),
-            participant_preview_user_ids=list(
-                data.get("participantPreviewUserIds") or []
-            ),
+            participant_preview_user_ids=list(data.get("participantPreviewUserIds") or []),
             participant_count=int(data.get("participantCount", 0)),
             is_following=viewer.get("isFollowing") if "isFollowing" in viewer else None,
             has_unread=viewer.get("hasUnread") if "hasUnread" in viewer else None,
@@ -647,6 +793,7 @@ class Message:
     reactions: list[MessageReaction] = field(default_factory=list)
     thread: ThreadSummary | None = None
     deleted_at: datetime | None = None
+    pinned: bool = False
 
     @classmethod
     def parse(cls, data: dict[str, Any] | None) -> Message | None:
@@ -669,6 +816,7 @@ class Message:
             reactions=[MessageReaction.parse(r) for r in data.get("reactions") or []],
             thread=ThreadSummary.parse(data.get("thread")),
             deleted_at=parse_datetime(data.get("deletedAt")),
+            pinned=bool(data.get("pinned", False)),
         )
 
 
@@ -776,49 +924,169 @@ class FollowedThreadsPage:
 
 
 @dataclass
-class Notification:
-    id: str
-    created_at: datetime | None
-    actor: User | None
-    kind: str  # "direct_message" | "mention" | "reply" | "room_message"
+@dataclass
+class NotificationMessageReference:
+    """A reference to the message a notification signal points at."""
+
     room: RoomSummary | None = None
     event_id: str = ""
-    in_reply_to_id: str = ""
     thread_root_event_id: str | None = None
 
     @classmethod
-    def parse(cls, data: dict[str, Any]) -> Notification:
+    def parse(cls, data: dict[str, Any] | None) -> NotificationMessageReference | None:
+        if not data:
+            return None
+        return cls(
+            room=RoomSummary.parse(data.get("room")),
+            event_id=data.get("eventId", ""),
+            thread_root_event_id=data.get("threadRootEventId"),
+        )
+
+
+@dataclass
+class NotificationSignal:
+    """The concrete signal that produced a :class:`NotificationOccurrence`.
+
+    ``kind`` names the ``oneof kind`` case set on the signal
+    (``direct_message_received``, ``direct_mention_received``,
+    ``reply_received``, ``role_mention_received``, ``here_mention_received``,
+    ``all_mention_received``, ``followed_thread_activity``,
+    ``followed_room_activity``, ``reaction_received``,
+    ``room_message_received``). ``message`` is the referenced
+    :class:`NotificationMessageReference`.
+    """
+
+    kind: str
+    message: NotificationMessageReference | None
+    role_names: list[str] = field(default_factory=list)
+    emoji: str = ""
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> NotificationSignal | None:
+        if not data:
+            return None
         kind = ""
-        room: RoomSummary | None = None
-        event_id = ""
-        in_reply_to_id = ""
-        thread_root: str | None = None
-        for candidate in ("directMessage", "mention", "reply", "roomMessage"):
+        message: NotificationMessageReference | None = None
+        for candidate in (
+            "directMessageReceived",
+            "directMentionReceived",
+            "replyReceived",
+            "roleMentionReceived",
+            "hereMentionReceived",
+            "allMentionReceived",
+            "followedThreadActivity",
+            "followedRoomActivity",
+            "reactionReceived",
+            "roomMessageReceived",
+        ):
             payload = data.get(candidate)
             if payload is None:
                 continue
             kind = _snake_case(candidate)
-            room = RoomSummary.parse(payload.get("room"))
-            event_id = payload.get("eventId", "")
-            in_reply_to_id = payload.get("inReplyToId", "") if candidate == "reply" else ""
-            thread_root = payload.get("threadRootEventId")
+            message = NotificationMessageReference.parse(payload.get("message"))
             break
+        return cls(
+            kind=kind,
+            message=message,
+            role_names=list(data.get("roleNames") or []),
+            emoji=data.get("emoji", ""),
+        )
+
+
+@dataclass
+class NotificationOccurrence:
+    """One stored notification in the viewer's notification center.
+
+    Replaces the pre-0.5 ``Notification`` message: the old oneof of
+    ``directMessage``/``mention``/``reply``/``roomMessage`` is now the
+    ``signal`` field, and dismissals became read/deletions.
+    """
+
+    id: str
+    created_at: datetime | None
+    actor: User | None
+    signal: NotificationSignal | None = None
+    unread: bool = False
+    expires_at: datetime | None = None
+    attention_level: str = ""
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> NotificationOccurrence | None:
+        if not data:
+            return None
         return cls(
             id=data.get("id", ""),
             created_at=parse_datetime(data.get("createdAt")),
             actor=User.parse(data.get("actor")),
-            kind=kind,
-            room=room,
-            event_id=event_id,
-            in_reply_to_id=in_reply_to_id,
-            thread_root_event_id=thread_root,
+            signal=NotificationSignal.parse(data.get("signal")),
+            unread=bool(data.get("unread", False)),
+            expires_at=parse_datetime(data.get("expiresAt")),
+            attention_level=data.get("attentionLevel", ""),
+        )
+
+
+@dataclass
+class NotificationRoomUnreadCount:
+    room_id: str
+    unread_count: int = 0
+    important_unread_count: int = 0
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> NotificationRoomUnreadCount | None:
+        if not data:
+            return None
+        return cls(
+            room_id=data.get("roomId", ""),
+            unread_count=int(data.get("unreadCount") or 0),
+            important_unread_count=int(data.get("importantUnreadCount") or 0),
         )
 
 
 @dataclass
-class NotificationsPage:
-    notifications: list[Notification] = field(default_factory=list)
+class NotificationPolicy:
+    """Effective notification policy for a scope (server, room group, room)."""
+
+    overrides: dict[str, Any] | None = None
+    effective: dict[str, Any] | None = None
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> NotificationPolicy:
+        data = data or {}
+        return cls(
+            overrides=data.get("overrides"),
+            effective=data.get("effective"),
+        )
+
+
+@dataclass
+class NotificationOccurrencesPage:
+    occurrences: list[NotificationOccurrence] = field(default_factory=list)
     page: Page = field(default_factory=Page)
+    unread_count: int = 0
+    next_expiry_at: datetime | None = None
+    room_unread_counts: list[NotificationRoomUnreadCount] = field(default_factory=list)
+    important_unread_count: int = 0
+
+    @classmethod
+    def parse(cls, data: dict[str, Any]) -> NotificationOccurrencesPage:
+        return cls(
+            occurrences=[
+                o
+                for o in (NotificationOccurrence.parse(n) for n in data.get("occurrences") or [])
+                if o is not None
+            ],
+            page=Page.parse(data.get("page")),
+            unread_count=int(data.get("unreadCount") or 0),
+            next_expiry_at=parse_datetime(data.get("nextExpiryAt")),
+            room_unread_counts=[
+                c
+                for c in (
+                    NotificationRoomUnreadCount.parse(r) for r in data.get("roomUnreadCounts") or []
+                )
+                if c is not None
+            ],
+            important_unread_count=int(data.get("importantUnreadCount") or 0),
+        )
 
 
 @dataclass
@@ -830,9 +1098,7 @@ class NotificationPreference:
     def parse(cls, data: dict[str, Any] | None) -> NotificationPreference:
         data = data or {}
         return cls(
-            level=_parse_enum(
-                NotificationLevel, data.get("level"), NotificationLevel.UNSPECIFIED
-            ),  # type: ignore[arg-type]
+            level=_parse_enum(NotificationLevel, data.get("level"), NotificationLevel.UNSPECIFIED),  # type: ignore[arg-type]
             effective_level=_parse_enum(
                 NotificationLevel,
                 data.get("effectiveLevel"),
@@ -910,9 +1176,7 @@ class ActiveCall:
         return cls(
             call_id=data.get("callId", ""),
             room=RoomSummary.parse(data.get("room")),
-            participants=[
-                ActiveCallParticipant.parse(p) for p in data.get("participants") or []
-            ],
+            participants=[ActiveCallParticipant.parse(p) for p in data.get("participants") or []],
         )
 
 

+ 42 - 32
tests/test_client.py

@@ -468,35 +468,53 @@ async def test_follow_and_unfollow_thread(client):
 # --- Notifications ----------------------------------------------------
 
 
-async def test_list_notifications(client):
-    resp = notifications_pb2.ListNotificationsResponse()
-    n = resp.notifications.add()
-    n.id = "n1"
-    n.created_at.FromJsonString("2026-01-01T00:00:00Z")
-    n.actor.id = "u1"
-    n.actor.login = "alice"
-    n.actor.display_name = "Alice"
-    n.mention.room.id = "r1"
-    n.mention.room.name = "general"
-    n.mention.event_id = "e1"
+async def test_list_notification_occurrences(client):
+    resp = notifications_pb2.ListNotificationOccurrencesResponse()
+    occ = resp.occurrences.add()
+    occ.id = "n1"
+    occ.created_at.FromJsonString("2026-01-01T00:00:00Z")
+    occ.actor.id = "u1"
+    occ.actor.login = "alice"
+    occ.actor.display_name = "Alice"
+    occ.unread = True
+    sig = occ.signal.direct_mention_received
+    sig.message.room.id = "r1"
+    sig.message.room.name = "general"
+    sig.message.event_id = "e1"
     resp.page.total_count = 1
-    _mock_method(client, "notifications", "list_notifications", resp)
+    resp.unread_count = 1
+    _mock_method(client, "notifications", "list_notification_occurrences", resp)
 
     async with client:
-        page = await client.list_notifications()
+        page = await client.list_notification_occurrences()
 
     assert page.page.total_count == 1
-    assert page.notifications[0].kind == "mention"
-    assert page.notifications[0].room is not None
-    assert page.notifications[0].room.name == "general"
+    assert page.unread_count == 1
+    assert page.occurrences[0].signal is not None
+    assert page.occurrences[0].signal.kind == "direct_mention_received"
+    assert page.occurrences[0].signal.message is not None
+    assert page.occurrences[0].signal.message.event_id == "e1"
+    assert page.occurrences[0].signal.message.room is not None
+    assert page.occurrences[0].signal.message.room.name == "general"
 
 
-async def test_dismiss_notification(client):
-    resp = notifications_pb2.DismissNotificationResponse(dismissed=True)
-    _mock_method(client, "notifications", "dismiss_notification", resp)
+async def test_mark_and_delete_notification_occurrence(client):
+    read_resp = notifications_pb2.MarkNotificationReadResponse()
+    read_resp.occurrence.id = "n1"
+    read_resp.occurrence.unread = False
+    _mock_method(client, "notifications", "mark_notification_read", read_resp)
+
+    del_resp = notifications_pb2.DeleteNotificationOccurrenceResponse(deleted=True)
+    _mock_method(client, "notifications", "delete_notification_occurrence", del_resp)
+
+    all_resp = notifications_pb2.DeleteAllNotificationOccurrencesResponse(deleted_count=3)
+    _mock_method(client, "notifications", "delete_all_notification_occurrences", all_resp)
 
     async with client:
-        assert await client.dismiss_notification("n1") is True
+        occ = await client.mark_notification_read("n1")
+        assert occ is not None and occ.unread is False
+        assert await client.delete_notification_occurrence("n1") is True
+        assert await client.delete_all_notification_occurrences() == 3
 
 
 async def test_notification_preference(client):
@@ -511,9 +529,7 @@ async def test_notification_preference(client):
     )
 
     async with client:
-        pref = await client.update_room_notification_preference(
-            "r1", NotificationLevel.MUTED
-        )
+        pref = await client.update_room_notification_preference("r1", NotificationLevel.MUTED)
 
     assert pref.level is NotificationLevel.MUTED
     assert pref.effective_level is NotificationLevel.MUTED
@@ -625,9 +641,7 @@ async def test_asset_upload_end_to_end(client, tmp_path):
     create_resp.upload.size = len(payload)
     create_resp.upload.max_chunk_size = 4096
     create_resp.upload.sha256 = sha
-    create_mock = _mock_method(
-        client, "asset_uploads", "create_upload", create_resp
-    )
+    create_mock = _mock_method(client, "asset_uploads", "create_upload", create_resp)
 
     chunk_resp = asset_uploads_pb2.UploadChunkResponse()
     chunk_resp.upload.upload_id = "up1"
@@ -654,9 +668,7 @@ async def test_asset_upload_end_to_end(client, tmp_path):
     _mock_method(client, "asset_uploads", "complete_upload", complete_resp)
 
     async with client:
-        asset = await client.upload_attachment(
-            "r1", file, content_type="text/plain"
-        )
+        asset = await client.upload_attachment("r1", file, content_type="text/plain")
 
     assert asset.id == "a1"
     assert asset.filename == "greet.txt"
@@ -746,9 +758,7 @@ async def test_admin_update_server_config(client):
     _mock_method(client, "admin_server", "update_server_config", resp)
 
     async with client:
-        config, profile = await client.admin_update_server_config(
-            server_name="MyServer", motd="hi"
-        )
+        config, profile = await client.admin_update_server_config(server_name="MyServer", motd="hi")
 
     assert config.server_name == "MyServer"
     assert profile.version == "0.4.2"

+ 25 - 8
tests/test_realtime.py

@@ -12,7 +12,9 @@ from chattolib.realtime import (
     ChattoRealtimeCloseError,
     ChattoRealtimeError,
     RealtimeEvent,
+    RealtimeProjectionEvent,
     _wrap_event,
+    _wrap_projection_event,
     realtime_url,
 )
 
@@ -26,27 +28,42 @@ def test_realtime_url_http_to_ws():
 
 
 def test_realtime_url_strips_trailing_slash():
-    assert (
-        realtime_url("https://chat.chatto.run/") == "wss://chat.chatto.run/api/realtime"
-    )
+    assert realtime_url("https://chat.chatto.run/") == "wss://chat.chatto.run/api/realtime"
 
 
-def test_wrap_event_message_posted():
+def test_wrap_event_user_typing():
     from chattolib._pb.chatto.realtime.v1 import realtime_pb2
 
     envelope = realtime_pb2.RealtimeEventEnvelope()
     envelope.id = "evt_1"
     envelope.actor_id = "u1"
-    envelope.message_posted.room_id = "r1"
-    envelope.message_posted.message_event_id = "e1"
+    envelope.user_typing.room_id = "r1"
 
     wrapped = _wrap_event(envelope)
     assert isinstance(wrapped, RealtimeEvent)
     assert wrapped.id == "evt_1"
-    assert wrapped.kind == "message_posted"
+    assert wrapped.kind == "user_typing"
     assert wrapped.actor_id == "u1"
     assert wrapped.payload.room_id == "r1"
-    assert wrapped.payload.message_event_id == "e1"
+
+
+def test_wrap_projection_event():
+    from chattolib._pb.chatto.realtime.v1 import realtime_pb2
+
+    envelope = realtime_pb2.RealtimeProjectionEvent()
+    envelope.id = "proj_1"
+    envelope.resume_cursor = "cur_1"
+    op = envelope.operations.add()
+    op.room_remove.room_id = "r1"
+    op2 = envelope.operations.add()
+    op2.reset.SetInParent()
+
+    wrapped = _wrap_projection_event(envelope)
+    assert isinstance(wrapped, RealtimeProjectionEvent)
+    assert wrapped.id == "proj_1"
+    assert wrapped.resume_cursor == "cur_1"
+    assert [o.operation for o in wrapped.operations] == ["room_remove", "reset"]
+    assert wrapped.operations[0].payload.room_id == "r1"
 
 
 def test_wrap_event_presence_changed():

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.