Skip to main content

tuwunel_api_client/sync/v3/
left.rs

1use std::collections::BTreeMap;
2
3use futures::{
4	FutureExt, StreamExt, TryFutureExt,
5	future::{join3, join4},
6};
7use ruma::{
8	OwnedEventId, OwnedRoomId, RoomId, UserId,
9	api::client::{
10		filter::FilterDefinition,
11		sync::sync_events::v3::{LeftRoom, RoomAccountData, StateEvents, Timeline},
12	},
13	events::{AnyRawAccountDataEvent, TimelineEventType::*, room::member::MembershipState},
14};
15use tuwunel_core::{
16	Error, Result, at, debug,
17	error::inspect_debug_log,
18	extract_variant, is_equal_to, ref_at,
19	result::FlatOk,
20	smallvec::SmallVec,
21	utils::{
22		BoolExt, FutureBoolExt, IterStream, ReadyExt, TryFutureExtExt,
23		future::ReadyBoolExt,
24		option::OptionExt,
25		result::MapExpect,
26		stream::{BroadbandExt, WidebandExt},
27	},
28	warn,
29};
30use tuwunel_matrix::{
31	Event,
32	event::{Matches, trim_event_fields},
33	pdu::{PduCount, PduEvent},
34};
35use tuwunel_service::Services;
36
37use super::{
38	super::{
39		load_timeline, own_departure, stand_in_leave, strip_state_unsigned, timeline_prev_batch,
40	},
41	StateAfter, StateChangeParams, StateChanges, missing_room_state,
42	state::calculate_state_changes,
43};
44use crate::{ignored_filter, is_empty_account_data_event, with_membership};
45
46type TimelineEventIds = SmallVec<[OwnedEventId; 1]>;
47
48pub(super) fn collect_rooms<'a>(
49	services: &'a Services,
50	sender_user: &'a UserId,
51	since: u64,
52	next_batch: u64,
53	full_state: bool,
54	state_after: StateAfter,
55	filter: &'a FilterDefinition,
56) -> impl Future<Output = BTreeMap<OwnedRoomId, LeftRoom>> + Send + 'a {
57	services
58		.state_cache
59		.rooms_departed(sender_user)
60		.ready_filter(|room_id| filter.room.matches(room_id))
61		.map(ToOwned::to_owned)
62		.broad_filter_map(move |room_id| {
63			handle_left_room(
64				services,
65				since,
66				room_id.clone(),
67				sender_user,
68				next_batch,
69				full_state,
70				state_after,
71				filter,
72			)
73			.map_ok(move |left_room| (room_id, left_room))
74			.ok()
75		})
76		.ready_filter_map(|(room_id, left_room)| left_room.map(|left_room| (room_id, left_room)))
77		.collect()
78}
79
80#[tracing::instrument(
81	target = "tuwunel_api_client::sync::v3",
82	name = "left",
83	level = "debug",
84	skip_all,
85	fields(
86		room_id = %room_id,
87		full = %full_state,
88	),
89)]
90#[expect(clippy::too_many_arguments)]
91async fn handle_left_room(
92	services: &Services,
93	since: u64,
94	ref room_id: OwnedRoomId,
95	sender_user: &UserId,
96	next_batch: u64,
97	full_state: bool,
98	state_after: StateAfter,
99	filter: &FilterDefinition,
100) -> Result<Option<LeftRoom>> {
101	let left_count = services
102		.state_cache
103		.get_departure_count(room_id, sender_user)
104		.await
105		.unwrap_or(0);
106
107	if left_count == 0 || left_count > next_batch {
108		return Ok(None);
109	}
110
111	let include_leave = filter.room.include_leave;
112	if since == 0 && !include_leave {
113		return Ok(None);
114	}
115
116	// Cannot sync unless the event falls within the snapshot. The room is only
117	// sync'ed once to the client, after that it's too late.
118	if since != 0 && left_count <= since {
119		return Ok(None);
120	}
121
122	let forgotten = services
123		.state_cache
124		.is_forgotten(sender_user, room_id)
125		.await?;
126
127	if forgotten && since == 0 {
128		return Ok(None);
129	}
130
131	let is_not_found = services.metadata.exists(room_id).is_false();
132
133	let is_disabled = services.metadata.is_disabled(room_id);
134
135	let is_banned = services.metadata.is_banned(room_id);
136
137	// Last, so a refusal on the same poll spares its reads.
138	let is_out_of_band = departed_out_of_band(services, room_id, sender_user, since);
139
140	if forgotten
141		|| is_not_found
142			.or3(is_disabled, is_banned, is_out_of_band)
143			.await
144	{
145		let departure = forgotten
146			.then_async(async || {
147				own_departure(services, room_id, sender_user)
148					.await
149					.map(Event::into_format)
150			})
151			.await
152			.flatten();
153
154		let event =
155			departure.map_or_else(|| stand_in_leave(services, room_id, sender_user), Ok)?;
156
157		let state = state_after.wrap(StateEvents {
158			events: vec![trim_event_fields(event, filter.event_fields.as_deref())],
159		});
160
161		return Ok(Some(LeftRoom {
162			account_data: RoomAccountData::default(),
163			state,
164			timeline: Timeline {
165				limited: false,
166				events: Default::default(),
167				prev_batch: Some(left_count.to_string()),
168			},
169		}));
170	}
171
172	let horizon = services
173		.state_accessor
174		.departure_count(sender_user, room_id)
175		.await
176		.inspect_err(|error| warn!(target: "tuwunel_api_client::sync::v3", %room_id, %sender_user, %error, "Unreadable departure count"))
177		.ok()
178		.flatten()
179		.map(PduCount::into_unsigned)
180		.unwrap_or(left_count);
181
182	load_left_room(
183		services,
184		sender_user,
185		room_id,
186		since,
187		horizon,
188		full_state,
189		state_after,
190		filter,
191	)
192	.await
193}
194
195/// Whether the room holds no departure event this sync can send.
196///
197/// An invite rescinded or rejected after this server left the room writes only
198/// the membership row. The state then holds no departure for the user, or an
199/// older one with no event after the client's position to diff it against, and
200/// either way the rest of the room's frozen state is not theirs to receive. A
201/// ban stays with the ordinary loader, which serves it whatever the row's count
202/// says, and so does any departure when the read of the room's last event
203/// fails, which is logged and proves nothing.
204async fn departed_out_of_band(
205	services: &Services,
206	room_id: &RoomId,
207	sender_user: &UserId,
208	since: u64,
209) -> bool {
210	let no_later_event = since != 0
211		&& services
212			.timeline
213			.last_timeline_count(None, room_id, None)
214			.await
215			.inspect_err(|error| warn!(target: "tuwunel_api_client::sync::v3", %room_id, %since, %error, "Unreadable last event count"))
216			.is_ok_and(|last| last <= PduCount::Normal(since));
217
218	own_departure(services, room_id, sender_user)
219		.await
220		.is_none_or(|event| {
221			no_later_event && event.membership_for(sender_user) != Some(MembershipState::Ban)
222		})
223}
224
225#[tracing::instrument(
226	target = "tuwunel_api_client::sync::v3",
227	name = "load",
228	level = "debug",
229	skip_all
230)]
231#[expect(clippy::too_many_arguments)]
232async fn load_left_room(
233	services: &Services,
234	sender_user: &UserId,
235	room_id: &RoomId,
236	since: u64,
237	horizon: u64,
238	full_state: bool,
239	state_after: StateAfter,
240	filter: &FilterDefinition,
241) -> Result<Option<LeftRoom>> {
242	let initial = since == 0;
243	let timeline_limit: usize = filter
244		.room
245		.timeline
246		.limit
247		.map(TryInto::try_into)
248		.map_expect("UInt to usize")
249		.unwrap_or(10)
250		.min(100);
251
252	let (timeline_pdus, limited, _) = load_timeline(
253		services,
254		sender_user,
255		room_id,
256		PduCount::Normal(since),
257		Some(PduCount::Normal(horizon)),
258		timeline_limit.max(1),
259	)
260	.await
261	.unwrap_or_default();
262
263	let since_shortstatehash = since
264		.lt(&horizon)
265		.then_async(|| {
266			services
267				.timeline
268				.next_shortstatehash(room_id, PduCount::Normal(since))
269				.ok()
270		})
271		.map(Option::flatten);
272
273	let horizon_shortstatehash = timeline_pdus
274		.first()
275		.map(at!(0))
276		.map_async(|count| {
277			services
278				.timeline
279				.get_shortstatehash(room_id, count)
280				.inspect_err(log_horizon_error)
281				.ok()
282		});
283
284	// MSC4222 `state_after` describes the state at the leave.
285	let after_shortstatehash = state_after.requested().then_async(|| {
286		services
287			.timeline
288			.shortstatehash_after(room_id, PduCount::Normal(horizon))
289			.inspect_err(inspect_debug_log)
290	});
291
292	let left_shortstatehash = services
293		.timeline
294		.get_shortstatehash(room_id, PduCount::Normal(horizon))
295		.inspect_err(inspect_debug_log)
296		.or_else(|_| services.state.get_room_shortstatehash(room_id))
297		.map_err(|_| missing_room_state(room_id));
298
299	let (since_shortstatehash, horizon_shortstatehash, after_shortstatehash, left_shortstatehash) =
300		join4(
301			since_shortstatehash,
302			horizon_shortstatehash,
303			after_shortstatehash,
304			left_shortstatehash,
305		)
306		.boxed() // erase region
307		.await;
308
309	let StateChanges { state_events, .. } =
310		calculate_state_changes(services, sender_user, room_id, StateChangeParams {
311			full_state: full_state || initial,
312			state_after,
313			since_shortstatehash,
314			horizon_shortstatehash: horizon_shortstatehash.flatten(),
315			after_shortstatehash: after_shortstatehash.flat_ok(),
316			current_shortstatehash: left_shortstatehash?,
317			joined_since_last_sync: false,
318			witness: None,
319			include_heroes: true,
320		})
321		.await?;
322
323	let is_sender_membership = |event: &PduEvent| {
324		*event.kind() == RoomMember && event.state_key() == Some(sender_user.as_str())
325	};
326
327	let departure = late_departure(services, sender_user, room_id, since, horizon).await;
328
329	let has_departure = departure.is_some();
330
331	let timeline_sender_member = timeline_limit
332		.eq(&0)
333		.then(|| timeline_pdus.last().map(ref_at!(1)).cloned())
334		.into_iter()
335		.flat_map(Option::into_iter);
336
337	let encrypted = services
338		.state_accessor
339		.is_encrypted_room(room_id)
340		.await;
341
342	let event_fields = filter.event_fields.as_deref();
343
344	let in_timeline = in_timeline(&timeline_pdus);
345
346	let state_events = state_events
347		.into_iter()
348		.filter(|pdu| !has_departure || !is_sender_membership(pdu))
349		.chain(departure)
350		.filter(|pdu| filter.room.state.matches(pdu))
351		.filter(|pdu| timeline_limit > 0 || has_departure || !is_sender_membership(pdu))
352		.chain(timeline_sender_member)
353		.stream()
354		.wide_then(|pdu| with_membership(services, pdu, sender_user, encrypted))
355		.map(|pdu| strip_state_unsigned(pdu, sender_user, &in_timeline))
356		.map(|pdu| trim_event_fields(pdu.into_format(), event_fields))
357		.collect();
358
359	let left_prev_batch = timeline_limit
360		.eq(&0)
361		.then_some(horizon)
362		.map(PduCount::Normal);
363
364	let prev_batch = timeline_prev_batch(&timeline_pdus, PduCount::Normal(horizon))
365		.filter(|_| timeline_limit > 0)
366		.or(left_prev_batch)
367		.as_ref()
368		.map(ToString::to_string);
369
370	let timeline_events = timeline_pdus
371		.into_iter()
372		.stream()
373		.wide_filter_map(|item| ignored_filter(services, item, sender_user))
374		.map(at!(1))
375		.ready_filter(|pdu| filter.room.timeline.matches(pdu))
376		.take(timeline_limit)
377		.wide_then(|pdu| with_membership(services, pdu, sender_user, encrypted))
378		.wide_then(|pdu| {
379			services
380				.pdu_metadata
381				.bundle_aggregations(sender_user, pdu)
382		})
383		.collect::<Vec<_>>();
384
385	let account_data_events = services
386		.account_data
387		.changes_since(Some(room_id), sender_user, since, None)
388		.ready_filter_map(|e| extract_variant!(e, AnyRawAccountDataEvent::Room))
389		.ready_filter(move |e| since != 0 || !is_empty_account_data_event(e))
390		.collect();
391
392	let (state_events, account_data_events, timeline_events) =
393		join3(state_events, account_data_events, timeline_events).await;
394
395	let state = state_after.wrap(StateEvents { events: state_events });
396
397	Ok(Some(LeftRoom {
398		account_data: RoomAccountData { events: account_data_events },
399		state,
400		timeline: Timeline {
401			prev_batch,
402			limited: limited || timeline_limit == 0,
403			events: timeline_events
404				.into_iter()
405				.map(|pdu| trim_event_fields(pdu.into_format(), event_fields))
406				.collect(),
407		},
408	}))
409}
410
411async fn late_departure(
412	services: &Services,
413	sender_user: &UserId,
414	room_id: &RoomId,
415	since: u64,
416	horizon: u64,
417) -> Option<PduEvent> {
418	since
419		.ge(&horizon)
420		.then_async(|| {
421			services
422				.timeline
423				.get_pdu_at(room_id, PduCount::Normal(horizon))
424				.inspect_err(inspect_debug_log)
425				.ok()
426		})
427		.map(|event| {
428			event.flatten().filter(|event| {
429				matches!(
430					event.membership_for(sender_user),
431					Some(MembershipState::Leave | MembershipState::Ban)
432				)
433			})
434		})
435		.await
436}
437
438/// Records a failed horizon read, quietly when the row is simply absent.
439///
440/// `append_to_state` writes no state hash for a room's `m.room.create` event,
441/// there being no state before it, so the first window of every room misses
442/// here by design. An absent row reads the same whatever left it absent, so
443/// the loud path is what remains: a fault that is not a miss at all.
444pub(super) fn log_horizon_error(error: &Error) {
445	match error.is_not_found() {
446		| true =>
447			debug!(target: "tuwunel_api_client::sync::v3", ?error, "no shortstatehash at the window's first event"),
448		| false => inspect_debug_log(error),
449	}
450}
451
452pub(super) fn in_timeline(
453	timeline_pdus: &[(PduCount, PduEvent)],
454) -> impl Fn(&PduEvent) -> bool + use<> {
455	let timeline_ids: TimelineEventIds = timeline_pdus
456		.iter()
457		.map(ref_at!(1))
458		.map(Event::event_id)
459		.map(ToOwned::to_owned)
460		.collect();
461
462	move |event: &PduEvent| {
463		timeline_ids
464			.iter()
465			.any(is_equal_to!(event.event_id()))
466	}
467}