Skip to main content

tuwunel_api/client/sync/v5/extensions/
profiles.rs

1use futures::{StreamExt, TryStreamExt};
2use itertools::Itertools;
3use ruma::{
4	OwnedUserId, RoomId, UserId,
5	api::client::sync::sync_events::v5::response::{Profiles, Room as ResponseRoom},
6	events::{StateEventType, room::member::MembershipState},
7	profile::{ProfileFieldName, UserProfileChanges, UserProfileUpdate},
8	serde::Raw,
9};
10use serde_json::Value;
11use tuwunel_core::{
12	Result,
13	utils::{
14		BoolExt, IterStream,
15		stream::{BroadbandExt, TryReadyExt},
16	},
17};
18use tuwunel_service::{Services, sync::Connection};
19
20use super::{
21	super::{range::Results, rooms::merged_room_details},
22	SyncInfo, Window, selector,
23};
24use crate::client::sync::profiles::{Changes, Fields, fold_change, read_field, visible};
25
26/// Collects the MSC4262 profiles extension payload.
27///
28/// Current selected-room bases and bounded discovery share one user-field set.
29/// Visibility and read errors resolve before the response position is acknowledged.
30#[tracing::instrument(name = "profiles", level = "trace", skip_all)]
31pub(super) async fn collect(
32	SyncInfo { services, sender_user, .. }: SyncInfo<'_>,
33	conn: &Connection,
34	window: &Window,
35	ranges: &Results,
36) -> Result<Profiles> {
37	let requested = conn.extensions.profiles.fields.as_deref();
38	if requested.is_some_and(<[_]>::is_empty) {
39		return Ok(Profiles::default());
40	}
41
42	let bases = room_bases(services, conn, window, ranges).await?;
43	let changes = bases
44		.chain(
45			conn.own_profile_owed()
46				.then_some(sender_user.to_owned()),
47		)
48		.sorted_unstable()
49		.dedup()
50		.stream()
51		.broad_then(|user_id| base(services, sender_user, user_id, requested))
52		.ready_try_filter_map(Result::Ok)
53		.try_collect()
54		.await?;
55
56	let changes = services
57		.profile
58		.try_profile_changed(sender_user, conn.globalsince, Some(conn.next_batch))
59		.ready_try_filter(|(_, field)| was_requested(requested, field))
60		.ready_try_fold(changes, |changes, change| Ok(fold_change(changes, change)))
61		.await?;
62
63	let changes = window
64		.keys()
65		.merge(conn.rooms.keys())
66		.dedup()
67		.filter(|_| conn.globalsince != 0)
68		.try_stream()
69		.try_fold(changes, async |changes, room_id| {
70			fold_room(changes, services, conn, room_id, requested).await
71		})
72		.await?;
73
74	let users = changes
75		.into_iter()
76		.stream()
77		.broad_then(|(user_id, fields)| collect_user(services, sender_user, user_id, fields))
78		.ready_try_filter_map(Result::Ok)
79		.try_collect()
80		.await?;
81
82	Ok(Profiles { users })
83}
84
85#[tracing::instrument(level = "trace", skip_all)]
86async fn room_bases(
87	services: &Services,
88	conn: &Connection,
89	window: &Window,
90	ranges: &Results,
91) -> Result<impl Iterator<Item = OwnedUserId>> {
92	let config = &conn.extensions.profiles;
93
94	let subjects = selector(
95		conn,
96		window,
97		config.lists.as_ref().map(|lists| lists.iter()),
98		config.rooms.as_ref().map(|rooms| rooms.iter()),
99	)
100	.filter_map(|room_id| {
101		ranges
102			.payload(room_id)
103			.map(|room| (room_id, room))
104	})
105	.filter(|(_, room)| room.initial.unwrap_or(false) || conn.own_profile_owed())
106	.try_stream()
107	.try_fold(Vec::new(), async |bases, (room_id, room)| {
108		let bases = extend_subjects(bases, subjects(room));
109
110		let Some(selected) = window.get(room_id) else {
111			return Ok(bases);
112		};
113
114		let (_, state) = merged_room_details(conn, &selected.lists, room_id);
115		let lazy = state
116			.iter()
117			.any(|(kind, key)| kind == &StateEventType::RoomMember && key == "$LAZY");
118
119		let full = state.iter().any(|(kind, key)| {
120			(kind == &StateEventType::RoomMember || kind == &StateEventType::from("*"))
121				&& key == "*"
122		});
123
124		if (lazy && !full) || room.membership != Some(MembershipState::Join) {
125			return Ok(bases);
126		}
127
128		services
129			.state_cache
130			.room_members_checked(room_id)
131			.map_ok(ToOwned::to_owned)
132			.ready_try_fold(bases, |bases, user_id| Ok(extend_subjects(bases, [user_id])))
133			.await
134	})
135	.await?;
136
137	Ok(subjects.into_iter())
138}
139
140fn extend_subjects(
141	mut subjects: Vec<OwnedUserId>,
142	additional: impl IntoIterator<Item = OwnedUserId>,
143) -> Vec<OwnedUserId> {
144	subjects.extend(additional);
145	subjects
146}
147
148fn subjects(room: &ResponseRoom) -> impl Iterator<Item = OwnedUserId> + '_ {
149	let senders = room
150		.timeline
151		.iter()
152		.filter_map(|event| event.get_field("sender").ok().flatten());
153
154	let members = room
155		.timeline
156		.iter()
157		.filter_map(member)
158		.chain(room.required_state.iter().filter_map(member));
159
160	let heroes = room
161		.heroes
162		.iter()
163		.flatten()
164		.map(|hero| hero.user_id.clone());
165
166	senders.chain(members).chain(heroes)
167}
168
169fn member<T>(event: &Raw<T>) -> Option<OwnedUserId> {
170	event
171		.get_field("type")
172		.ok()
173		.flatten()
174		.filter(|kind: &StateEventType| kind == &StateEventType::RoomMember)
175		.and_then(|_| event.get_field("state_key").ok().flatten())
176}
177
178#[tracing::instrument(level = "trace", skip_all)]
179async fn base(
180	services: &Services,
181	sender_user: &UserId,
182	user_id: OwnedUserId,
183	requested: Option<&[ProfileFieldName]>,
184) -> Result<Option<(OwnedUserId, Fields)>> {
185	if !visible(services, sender_user, &user_id).await? {
186		return Ok(None);
187	}
188
189	let fields: Fields = match requested {
190		| Some(fields) => fields.iter().cloned().collect(),
191		| None =>
192			services
193				.profile
194				.try_profile_field_names(&user_id)
195				.try_collect()
196				.await?,
197	};
198
199	Ok(fields
200		.is_empty()
201		.is_false()
202		.then_some((user_id, fields)))
203}
204
205async fn fold_room(
206	changes: Changes,
207	services: &Services,
208	conn: &Connection,
209	room_id: &RoomId,
210	requested: Option<&[ProfileFieldName]>,
211) -> Result<Changes> {
212	services
213		.profile
214		.try_room_profile_changed(room_id, changes_from(conn, room_id), Some(conn.next_batch))
215		.ready_try_filter(|(_, field)| was_requested(requested, field))
216		.ready_try_fold(changes, |changes, change| Ok(fold_change(changes, change)))
217		.await
218}
219
220fn changes_from(conn: &Connection, room_id: &RoomId) -> u64 {
221	conn.rooms
222		.get(room_id)
223		.is_some_and(|room| room.roomsince.gt(&0) && conn.profiles_fields_owed().is_false())
224		.then_some(conn.globalsince)
225		.unwrap_or_default()
226}
227
228fn was_requested(requested: Option<&[ProfileFieldName]>, field: &str) -> bool {
229	requested.is_none_or(|fields| fields.iter().any(|name| name.as_str() == field))
230}
231
232async fn collect_user(
233	services: &Services,
234	sender_user: &UserId,
235	user_id: OwnedUserId,
236	fields: Fields,
237) -> Result<Option<(OwnedUserId, UserProfileUpdate)>> {
238	if !visible(services, sender_user, &user_id).await? {
239		return Ok(None);
240	}
241
242	let update = read_update(services, &user_id, fields).await?;
243
244	Ok(Some((user_id, update)))
245}
246
247async fn read_update(
248	services: &Services,
249	user_id: &UserId,
250	fields: Fields,
251) -> Result<UserProfileUpdate> {
252	let changes = fields
253		.into_iter()
254		.stream()
255		.then(|name| read_field(services, user_id, name))
256		.map(|(name, value)| value.map(|value| (name, value)))
257		.ready_try_fold(UserProfileChanges::new(), |changes, field| {
258			Ok(fold_field(changes, field))
259		})
260		.await?;
261
262	Ok(UserProfileUpdate::Updated(changes))
263}
264
265fn fold_field(
266	mut changes: UserProfileChanges,
267	(name, value): (ProfileFieldName, Option<Value>),
268) -> UserProfileChanges {
269	match value {
270		| None => changes.removed.push(name),
271		| Some(value) => {
272			changes.updated.insert(name, value);
273		},
274	}
275
276	changes
277}
278
279#[cfg(test)]
280mod tests {
281	use ruma::room_id;
282	use tuwunel_service::sync::{Connection, Room};
283
284	use super::changes_from;
285
286	#[test]
287	fn a_widened_field_set_replays_a_known_room() {
288		let room_id = room_id!("!known:example.com");
289		let mut conn = Connection {
290			globalsince: 7,
291			rooms: [(room_id.to_owned(), Room { roomsince: 3, ..Default::default() })].into(),
292			..Default::default()
293		};
294
295		conn.extensions.profiles.enabled = Some(true);
296
297		assert_eq!(changes_from(&conn, room_id), 7);
298
299		conn.profiles_fields_widened = true;
300
301		assert_eq!(changes_from(&conn, room_id), 0);
302
303		conn.own_profile_since = 7;
304
305		assert_eq!(changes_from(&conn, room_id), 7);
306	}
307}