Skip to main content

mail4agent_messenger/wire/
sync.rs

1//! `GET /sync` response shape — research doc §1.4, refined by the server
2//! plan's §3 (`/sync` algorithm) and manager decision #1 (spec v1.19).
3//!
4//! Every field below is either mandatory (`next_batch` only) or defaulted,
5//! so a maximally sparse incremental sync response (`{"next_batch": "..."}`,
6//! the shape an empty long-poll wake returns per server plan §3.1) parses
7//! to a [`SyncResponse`] with every section empty rather than failing or
8//! requiring a caller to special-case "field absent". `presence` is
9//! deliberately not modeled at all — research doc §1.6/§2 item 4 and the
10//! server plan both drop room-broadcast presence for v1.
11
12use crate::error::MessengerError;
13use crate::ids::{RoomId, UserId};
14use crate::wire::events::{BasicEvent, RawEvent, StrippedStateEvent, ToDeviceEvent};
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17
18/// Parses a raw `/sync` response body into a [`SyncResponse`].
19pub fn parse_sync_response(body: &[u8]) -> Result<SyncResponse, MessengerError> {
20    Ok(serde_json::from_slice(body)?)
21}
22
23/// A room's `m.heroes`/member-count summary (research doc §1.4, server plan
24/// §3.4).
25#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
26pub struct RoomSummary {
27    /// Up to five other members' user ids, used to synthesize a display
28    /// name for a room with no `m.room.name`.
29    #[serde(default, rename = "m.heroes")]
30    pub heroes: Vec<UserId>,
31    /// Total joined member count, if the server chose to include it (spec
32    /// makes this optional; the server plan always computes it, but a
33    /// caller of this type should not assume that of every server).
34    #[serde(default, rename = "m.joined_member_count", skip_serializing_if = "Option::is_none")]
35    pub joined_member_count: Option<u64>,
36    /// Total invited member count, same optionality as
37    /// `joined_member_count`.
38    #[serde(default, rename = "m.invited_member_count", skip_serializing_if = "Option::is_none")]
39    pub invited_member_count: Option<u64>,
40}
41
42/// A room's `state.events` block: full current state on an initial or
43/// `limited` sync, or the delta since `since` on a non-limited incremental
44/// sync (research doc §1.4, server plan §3.3's lazy-loading + gap rule).
45#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
46pub struct StateSection {
47    /// The state events themselves.
48    #[serde(default)]
49    pub events: Vec<RawEvent>,
50}
51
52/// A room's `timeline` block.
53#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
54pub struct TimelineSection {
55    /// Timeline events in this window, oldest first.
56    #[serde(default)]
57    pub events: Vec<RawEvent>,
58    /// `true` when there is a gap between `since` and this window — the
59    /// client fell behind, or this is the room's first sync (research doc
60    /// §1.4, server plan §3.2). A `limited` window's accompanying `state`
61    /// includes the gap-rule membership backfill (server plan §3.3).
62    #[serde(default)]
63    pub limited: bool,
64    /// The pagination token for `GET /rooms/{roomId}/messages?from=
65    /// <prev_batch>&dir=b` to page further back than this window. Absent
66    /// only when the window covers the room's entire history.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub prev_batch: Option<String>,
69}
70
71/// A room's `ephemeral.events` block: `m.typing`/`m.receipt`, batched per
72/// sync tick rather than streamed (research doc §1.6, §2).
73#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
74pub struct EphemeralSection {
75    /// The ephemeral events themselves.
76    #[serde(default)]
77    pub events: Vec<BasicEvent>,
78}
79
80/// An `account_data.events` block — used both globally (top level of
81/// [`SyncResponse`]) and per room ([`JoinedRoom::account_data`]).
82#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
83pub struct AccountDataSection {
84    /// The account-data events themselves.
85    #[serde(default)]
86    pub events: Vec<BasicEvent>,
87}
88
89/// A room's `unread_notifications` block (research doc §1.4, server plan
90/// §3.5). `highlight_count` is server-computed and, per the server plan,
91/// this server always reports `0` for it (no push-rule engine in v1) — this
92/// type only models the wire shape, it does not assume either field's
93/// value.
94#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
95pub struct UnreadNotifications {
96    /// Events since the caller's own read receipt, excluding their own.
97    #[serde(default)]
98    pub notification_count: u64,
99    /// The subset of `notification_count` that would trigger a highlight.
100    /// Always `<= notification_count`.
101    #[serde(default)]
102    pub highlight_count: u64,
103}
104
105/// One room this account is (or was) a member of, under `rooms.join`.
106#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
107pub struct JoinedRoom {
108    /// The room's summary (heroes, member counts).
109    #[serde(default)]
110    pub summary: RoomSummary,
111    /// The room's state, per `StateSection`'s own doc for what's included.
112    #[serde(default)]
113    pub state: StateSection,
114    /// The room's timeline window.
115    #[serde(default)]
116    pub timeline: TimelineSection,
117    /// Ephemeral events (typing, receipts) for this room.
118    #[serde(default)]
119    pub ephemeral: EphemeralSection,
120    /// Room-scoped account data (e.g. `m.fully_read`).
121    #[serde(default)]
122    pub account_data: AccountDataSection,
123    /// Unread notification counters for this room.
124    #[serde(default)]
125    pub unread_notifications: UnreadNotifications,
126}
127
128/// The stripped-state preview block under `rooms.invite`.
129#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
130pub struct InviteStateSection {
131    /// The stripped state events themselves.
132    #[serde(default)]
133    pub events: Vec<StrippedStateEvent>,
134}
135
136/// One room this account has been invited to, under `rooms.invite`.
137#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
138pub struct InvitedRoom {
139    /// A stripped preview of the room's state, enough to render an invite
140    /// without having joined.
141    #[serde(default)]
142    pub invite_state: InviteStateSection,
143}
144
145/// One room this account has left (or been banned/kicked from), under
146/// `rooms.leave`.
147#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
148pub struct LeftRoom {
149    /// State as of leaving (or the delta since `since`, for an account that
150    /// left before `since` and is only now being reported due to a gap).
151    #[serde(default)]
152    pub state: StateSection,
153    /// Timeline up to the point of leaving.
154    #[serde(default)]
155    pub timeline: TimelineSection,
156    /// Room-scoped account data as of leaving.
157    #[serde(default)]
158    pub account_data: AccountDataSection,
159}
160
161/// The `rooms` block: every room this account has any relationship with,
162/// partitioned by that relationship.
163#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
164pub struct RoomsSection {
165    /// Rooms currently joined.
166    #[serde(default)]
167    pub join: BTreeMap<RoomId, JoinedRoom>,
168    /// Rooms currently invited to.
169    #[serde(default)]
170    pub invite: BTreeMap<RoomId, InvitedRoom>,
171    /// Rooms left (or whose leave/ban/kick happened since `since`).
172    #[serde(default)]
173    pub leave: BTreeMap<RoomId, LeftRoom>,
174}
175
176/// The `to_device.events` block: to-device delivery, batched through the
177/// same `/sync` channel as everything else (research doc §3.3).
178#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
179pub struct ToDeviceSection {
180    /// The to-device events themselves.
181    #[serde(default)]
182    pub events: Vec<ToDeviceEvent>,
183}
184
185/// The `device_lists` block: which users (sharing a joined room with the
186/// caller) had a device-list change since `since` (research doc §3.2).
187#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
188pub struct DeviceListsSection {
189    /// Users whose device list changed.
190    #[serde(default)]
191    pub changed: Vec<UserId>,
192    /// Users who no longer share any room with the caller as of this sync,
193    /// and so are no longer tracked.
194    #[serde(default)]
195    pub left: Vec<UserId>,
196}
197
198/// A parsed `GET /sync` response (research doc §1.4). See the module doc
199/// for why every field but `next_batch` is defaulted.
200#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
201pub struct SyncResponse {
202    /// The cursor to pass as `since` on the next call.
203    pub next_batch: String,
204    /// Rooms this account has any relationship with.
205    #[serde(default)]
206    pub rooms: RoomsSection,
207    /// Global (not room-scoped) account data.
208    #[serde(default)]
209    pub account_data: AccountDataSection,
210    /// To-device events delivered to this device.
211    #[serde(default)]
212    pub to_device: ToDeviceSection,
213    /// Device-list change notifications for users sharing a room with this
214    /// account.
215    #[serde(default)]
216    pub device_lists: DeviceListsSection,
217    /// Remaining one-time-key count per algorithm (research doc §3.2) —
218    /// keyed by algorithm name (e.g. `"signed_curve25519"`).
219    #[serde(default)]
220    pub device_one_time_keys_count: BTreeMap<String, u64>,
221    /// Fallback-key algorithms this device has published that the server
222    /// has not yet consumed one of (research doc §3.2) — a non-empty entry
223    /// here means "unused", an *empty* list means "top one up".
224    #[serde(default)]
225    pub device_unused_fallback_key_types: Vec<String>,
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::ids::EventId;
232    use crate::wire::events::RoomKeyContent;
233
234    #[test]
235    fn parses_incremental_sync_with_only_next_batch() {
236        let body = br#"{"next_batch":"s72595_4483_1934"}"#;
237        let response = parse_sync_response(body).expect("sparse sync response parses");
238        assert_eq!(response.next_batch, "s72595_4483_1934");
239        assert!(response.rooms.join.is_empty());
240        assert!(response.rooms.invite.is_empty());
241        assert!(response.rooms.leave.is_empty());
242        assert!(response.to_device.events.is_empty());
243        assert!(response.device_lists.changed.is_empty());
244        assert!(response.device_one_time_keys_count.is_empty());
245        assert!(response.device_unused_fallback_key_types.is_empty());
246    }
247
248    #[test]
249    fn parses_sync_response_with_limited_timeline() {
250        let body = serde_json::json!({
251            "next_batch": "s999",
252            "rooms": {
253                "join": {
254                    "!room:example.org": {
255                        "summary": {
256                            "m.heroes": ["@bob:example.org"],
257                            "m.joined_member_count": 3,
258                            "m.invited_member_count": 0
259                        },
260                        "state": { "events": [] },
261                        "timeline": {
262                            "events": [
263                                {
264                                    "event_id": "$1:example.org",
265                                    "type": "m.room.message",
266                                    "sender": "@alice:example.org",
267                                    "origin_server_ts": 1_700_000_000_000i64,
268                                    "content": { "msgtype": "m.text", "body": "hi" }
269                                }
270                            ],
271                            "limited": true,
272                            "prev_batch": "t111_222"
273                        },
274                        "ephemeral": { "events": [] },
275                        "account_data": { "events": [] },
276                        "unread_notifications": { "notification_count": 2, "highlight_count": 0 }
277                    }
278                }
279            }
280        })
281        .to_string();
282
283        let response = parse_sync_response(body.as_bytes()).expect("valid sync response");
284        assert_eq!(response.next_batch, "s999");
285        let room_id = RoomId::parse("!room:example.org").expect("valid room id");
286        let room = response.rooms.join.get(&room_id).expect("room present");
287        assert!(room.timeline.limited);
288        assert_eq!(room.timeline.prev_batch.as_deref(), Some("t111_222"));
289        assert_eq!(room.timeline.events.len(), 1);
290        assert_eq!(room.timeline.events[0].event_type, "m.room.message");
291        assert_eq!(room.summary.joined_member_count, Some(3));
292        assert_eq!(room.unread_notifications.notification_count, 2);
293        assert_eq!(room.unread_notifications.highlight_count, 0);
294    }
295
296    #[test]
297    fn parses_sync_response_with_to_device_events() {
298        let body = serde_json::json!({
299            "next_batch": "s2",
300            "to_device": {
301                "events": [
302                    {
303                        "sender": "@bob:example.org",
304                        "type": "m.room_key",
305                        "content": {
306                            "algorithm": "m.megolm.v1.aes-sha2",
307                            "room_id": "!r:example.org",
308                            "session_id": "session-abc",
309                            "session_key": "session-key-b64"
310                        }
311                    }
312                ]
313            },
314            "device_one_time_keys_count": { "signed_curve25519": 42 },
315            "device_unused_fallback_key_types": []
316        })
317        .to_string();
318
319        let response = parse_sync_response(body.as_bytes()).expect("valid sync response");
320        assert_eq!(response.to_device.events.len(), 1);
321        let event = &response.to_device.events[0];
322        assert_eq!(event.event_type, "m.room_key");
323        let content: RoomKeyContent = serde_json::from_value(event.content.clone()).expect("valid room_key content");
324        assert_eq!(content.session_id, "session-abc");
325        assert_eq!(content.room_id, RoomId::parse("!r:example.org").expect("valid room id"));
326        assert_eq!(response.device_one_time_keys_count.get("signed_curve25519"), Some(&42));
327        assert!(response.device_unused_fallback_key_types.is_empty());
328    }
329
330    #[test]
331    fn parses_invite_and_leave_room_sections() {
332        let body = serde_json::json!({
333            "next_batch": "s3",
334            "rooms": {
335                "invite": {
336                    "!invited:example.org": {
337                        "invite_state": {
338                            "events": [
339                                {
340                                    "type": "m.room.member",
341                                    "state_key": "@me:example.org",
342                                    "sender": "@bob:example.org",
343                                    "content": { "membership": "invite" }
344                                }
345                            ]
346                        }
347                    }
348                },
349                "leave": {
350                    "!left:example.org": {
351                        "timeline": { "events": [] },
352                        "state": { "events": [] }
353                    }
354                }
355            }
356        })
357        .to_string();
358
359        let response = parse_sync_response(body.as_bytes()).expect("valid sync response");
360        let invited_id = RoomId::parse("!invited:example.org").expect("valid room id");
361        let invited = response.rooms.invite.get(&invited_id).expect("invited room present");
362        assert_eq!(invited.invite_state.events.len(), 1);
363        assert_eq!(invited.invite_state.events[0].state_key, "@me:example.org");
364
365        let left_id = RoomId::parse("!left:example.org").expect("valid room id");
366        assert!(response.rooms.leave.contains_key(&left_id));
367    }
368
369    #[test]
370    fn ephemeral_and_account_data_events_use_the_basic_shape() {
371        let body = serde_json::json!({
372            "next_batch": "s4",
373            "rooms": {
374                "join": {
375                    "!room:example.org": {
376                        "ephemeral": {
377                            "events": [
378                                { "type": "m.typing", "content": { "user_ids": ["@alice:example.org"] } }
379                            ]
380                        },
381                        "account_data": {
382                            "events": [
383                                { "type": "m.fully_read", "content": { "event_id": "$last:example.org" } }
384                            ]
385                        }
386                    }
387                }
388            }
389        })
390        .to_string();
391
392        let response = parse_sync_response(body.as_bytes()).expect("valid sync response");
393        let room_id = RoomId::parse("!room:example.org").expect("valid room id");
394        let room = response.rooms.join.get(&room_id).expect("room present");
395        assert_eq!(room.ephemeral.events[0].event_type, "m.typing");
396        let fully_read: crate::wire::events::FullyReadContent =
397            serde_json::from_value(room.account_data.events[0].content.clone()).expect("valid fully_read content");
398        assert_eq!(fully_read.event_id, EventId::parse("$last:example.org").expect("valid event id"));
399    }
400}