tuwunel_api_client/sync/v5/extensions/
profiles.rs1use 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#[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}