Bladeren bron

Track Chatto 0.4.19

Regenerated proto bindings from upstream. Highlights:

- Realtime is now protocol v2. RealtimeEventEnvelope carries only
  transient events (typing, presence, mention/DM notifications,
  session termination); durable state (messages, rooms, members,
  notifications, calls, presence, threads, room-group layout) is
  delivered as ordered RealtimeProjectionEvent operations, with a
  RealtimeCaughtUp cursor at the replay-to-live boundary. Clients can
  supply a resume_cursor and retained_room_ids on subscribe, and lazily
  materialise a joined room's timeline with RealtimeHydrateRoom.
- LinkPreview gained a rich social_post message
  (SocialPostAuthor/Image/ExternalLink, recursive quoted_post).
- MessageVideoProcessing gained an hls field
  (MessageVideoHLS { master_playlist_url }) for adaptive streaming.
- AdminRoomLayoutService added GetRoom and GetRoomGroup.
- Additive AdminUser.GetMemberResponse fields for role-assignment
  limits; doc/permission tightening across room, member-directory,
  voice-call, and admin-permission services.

Client changes:

- Bumped REALTIME_PROTOCOL_VERSION to 2.
- Extended RealtimeConnection / stream_events with resume_cursor and
  retained_room_ids; added hydrate_room(). RealtimeEvent now also
  covers projection_event and caught_up frames. ChattoRealtimeError
  now carries optional retry_after_ms and room_id.
- Added VideoHLS + SocialPostPreview family dataclasses in types.py
  and wired them through VideoProcessing.parse / LinkPreview.parse.

Bumped version 0.4.11 -> 0.4.19 to align with the Chatto server release.
TheCodemancer 3 maanden geleden
bovenliggende
commit
7feb709388

+ 3 - 2
CLAUDE.md

@@ -95,14 +95,14 @@ generated protobuf bindings.
 | Assets | `AssetService` | `GetAsset`, `BatchGetAssets` |
 | Asset uploads | `AssetUploadService` | `CreateUpload`, `UploadChunk`, `GetUpload`, `CompleteUpload`, `CancelUpload`. `upload_attachment(room, path)` helper computes SHA-256, chunks, and completes in one call. |
 | Admin: server | `chatto.admin.v1.AdminServerService` | `GetServerConfig`, `UpdateServerConfig`, `UploadServerLogo`, `DeleteServerLogo`, `UploadServerBanner`, `DeleteServerBanner`, `GetServerSecurityConfig`, `UpdateBlockedUsernames` |
-| Admin: room layout | `chatto.admin.v1.AdminRoomLayoutService` | `ListRoomGroups`, `Create/Update/Delete/ReorderRoomGroup(s)`, `MoveRoomToGroup`, `ReorderSidebarItemsInGroup`, `Create/Update/Delete/MoveSidebarLink(ToGroup)` |
+| Admin: room layout | `chatto.admin.v1.AdminRoomLayoutService` | `GetRoom`, `GetRoomGroup`, `ListRoomGroups`, `Create/Update/Delete/ReorderRoomGroup(s)`, `MoveRoomToGroup`, `ReorderSidebarItemsInGroup`, `Create/Update/Delete/MoveSidebarLink(ToGroup)` |
 | Admin: users | `chatto.admin.v1.AdminUserService` | `ListMembers`, `GetMember`, `BatchGetMembers`, `AssignRole`, `RevokeRole`, `UpdateUser`, `UpdateUserPassword`, `ClearUsernameCooldown`, `DeleteUser` |
 | Admin: roles | `chatto.admin.v1.AdminRoleService` | `ListRoles`, `GetRole`, `CreateRole`, `UpdateRole`, `DeleteRole`, `ReorderRoles` |
 | Admin: event log | `chatto.admin.v1.AdminEventLogService` | `ListEvents`, `ListEventTypes`, `GetEvent` (raw response) |
 | Admin: diagnostics | `chatto.admin.v1.AdminDiagnosticsService` | `GetSystemInfo` (raw response) |
 | Admin: permissions | `chatto.admin.v1.AdminPermissionService` | `GetRole/UserPermissionMatrix`, `ListRole/UserPermissionDecisions`, `ExplainPermissions`, `SetRolePermission`, `SetUserPermission` (raw responses where the permission shape is server-version-dependent) |
 | Voice calls | `VoiceCallService` | `ListActiveCalls`, `GetActiveCall`, `BatchGetActiveCalls`, `JoinCall`, `LeaveCall`, `GetCallToken` |
-| Realtime (WS) | `chatto.realtime.v1` protobuf WS | `stream_events(client)` / `RealtimeConnection` — full frame set: hello, subscribe, event, heartbeat, ping/pong, error, close |
+| Realtime (WS) | `chatto.realtime.v1` protobuf WS (protocol v2) | `stream_events(client)` / `RealtimeConnection` — full frame set: hello, subscribe (with `resume_cursor` + `retained_room_ids`), event, projection_event, caught_up, heartbeat, ping/pong, error, close, `hydrate_room` |
 
 ### Naming conventions
 
@@ -123,3 +123,4 @@ generated protobuf bindings.
 - Server profile fields are no longer inside `server.profile`; the shape is now `ServerPublicProfile` returned by `ServerDiscoveryService.GetServer`.
 - No more `motd` on the public profile; it is a separate authenticated RPC (`ServerService.GetMotd`).
 - Room groups can contain `SidebarLink` items (not just rooms). `RoomGroup.sidebar_links` exposes them.
+- Realtime is protocol **v2** as of Chatto 0.4.19. `RealtimeEventEnvelope` now only carries **transient** events (`user_typing`, `presence_changed`, `mention_notification`, `new_direct_message_notification`, `session_terminated`). All durable state (messages, rooms, members, notifications, calls, presence, threads, room-groups) is delivered as `RealtimeProjectionEvent` frames whose operations mutate the caller's server-scoped projection. `stream_events` surfaces both, plus synthetic `caught_up` events carrying the resume cursor; `RealtimeConnection.hydrate_room(room_id)` lazily materialises a joined room's timeline window.

+ 13 - 2
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.
@@ -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.

+ 8 - 7
proto/chatto/admin/v1/permissions.proto

@@ -209,7 +209,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,11 +221,11 @@ 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;
@@ -301,9 +301,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.
@@ -319,7 +319,8 @@ service AdminPermissionService {
   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.
   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.

+ 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];

+ 10 - 0
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.

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

@@ -66,7 +66,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 {

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

@@ -53,7 +53,8 @@ message RoomSummary {
 
 // Request to create a channel room.
 message CreateRoomRequest {
-  // Required. Name of the new channel room.
+  // Required. NFC-normalized name of the new channel room. Names accept
+  // Unicode letters, decimal digits, hyphens, and underscores.
   string name = 1 [(buf.validate.field).string = {
     min_len: 1
     max_len: 30
@@ -77,7 +78,8 @@ 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 NFC-normalized room name, when changing it. Names accept Unicode
+  // letters, decimal digits, hyphens, and underscores.
   optional string name = 2 [(buf.validate.field).string = {
     min_len: 1
     max_len: 30
@@ -351,13 +353,16 @@ service RoomService {
   // 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

+ 3 - 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.
@@ -57,10 +57,10 @@ service VoiceCallService {
   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;

+ 330 - 383
proto/chatto/realtime/v1/realtime.proto

@@ -3,26 +3,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 +40,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 +52,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 +64,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 +82,313 @@ 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 {
+  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 the viewer's current pending-notification page and room counts.
+    RealtimeProjectionNotificationsReplace notifications_replace = 13;
+    // 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;
+  }
+}
+
+// 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;
+}
+
+// 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;
+  // Current pending notifications targeting this room for the viewer.
+  uint32 viewer_notification_count = 3;
+  // 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 RealtimeProjectionNotificationsReplace {
+  // Newest pending notifications and total pending count.
+  chatto.api.v1.ListNotificationsResponse page = 1;
+  // Complete current counts for rooms with pending notifications.
+  repeated chatto.api.v1.RoomNotificationCount room_counts = 2;
+  // Live transition that caused this replacement, when one exists. Bootstrap,
+  // replay reconciliation, and compacted reset replacements omit this field.
+  optional RealtimeProjectionNotificationChange change = 3;
+}
+
+// One live notification transition accompanying authoritative current state.
+//
+// This metadata exists for one-shot presentation effects such as sounds. The
+// enclosing replacement remains the canonical notification state.
+message RealtimeProjectionNotificationChange {
+  RealtimeProjectionNotificationAction action = 1;
+  string notification_id = 2;
+  // True when a created notification must not produce an alert.
+  bool silent = 3;
+}
+
+// Kind of live notification transition.
+enum RealtimeProjectionNotificationAction {
+  REALTIME_PROJECTION_NOTIFICATION_ACTION_UNSPECIFIED = 0;
+  REALTIME_PROJECTION_NOTIFICATION_ACTION_CREATED = 1;
+  REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED = 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 +419,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 +439,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 87;
+  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";
 
   // Stable event ID.
   string id = 1;
@@ -141,80 +453,11 @@ 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.
     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.
@@ -224,66 +467,6 @@ message RealtimeEventEnvelope {
   }
 }
 
-// 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
@@ -306,242 +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

+ 1 - 1
pyproject.toml

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

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


+ 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,

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


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


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


File diff suppressed because it is too large
+ 8 - 0
src/chattolib/_pb/chatto/realtime/v1/realtime_pb2.py


+ 129 - 13
src/chattolib/realtime.py

@@ -3,7 +3,21 @@
 Chatto exposes a binary-protobuf realtime channel at ``/api/realtime`` (see
 ``proto/chatto/realtime/v1/realtime.proto``). This module speaks that
 protocol: it opens the WebSocket, exchanges ``hello`` frames, subscribes to
-the caller's authorized live-event stream, and yields decoded events.
+the caller's authorized server-projection stream, and yields decoded events.
+
+Chatto 0.4.19 introduced protocol version 2, splitting realtime delivery into
+two channels multiplexed over the same WebSocket:
+
+* **Transient events** (``user_typing``, ``presence_changed``, mention/DM/reply
+  notifications, ``session_terminated``) carried inside
+  ``RealtimeEventEnvelope`` — never replayed.
+* **Durable projection events** carried by ``RealtimeProjectionEvent``, which
+  contains ordered operations that mutate the caller's server-scoped
+  projection.
+
+Both are yielded through the same iterator as :class:`RealtimeEvent`;
+``kind`` names the frame or envelope case. A ``caught_up`` cursor is also
+surfaced as a synthetic event so callers can persist the resume cursor.
 
 Usage::
 
@@ -30,16 +44,26 @@ if TYPE_CHECKING:
     from chattolib.client import ChattoClient
 
 REALTIME_PATH = "/api/realtime"
-REALTIME_PROTOCOL_VERSION = 1
+REALTIME_PROTOCOL_VERSION = 2
 
 
 class ChattoRealtimeError(ChattoError):
     """Server returned a protocol error over the realtime WebSocket."""
 
-    def __init__(self, code: str, message: str, *, fatal: bool = False) -> None:
+    def __init__(
+        self,
+        code: str,
+        message: str,
+        *,
+        fatal: bool = False,
+        retry_after_ms: int | None = None,
+        room_id: str | None = None,
+    ) -> None:
         self.code = code
         self.message = message
         self.fatal = fatal
+        self.retry_after_ms = retry_after_ms
+        self.room_id = room_id
         super().__init__(f"{code}: {message}")
 
 
@@ -83,14 +107,21 @@ class ServerHello:
 
 @dataclass
 class RealtimeEvent:
-    """One live event delivered over the realtime WebSocket.
+    """One frame delivered over the realtime WebSocket.
 
-    ``kind`` names the ``oneof event`` case set on the envelope
-    (``message_posted``, ``reaction_added``, ``presence_changed``, …).
+    ``kind`` names either the ``oneof event`` case on
+    ``RealtimeEventEnvelope`` (``user_typing``, ``presence_changed``,
+    ``mention_notification``, …) or the top-level server-frame case for
+    non-envelope deliveries: ``projection_event`` and ``caught_up``.
     ``payload`` is the concrete protobuf sub-message; access its fields
-    directly (e.g. ``event.payload.room_id``). Callers that want to hydrate
-    the referenced resource should follow the hydration hints documented on
-    each event message in ``realtime.proto``.
+    directly (e.g. ``event.payload.room_id``).
+
+    For transient envelope events, ``id`` / ``created_at`` / ``actor_id`` come
+    from :class:`RealtimeEventEnvelope`. For ``projection_event`` frames the
+    same fields are copied from :class:`RealtimeProjectionEvent` (which also
+    carries an optional ``resume_cursor``). For ``caught_up`` frames, ``id``
+    is empty; ``payload`` is the ``RealtimeCaughtUp`` message whose
+    ``cursor`` field is the durable resume cursor.
     """
 
     id: str
@@ -98,7 +129,7 @@ class RealtimeEvent:
     actor_id: str | None
     kind: str
     payload: Any
-    raw: Any  # the full RealtimeEventEnvelope
+    raw: Any  # the full RealtimeEventEnvelope or RealtimeServerFrame
 
 
 class RealtimeConnection:
@@ -114,9 +145,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) if retained_room_ids else []
         self._ws: Any = None
         self._server_hello: ServerHello | None = None
 
@@ -174,6 +209,10 @@ class RealtimeConnection:
 
         subscribe = realtime_pb2.RealtimeClientFrame()
         subscribe.subscribe_events.SetInParent()
+        if self._resume_cursor is not None:
+            subscribe.subscribe_events.resume_cursor = self._resume_cursor
+        if self._retained_room_ids:
+            subscribe.subscribe_events.retained_room_ids.extend(self._retained_room_ids)
         await self._ws.send(subscribe.SerializeToString())
 
     async def close(self) -> None:
@@ -194,6 +233,22 @@ class RealtimeConnection:
         frame.ping.nonce = nonce
         await self._ws.send(frame.SerializeToString())
 
+    async def hydrate_room(self, room_id: str) -> None:
+        """Request lazy materialisation of one joined room's recent timeline.
+
+        The server responds on the same projection stream with a
+        ``room_timeline_replace`` operation for ``room_id``. Repeats are
+        idempotent. Errors surface as non-fatal ``RealtimeError`` frames
+        whose ``room_id`` matches.
+        """
+        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]:
         """Yield decoded live events until the connection closes."""
         if self._ws is None:
@@ -212,11 +267,23 @@ class RealtimeConnection:
             case = frame.WhichOneof("frame")
             if case == "event":
                 yield _wrap_event(frame.event)
+            elif case == "projection_event":
+                yield _wrap_projection_event(frame)
+            elif case == "caught_up":
+                yield _wrap_caught_up(frame)
             elif case in ("heartbeat", "pong", "subscribed"):
                 continue
             elif case == "error":
                 err = frame.error
-                exc = ChattoRealtimeError(err.code, err.message, fatal=err.fatal)
+                exc = ChattoRealtimeError(
+                    err.code,
+                    err.message,
+                    fatal=err.fatal,
+                    retry_after_ms=(
+                        err.retry_after_ms if err.HasField("retry_after_ms") else None
+                    ),
+                    room_id=err.room_id if err.HasField("room_id") else None,
+                )
                 if err.fatal:
                     raise exc
                 # Non-fatal errors are surfaced but the stream continues.
@@ -243,7 +310,15 @@ def _raise_for_control(frame: Any) -> None:
     case = frame.WhichOneof("frame")
     if case == "error":
         err = frame.error
-        raise ChattoRealtimeError(err.code, err.message, fatal=err.fatal)
+        raise ChattoRealtimeError(
+            err.code,
+            err.message,
+            fatal=err.fatal,
+            retry_after_ms=(
+                err.retry_after_ms if err.HasField("retry_after_ms") else None
+            ),
+            room_id=err.room_id if err.HasField("room_id") else None,
+        )
     if case == "close":
         close = frame.close
         raise ChattoRealtimeCloseError(
@@ -273,18 +348,59 @@ def _wrap_event(envelope: Any) -> RealtimeEvent:
     )
 
 
+def _wrap_projection_event(frame: Any) -> RealtimeEvent:
+    """Wrap a top-level projection_event frame as a :class:`RealtimeEvent`."""
+    pe = frame.projection_event
+    created_at = None
+    if pe.HasField("created_at"):
+        created_at = parse_datetime(pe.created_at.ToJsonString())
+    actor_id: str | None = pe.actor_id if pe.HasField("actor_id") else None
+    return RealtimeEvent(
+        id=pe.id,
+        created_at=created_at,
+        actor_id=actor_id,
+        kind="projection_event",
+        payload=pe,
+        raw=frame,
+    )
+
+
+def _wrap_caught_up(frame: Any) -> RealtimeEvent:
+    """Wrap a caught_up frame as a synthetic :class:`RealtimeEvent`."""
+    return RealtimeEvent(
+        id="",
+        created_at=None,
+        actor_id=None,
+        kind="caught_up",
+        payload=frame.caught_up,
+        raw=frame,
+    )
+
+
 async def stream_events(
     client: ChattoClient,
     *,
     protocol_version: int = REALTIME_PROTOCOL_VERSION,
+    resume_cursor: str | None = None,
+    retained_room_ids: list[str] | None = None,
 ) -> AsyncIterator[RealtimeEvent]:
     """Open a realtime connection and yield events until the server closes.
 
+    Pass ``resume_cursor`` from the most recently observed ``caught_up`` (or
+    ``projection_event.resume_cursor``) to resume a durable stream, and
+    ``retained_room_ids`` to declare which joined rooms' timeline windows the
+    client still retains locally.
+
     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():

+ 107 - 0
src/chattolib/types.py

@@ -501,6 +501,19 @@ class VideoVariant:
         )
 
 
+@dataclass
+class VideoHLS:
+    """HLS adaptive-streaming metadata for a processed video."""
+
+    master_playlist_url: AssetUrl | None = None
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> VideoHLS | None:
+        if not data:
+            return None
+        return cls(master_playlist_url=AssetUrl.parse(data.get("masterPlaylistUrl")))
+
+
 @dataclass
 class VideoProcessing:
     status: VideoProcessingStatus = VideoProcessingStatus.UNSPECIFIED
@@ -511,6 +524,7 @@ class VideoProcessing:
     reason_code: str = ""
     thumbnail_asset_url: AssetUrl | None = None
     variants: list[VideoVariant] = field(default_factory=list)
+    hls: VideoHLS | None = None
 
     @classmethod
     def parse(cls, data: dict[str, Any] | None) -> VideoProcessing | None:
@@ -529,6 +543,7 @@ class VideoProcessing:
             reason_code=data.get("reasonCode", ""),
             thumbnail_asset_url=AssetUrl.parse(data.get("thumbnailAssetUrl")),
             variants=[VideoVariant.parse(v) for v in data.get("variants") or []],
+            hls=VideoHLS.parse(data.get("hls")),
         )
 
 
@@ -557,6 +572,96 @@ 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 | None = None
+    width: int | None = None
+    height: int | None = None
+
+    @classmethod
+    def parse(cls, data: dict[str, Any]) -> SocialPostImage:
+        width = data.get("width")
+        height = data.get("height")
+        return cls(
+            url=data.get("url", ""),
+            asset_id=data.get("assetId", ""),
+            alt=data.get("alt"),
+            width=int(width) if width is not None else None,
+            height=int(height) if height is not None else None,
+        )
+
+
+@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 = ""
+    published_at: datetime | None = None
+    images: list[SocialPostImage] = field(default_factory=list)
+    external_link: SocialPostExternalLink | None = None
+    content_warning: str | None = None
+    url: str = ""
+    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=[SocialPostImage.parse(i) for i in data.get("images") or []],
+            external_link=SocialPostExternalLink.parse(data.get("externalLink")),
+            content_warning=data.get("contentWarning"),
+            url=data.get("url", ""),
+            quoted_post=cls.parse(data.get("quotedPost")),
+        )
+
+
 @dataclass
 class LinkPreview:
     url: str
@@ -567,6 +672,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 +687,7 @@ class LinkPreview:
             site_name=data.get("siteName"),
             embed_type=data.get("embedType"),
             embed_id=data.get("embedId"),
+            social_post=SocialPostPreview.parse(data.get("socialPost")),
         )
 
 

+ 50 - 7
tests/test_realtime.py

@@ -31,22 +31,22 @@ def test_realtime_url_strips_trailing_slash():
     )
 
 
-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"
+    envelope.user_typing.thread_root_event_id = "t1"
 
     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"
+    assert wrapped.payload.thread_root_event_id == "t1"
 
 
 def test_wrap_event_presence_changed():
@@ -100,14 +100,14 @@ def test_frame_roundtrip_hello():
     from chattolib._pb.chatto.realtime.v1 import realtime_pb2
 
     frame = realtime_pb2.RealtimeClientFrame()
-    frame.hello.protocol_version = 1
+    frame.hello.protocol_version = 2
     frame.hello.bearer_token = "cht_abc"
     wire = frame.SerializeToString()
 
     parsed = realtime_pb2.RealtimeClientFrame()
     parsed.ParseFromString(wire)
     assert parsed.WhichOneof("frame") == "hello"
-    assert parsed.hello.protocol_version == 1
+    assert parsed.hello.protocol_version == 2
     assert parsed.hello.bearer_token == "cht_abc"
 
 
@@ -116,6 +116,7 @@ def test_frame_roundtrip_hello():
     [
         ("subscribe_events", "subscribe_events"),
         ("ping", "ping"),
+        ("hydrate_room", "hydrate_room"),
     ],
 )
 def test_client_frame_oneof_cases(case: str, attr: str):
@@ -124,3 +125,45 @@ def test_client_frame_oneof_cases(case: str, attr: str):
     frame = realtime_pb2.RealtimeClientFrame()
     getattr(frame, attr).SetInParent()
     assert frame.WhichOneof("frame") == case
+
+
+def test_wrap_projection_event_carries_resume_cursor():
+    from chattolib._pb.chatto.realtime.v1 import realtime_pb2
+    from chattolib.realtime import _wrap_projection_event
+
+    frame = realtime_pb2.RealtimeServerFrame()
+    frame.projection_event.id = "pe_1"
+    frame.projection_event.actor_id = "u1"
+    frame.projection_event.resume_cursor = "cursor_xyz"
+    frame.projection_event.operations.add().reset.SetInParent()
+
+    wrapped = _wrap_projection_event(frame)
+    assert wrapped.kind == "projection_event"
+    assert wrapped.id == "pe_1"
+    assert wrapped.actor_id == "u1"
+    assert wrapped.payload.resume_cursor == "cursor_xyz"
+    assert len(wrapped.payload.operations) == 1
+
+
+def test_wrap_caught_up_exposes_cursor():
+    from chattolib._pb.chatto.realtime.v1 import realtime_pb2
+    from chattolib.realtime import _wrap_caught_up
+
+    frame = realtime_pb2.RealtimeServerFrame()
+    frame.caught_up.cursor = "cursor_live_boundary"
+
+    wrapped = _wrap_caught_up(frame)
+    assert wrapped.kind == "caught_up"
+    assert wrapped.id == ""
+    assert wrapped.payload.cursor == "cursor_live_boundary"
+
+
+def test_error_exception_carries_hydrate_hints():
+    exc = ChattoRealtimeError(
+        "too_many_retained_rooms",
+        "cap exceeded",
+        retry_after_ms=1000,
+        room_id="r1",
+    )
+    assert exc.retry_after_ms == 1000
+    assert exc.room_id == "r1"

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