Skip to main content

tuwunel_api/client/sync/v5/
rooms.rs

1mod bump_stamp;
2mod heroes;
3
4#[cfg(test)]
5mod tests;
6
7use std::collections::{BTreeMap, HashSet};
8
9use futures::{
10	FutureExt, StreamExt, TryFutureExt,
11	future::{join, join3, join4},
12};
13use ruma::{
14	JsOption, MxcUri, OwnedEventId, OwnedMxcUri, RoomId, UInt, UserId,
15	api::client::sync::sync_events::{
16		UnreadNotificationsCount,
17		v5::{DisplayName, response, response::Heroes},
18	},
19	events::{
20		AnySyncStateEvent, StateEventType, TimelineEventType, room::member::MembershipState,
21	},
22	serde::Raw,
23};
24use tuwunel_core::{
25	Error, Result, at, format_small_string, is_equal_to,
26	matrix::{
27		Event, StateKey,
28		pdu::{PduCount, PduEvent, RawPduId},
29	},
30	ref_at,
31	smallstr::SmallString,
32	smallvec::SmallVec,
33	utils::{
34		BoolExt, IterStream, OptionExt, ReadyExt, TryFutureExtExt,
35		hash::sha256::{
36			Digest as Sha256Digest, delimited as sha256_delimited, hash as sha256_hash,
37		},
38		math::usize_from_ruma,
39		result::FlatOk,
40		stream::{BroadbandExt, WidebandExt},
41	},
42};
43use tuwunel_service::{
44	Services,
45	sync::{RequiredState, Room, RoomConfig},
46};
47
48use self::{bump_stamp::room_bump_stamp, heroes::calculate_heroes};
49use super::{
50	super::{load_timeline_fallible, strip_prev_state},
51	Connection, ListIds, SyncInfo, WindowRoom,
52};
53use crate::client::{annotate_membership, ignored_filter, with_membership};
54
55#[derive(Debug)]
56pub(super) enum Failure {
57	Timeline(Error),
58	Payload(Error),
59}
60
61type ThreadCounts = BTreeMap<OwnedEventId, (u64, u64)>;
62type EventTypeString = SmallString<[u8; 32]>;
63type TimelineMembers<'a> = SmallVec<[&'a str; 2]>;
64pub(super) type RoomDetails = (usize, HashSet<(StateEventType, StateKey)>);
65
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub(super) enum StateMode {
68	Full,
69	Delta(PduCount),
70}
71
72#[derive(Clone, Copy)]
73struct StateSelection<'a> {
74	mode: StateMode,
75	previous: Option<&'a [u64]>,
76	changed: bool,
77}
78
79#[tracing::instrument(
80	name = "room",
81	level = "debug",
82	skip_all,
83	fields(room_id, roomsince)
84)]
85pub(super) async fn handle_room(
86	sync_info: SyncInfo<'_>,
87	conn: &Connection,
88	window_room: &WindowRoom,
89	room: &Room,
90	config_changed: bool,
91	room_details: RoomDetails,
92) -> Result<response::Room, Failure> {
93	let SyncInfo {
94		services,
95		sender_user,
96		previous_connection_pos,
97		direct_rooms,
98		..
99	} = sync_info;
100
101	let WindowRoom { lists, membership, room_id, .. } = window_room;
102	let roomsince = room.roomsince;
103
104	if matches!(*membership, Some(MembershipState::Leave | MembershipState::Ban)) {
105		return leave_or_ban_response(sync_info, conn, window_room, roomsince)
106			.map_err(Failure::Payload)
107			.await;
108	}
109
110	let is_invite = *membership == Some(MembershipState::Invite);
111
112	let encrypted = services.state_accessor.is_encrypted_room(room_id);
113
114	let (timeline_limit, required_state) = room_details;
115
116	let timeline = is_invite.is_false().then_async(|| {
117		load_timeline_fallible(
118			services,
119			sender_user,
120			room_id,
121			PduCount::Normal(roomsince),
122			Some(PduCount::from(conn.next_batch)),
123			timeline_limit,
124		)
125	});
126
127	let timeline = timeline
128		.map(Option::transpose)
129		.map_err(Failure::Timeline);
130
131	let (encrypted, timeline) = join(encrypted, timeline).await;
132
133	// A failed load must fail the room, else roomsince advances past unsent events.
134	let (timeline_pdus, limited, last_timeline_count) =
135		timeline?.unwrap_or_else(|| (Vec::new(), false, PduCount::default()));
136
137	let limited = room_timeline_limited(timeline_limit, limited);
138
139	let prev_batch = timeline_pdus
140		.first()
141		.map(at!(0))
142		.map(PduCount::into_unsigned)
143		.as_ref()
144		.map(ToString::to_string);
145
146	let bump_stamp = room_bump_stamp(
147		services,
148		sender_user,
149		room_id,
150		PduCount::Normal(roomsince),
151		PduCount::from(conn.next_batch),
152		last_timeline_count,
153	)
154	.map_err(Failure::Timeline)
155	.await?;
156
157	let mode = state_mode(roomsince, room.required_state.is_empty());
158	let state = StateSelection {
159		mode,
160		previous: config_changed.then_some(room.required_state.as_slice()),
161		changed: state_may_have_changed(mode, last_timeline_count),
162	};
163
164	let required_state = membership_allows_required_state(membership.as_ref())
165		.and_is(state.changed || state.previous.is_some())
166		.then_some(required_state)
167		.unwrap_or_default();
168
169	let required_state = collect_required_state(
170		services,
171		sender_user,
172		room_id,
173		state,
174		&required_state,
175		&timeline_pdus,
176		encrypted,
177	);
178
179	// TODO: figure out a timestamp we can use for remote invites
180	let invite_state = is_invite.then_async(|| {
181		services
182			.state_cache
183			.invite_state(sender_user, room_id)
184			.ok()
185	});
186
187	let timeline = timeline_pdus
188		.iter()
189		.stream()
190		.filter_map(|item| ignored_filter(services, item.clone(), sender_user))
191		.wide_then(|(position, pdu)| {
192			with_membership(services, pdu, sender_user, encrypted).map(move |pdu| (position, pdu))
193		})
194		.wide_then(|(position, pdu)| {
195			services
196				.pdu_metadata
197				.bundle_aggregations(sender_user, pdu)
198				.map(move |pdu| (position, pdu))
199		})
200		.map(|(position, pdu)| (position, Event::into_format(pdu)))
201		.collect::<Vec<_>>();
202
203	let meta = room_meta_future(services, room_id);
204	let events = join3(timeline, required_state, invite_state);
205	let member_counts = member_counts_future(services, room_id);
206	let notification_counts = notification_counts_future(services, sender_user, room_id);
207	let (
208		(room_name, room_avatar),
209		(timeline, required_state, invite_state),
210		(joined_count, invited_count),
211		(highlight_count, notification_count, _last_notification_read, thread_counts),
212	) = join4(meta, events, member_counts, notification_counts)
213		.boxed()
214		.await;
215
216	let (heroes, heroes_name, heroes_avatar) = resolve_heroes(
217		services,
218		sender_user,
219		room_id,
220		room_name.as_ref(),
221		room_avatar.as_deref(),
222	)
223	.await;
224
225	let previous_connection_pos = previous_connection_pos.filter(|_| !is_invite);
226	let (initial, num_live) =
227		room_timeline_metadata(roomsince, previous_connection_pos, &timeline);
228
229	let timeline = timeline.into_iter().map(at!(1)).collect();
230
231	Ok(response::Room {
232		initial,
233		lists: lists.clone(),
234		membership: membership.clone(),
235		name: room_name.or(heroes_name),
236		avatar: JsOption::from_option(room_avatar.or(heroes_avatar)),
237		is_dm: direct_rooms.contains(room_id).then_some(true),
238		heroes,
239		required_state,
240		invite_state: invite_state.flatten(),
241		prev_batch: prev_batch.as_deref().map(Into::into),
242		num_live,
243		limited,
244		timeline,
245		bump_stamp,
246		joined_count,
247		invited_count,
248		unread_notifications: merge_unread_notifications(
249			highlight_count,
250			notification_count,
251			&thread_counts,
252		),
253	})
254}
255
256async fn leave_or_ban_response(
257	SyncInfo { services, sender_user, .. }: SyncInfo<'_>,
258	conn: &Connection,
259	WindowRoom { lists, membership, room_id, .. }: &WindowRoom,
260	roomsince: u64,
261) -> Result<response::Room> {
262	// A rejected federated invite has no resolved state; the retraction still
263	// delivers on the membership alone.
264	let member_event = services
265		.state_accessor
266		.room_state_get(room_id, &StateEventType::RoomMember, sender_user.as_str())
267		.map_ok(Event::into_format)
268		.await
269		.ok();
270
271	Ok(response::Room {
272		initial: roomsince.eq(&0).then_some(true),
273		lists: lists.clone(),
274		membership: membership.clone(),
275		prev_batch: Some(conn.next_batch.to_string().into()),
276		limited: true,
277		required_state: member_event.into_iter().collect(),
278		..Default::default()
279	})
280}
281
282pub(super) fn merged_room_details(
283	conn: &Connection,
284	lists: &ListIds,
285	room_id: &RoomId,
286) -> RoomDetails {
287	lists
288		.iter()
289		.filter_map(|list_id| conn.lists.get(list_id))
290		.map(|list| &list.room_details)
291		.chain(conn.subscriptions.get(room_id))
292		.fold((0_usize, HashSet::new()), |(timeline_limit, mut required_state), config| {
293			required_state.extend(config.required_state.iter().cloned());
294			(timeline_limit.max(usize_from_ruma(config.timeline_limit)), required_state)
295		})
296}
297
298pub(super) fn room_config((timeline_limit, required_state): &RoomDetails) -> RoomConfig {
299	let timeline_limit = u64::try_from(*timeline_limit).expect("timeline limit must fit u64");
300	let digest = sha256_hash(timeline_limit.to_be_bytes());
301
302	required_state.iter().fold(
303		(digest_word(digest), RequiredState::new()),
304		|(hash, mut selectors), (event_type, state_key)| {
305			let entry = required_state_hash(event_type, state_key.as_str());
306
307			selectors.extend(state_key.as_str().ne("$LAZY").then_some(entry));
308
309			(hash ^ entry, selectors)
310		},
311	)
312}
313
314fn state_mode(roomsince: u64, unknown: bool) -> StateMode {
315	match (roomsince, unknown) {
316		| (0, _) | (_, true) => StateMode::Full,
317		| (roomsince, false) => StateMode::Delta(PduCount::Normal(roomsince)),
318	}
319}
320
321fn required_state_hash(event_type: &StateEventType, state_key: &str) -> u64 {
322	let event_type: EventTypeString = format_small_string!("{event_type}");
323	let digest = sha256_delimited([event_type.as_str(), state_key].into_iter());
324
325	digest_word(digest)
326}
327
328fn digest_word(digest: Sha256Digest) -> u64 {
329	u64::from_be_bytes(
330		digest[..8]
331			.try_into()
332			.expect("SHA-256 digest must contain eight bytes"),
333	)
334}
335
336pub(super) fn membership_allows_required_state(membership: Option<&MembershipState>) -> bool {
337	matches!(membership, None | Some(MembershipState::Join))
338}
339
340/// Whether a room's newest event lies past the delta cursor.
341///
342/// State changes arrive as timeline events, so a room with nothing newer than
343/// the cursor has none to report. A full sync always reports.
344fn state_may_have_changed(state_mode: StateMode, last_timeline_count: PduCount) -> bool {
345	match state_mode {
346		| StateMode::Full => true,
347		| StateMode::Delta(since) => last_timeline_count > since,
348	}
349}
350
351fn room_timeline_limited(timeline_limit: usize, limited: bool) -> bool {
352	timeline_limit > 0 && limited
353}
354
355fn room_timeline_metadata<Event>(
356	roomsince: u64,
357	previous_connection_pos: Option<u64>,
358	timeline_pdus: &[(PduCount, Event)],
359) -> (Option<bool>, Option<UInt>) {
360	let initial = roomsince.eq(&0).then_some(true);
361	let num_live = previous_connection_pos
362		.map(PduCount::from)
363		.and_then(|previous_connection_pos| {
364			timeline_pdus
365				.iter()
366				.rev()
367				.map(|(position, _)| *position)
368				.take_while(|position| *position > previous_connection_pos)
369				.count()
370				.try_into()
371				.ok()
372		});
373
374	(initial, num_live)
375}
376
377async fn resolve_heroes(
378	services: &Services,
379	sender_user: &UserId,
380	room_id: &RoomId,
381	room_name: Option<&DisplayName>,
382	room_avatar: Option<&MxcUri>,
383) -> (Option<Heroes>, Option<DisplayName>, Option<OwnedMxcUri>) {
384	services
385		.config
386		.calculate_heroes
387		.then_async(|| calculate_heroes(services, sender_user, room_id, room_name, room_avatar))
388		.await
389		.unwrap_or_default()
390}
391
392fn room_meta_future<'a>(
393	services: &'a Services,
394	room_id: &'a RoomId,
395) -> impl Future<Output = (Option<DisplayName>, Option<OwnedMxcUri>)> + Send + 'a {
396	let room_name = services
397		.state_accessor
398		.get_name(room_id)
399		.map_ok(Into::into)
400		.map(Result::ok);
401
402	let room_avatar = services
403		.state_accessor
404		.get_avatar(room_id)
405		.map_ok(|content| content.url)
406		.ok()
407		.map(Option::flatten);
408
409	join(room_name, room_avatar)
410}
411
412fn member_counts_future<'a>(
413	services: &'a Services,
414	room_id: &'a RoomId,
415) -> impl Future<Output = (Option<UInt>, Option<UInt>)> + Send + 'a {
416	let joined_count = services
417		.state_cache
418		.room_joined_count(room_id)
419		.map_ok(TryInto::try_into)
420		.map_ok(Result::ok)
421		.map(FlatOk::flat_ok);
422
423	let invited_count = services
424		.state_cache
425		.room_invited_count(room_id)
426		.map_ok(TryInto::try_into)
427		.map_ok(Result::ok)
428		.map(FlatOk::flat_ok);
429
430	join(joined_count, invited_count)
431}
432
433fn notification_counts_future<'a>(
434	services: &'a Services,
435	sender_user: &'a UserId,
436	room_id: &'a RoomId,
437) -> impl Future<Output = (Option<UInt>, Option<UInt>, Result<u64>, ThreadCounts)> + Send + 'a {
438	let highlight_count = services
439		.pusher
440		.highlight_count(sender_user, room_id)
441		.map(TryInto::try_into)
442		.map(Result::ok);
443
444	let notification_count = services
445		.pusher
446		.notification_count(sender_user, room_id)
447		.map(TryInto::try_into)
448		.map(Result::ok);
449
450	let last_read_count = services
451		.pusher
452		.last_notification_read(sender_user, room_id);
453
454	let thread_counts = services
455		.pusher
456		.thread_notification_counts(sender_user, room_id);
457
458	join4(highlight_count, notification_count, last_read_count, thread_counts)
459}
460
461// MSC3771/MSC3773: SSS v5 has no per-thread bucket; fold into the room total.
462fn merge_unread_notifications(
463	highlight_count: Option<UInt>,
464	notification_count: Option<UInt>,
465	thread_counts: &ThreadCounts,
466) -> UnreadNotificationsCount {
467	let (thread_notifications, thread_highlights) = thread_counts
468		.values()
469		.fold((0_u64, 0_u64), |(n, h), &(notifs, hl)| {
470			(n.saturating_add(notifs), h.saturating_add(hl))
471		});
472
473	let merge = |total: u64| {
474		move |count: UInt| count.saturating_add(UInt::try_from(total).unwrap_or_default())
475	};
476
477	UnreadNotificationsCount {
478		highlight_count: highlight_count.map(merge(thread_highlights)),
479		notification_count: notification_count.map(merge(thread_notifications)),
480	}
481}
482
483async fn collect_required_state(
484	services: &Services,
485	sender_user: &UserId,
486	room_id: &RoomId,
487	selection: StateSelection<'_>,
488	required_state: &HashSet<(StateEventType, StateKey)>,
489	timeline_pdus: &[(PduCount, PduEvent)],
490	encrypted: bool,
491) -> Vec<Raw<AnySyncStateEvent>> {
492	let StateSelection { mode: state_mode, previous, changed } = selection;
493	let lazy = required_state
494		.iter()
495		.any(is_equal_to!(&(StateEventType::RoomMember, "$LAZY".into())));
496
497	let needs_since_state = required_state
498		.iter()
499		.any(|(_, state_key)| state_key != "$LAZY");
500
501	// Falling back to current state would match every entry against itself.
502	let since_state = match state_mode {
503		| StateMode::Delta(since) if changed && needs_since_state => services
504			.timeline
505			.next_shortstatehash(room_id, since)
506			.ok()
507			.await
508			.map(|shortstatehash| (since, shortstatehash)),
509		| _ => None,
510	};
511
512	// Equal hashes exclude changes, but newly requested keys may still be due.
513	let state_unchanged = !changed
514		|| since_state
515			.map_async(|(_, since_shortstatehash)| {
516				services
517					.state
518					.get_room_shortstatehash(room_id)
519					.ok()
520					.map(move |current| current == Some(since_shortstatehash))
521			})
522			.await
523			.unwrap_or(false);
524
525	let timeline_senders = timeline_pdus
526		.iter()
527		.filter(|_| lazy)
528		.map(ref_at!(1))
529		.map(Event::sender)
530		.map(UserId::as_str);
531
532	let timeline_member_targets = timeline_pdus
533		.iter()
534		.filter(|_| lazy)
535		.map(ref_at!(1))
536		.filter(|event| *event.event_type() == TimelineEventType::RoomMember)
537		.filter_map(Event::state_key);
538
539	let wildcard_state = required_state
540		.iter()
541		.filter(|(_, state_key)| (!state_unchanged || previous.is_some()) && state_key == "*")
542		.stream()
543		.flat_map(|(event_type, _)| {
544			services
545				.state_accessor
546				.room_state_keys_with_ids(room_id, event_type)
547				.ready_filter_map(Result::ok)
548				.map(move |(state_key, event_id)| {
549					((event_type.clone(), state_key), Some(event_id), false)
550				})
551		});
552
553	let mut timeline_members: TimelineMembers<'_> = timeline_senders
554		.chain(timeline_member_targets)
555		.collect();
556
557	timeline_members.sort_unstable();
558	timeline_members.dedup();
559
560	let timeline_members = timeline_members
561		.into_iter()
562		.map(|sender| (StateEventType::RoomMember, StateKey::from_str(sender)));
563
564	let in_timeline = |event: &PduEvent| {
565		timeline_pdus
566			.iter()
567			.map(ref_at!(1))
568			.map(Event::event_id)
569			.any(is_equal_to!(event.event_id()))
570	};
571
572	required_state
573		.iter()
574		.filter(|_| !state_unchanged || previous.is_some())
575		.cloned()
576		.map(|state| (state, None, false))
577		.stream()
578		.chain(wildcard_state)
579		.chain(
580			timeline_members
581				.map(|state| (state, None, true))
582				.stream(),
583		)
584		.broad_filter_map(async |(state, event_id, lazy)| {
585			let (event_type, state_key) = state;
586			let state_key: StateKey = match state_key.as_str() {
587				| "$LAZY" | "*" => return None,
588				| "$ME" => sender_user.as_str().into(),
589				| _ => state_key,
590			};
591
592			let state_mode = previous
593				.filter(|previous| {
594					!state_was_requested(previous, &event_type, state_key.as_str(), sender_user)
595				})
596				.map(|_| StateMode::Full)
597				.unwrap_or(state_mode);
598
599			if state_unchanged && !lazy && state_mode != StateMode::Full {
600				return None;
601			}
602
603			let event_id = match event_id {
604				| Some(event_id) => event_id,
605				| None =>
606					services
607						.state_accessor
608						.room_state_get_id(room_id, &event_type, &state_key)
609						.ok()
610						.await?,
611			};
612
613			let pdu_id = services.timeline.get_pdu_id(&event_id).await.ok();
614			let count = pdu_id.map(RawPduId::pdu_count);
615			let same_at_since = since_state
616				.filter(|(since, _)| !lazy && count.is_some_and(|count| count <= *since))
617				.map_async(|(_, shortstatehash)| {
618					services
619						.state_accessor
620						.state_get_id(shortstatehash, &event_type, &state_key)
621						.ok()
622				})
623				.await
624				.flatten()
625				.is_some_and(|previous_event_id| previous_event_id == event_id);
626
627			let pdu_id =
628				state_is_required(state_mode, count, lazy, same_at_since).then_some(pdu_id)?;
629
630			let mut pdu = match pdu_id {
631				| None => services
632					.timeline
633					.get_outlier_pdu(&event_id)
634					.await
635					.ok()?,
636				| Some(pdu_id) => services
637					.timeline
638					.get_pdu_from_id(&pdu_id)
639					.or_else(|_| services.timeline.get_outlier_pdu(&event_id))
640					.await
641					.ok()?,
642			};
643
644			annotate_membership(services, &mut pdu, sender_user, encrypted).await;
645
646			let pdu = strip_prev_state(pdu, sender_user, in_timeline);
647
648			Some(Event::into_format(pdu))
649		})
650		.collect()
651		.await
652}
653
654fn state_was_requested(
655	previous: &[u64],
656	event_type: &StateEventType,
657	state_key: &str,
658	sender_user: &UserId,
659) -> bool {
660	let contains = |key| previous.contains(&required_state_hash(event_type, key));
661
662	contains("*") || contains(state_key) || (state_key == sender_user.as_str() && contains("$ME"))
663}
664
665fn state_is_required(
666	state_mode: StateMode,
667	count: Option<PduCount>,
668	lazy: bool,
669	same_at_since: bool,
670) -> bool {
671	lazy || match state_mode {
672		| StateMode::Full => true,
673		| StateMode::Delta(since) => count.is_none_or(|count| count > since) || !same_at_since,
674	}
675}