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