Skip to main content

tuwunel_service/rooms/pdu_metadata/
bundling.rs

1use std::collections::BTreeSet;
2
3use futures::{Stream, StreamExt, TryFutureExt, pin_mut};
4use ruma::{OwnedUserId, UserId, api::Direction, events::room::encrypted::Relation};
5use tuwunel_core::{
6	PduId,
7	arrayvec::ArrayVec,
8	implement,
9	matrix::{Event, Pdu, PduCount, RawPduId},
10	result::LogErr,
11	utils::{
12		BoolExt,
13		stream::{ReadyExt, TryIgnore},
14		u64_from_u8,
15	},
16};
17
18use super::{
19	ExtractRelatesTo, IgnoredThreadView,
20	IgnoredThreadView::{Adjusted, Omitted, Unchanged},
21	Service,
22	typed_relations::{CHILD_COUNT_OFFSET, KEY_LEN, Tag, prefix},
23};
24
25type Seek = ArrayVec<u8, KEY_LEN>;
26
27/// Fold read-time bundled aggregations into a served event's `unsigned`,
28/// per-requester. MSC3816: the stored `m.thread` bundle carries a shared
29/// `current_user_participated`, recomputed here for `sender_user`. MSC3925:
30/// when `bundle_edit_relations` is enabled, the newest `m.replace` edit is
31/// folded in as the full replacement event, and the bundled thread
32/// `latest_event` carries its own newest edit (MSC3856). MSC3267: when
33/// `bundle_reference_relations` is enabled, the `m.reference` children are
34/// folded in as a `{ chunk: [{ event_id }] }` summary. The thread presence gate
35/// keeps the common no-bundle case to a substring scan; the edit and reference
36/// folds are skipped unless enabled.
37#[implement(Service)]
38#[tracing::instrument(skip_all, level = "trace")]
39pub async fn bundle_aggregations(&self, sender_user: &UserId, mut pdu: Pdu) -> Pdu {
40	// MSC4025: an erased sender's event serves as the pruned clone, and a
41	// pruned event carries no aggregations.
42	if let Some(pruned) = self
43		.services
44		.state_accessor
45		.erased_view(sender_user, &pdu)
46		.await
47	{
48		return pruned;
49	}
50
51	let has_thread = pdu
52		.unsigned()
53		.is_some_and(|unsigned| unsigned.get().contains("m.thread"));
54
55	if has_thread {
56		let participated = self
57			.services
58			.threads
59			.user_participated(pdu.event_id(), sender_user)
60			.await;
61
62		pdu.set_thread_participated(participated)
63			.log_err()
64			.ok();
65
66		self.erase_thread_latest(sender_user, &mut pdu)
67			.await;
68
69		if self.services.server.config.bundle_edit_relations {
70			self.bundle_thread_latest_edit(sender_user, &mut pdu)
71				.await;
72		}
73	}
74
75	let replacement = self
76		.services
77		.server
78		.config
79		.bundle_edit_relations
80		.then_async(|| self.newest_replacement(&pdu))
81		.await
82		.flatten();
83
84	if let Some(replacement) = replacement
85		&& !self
86			.services
87			.state_accessor
88			.erased_for(sender_user, &replacement)
89			.await
90	{
91		pdu.set_replacement_bundle(&replacement.into_format())
92			.log_err()
93			.ok();
94	}
95
96	let references = self
97		.services
98		.server
99		.config
100		.bundle_reference_relations
101		.then_async(|| self.references(&pdu))
102		.await
103		.unwrap_or_default();
104
105	if !references.is_empty() {
106		pdu.set_reference_bundle(&references)
107			.log_err()
108			.ok();
109	}
110
111	pdu
112}
113
114/// MSC4025: the stored thread bundle carries a full `latest_event` of any
115/// sender; an erased hit swaps in the pruned form for this recipient. The
116/// event load and membership check run only on the erased hit.
117#[implement(Service)]
118#[tracing::instrument(skip_all, level = "trace")]
119async fn erase_thread_latest(&self, sender_user: &UserId, pdu: &mut Pdu) {
120	let Some((event_id, sender)) = pdu.thread_latest_event() else {
121		return;
122	};
123
124	if !self.services.users.is_erased(&sender).await {
125		return;
126	}
127
128	let Ok(latest) = self.services.timeline.get_pdu(&event_id).await else {
129		return;
130	};
131
132	if let Some(pruned) = self
133		.services
134		.state_accessor
135		.erased_view(sender_user, &latest)
136		.await
137	{
138		pdu.set_thread_latest_event(&pruned.into_format())
139			.log_err()
140			.ok();
141	}
142}
143
144/// The thread module's aggregated `latest_event` (MSC3856): when the edit
145/// fold is enabled, the bundled latest reply carries its own newest
146/// `m.replace` edit, so thread previews track edits. Erased-sender bundles
147/// stay in their pruned form.
148#[implement(Service)]
149#[tracing::instrument(skip_all, level = "trace")]
150async fn bundle_thread_latest_edit(&self, sender_user: &UserId, pdu: &mut Pdu) {
151	let Some((event_id, _)) = pdu.thread_latest_event() else {
152		return;
153	};
154
155	let Ok(mut latest) = self.services.timeline.get_pdu(&event_id).await else {
156		return;
157	};
158
159	if self
160		.services
161		.state_accessor
162		.erased_for(sender_user, &latest)
163		.await
164	{
165		return;
166	}
167
168	let Some(replacement) = self.newest_replacement(&latest).await else {
169		return;
170	};
171
172	if self
173		.services
174		.state_accessor
175		.erased_for(sender_user, &replacement)
176		.await
177	{
178		return;
179	}
180
181	if latest
182		.set_replacement_bundle(&replacement.into_format())
183		.log_err()
184		.is_err()
185	{
186		return;
187	}
188
189	pdu.set_thread_latest_event(&latest.into_format())
190		.log_err()
191		.ok();
192}
193
194/// MSC3925: the newest `m.replace` edit of `parent` as a full event, or `None`
195/// when `parent` is redacted or has no valid edit. An edit counts only when it
196/// shares the parent's sender and type and is not itself redacted; newest is by
197/// `origin_server_ts`, which the typed index sorts on.
198#[implement(Service)]
199#[tracing::instrument(skip_all, level = "trace")]
200async fn newest_replacement(&self, parent: &Pdu) -> Option<Pdu> {
201	if parent.is_redacted() {
202		return None;
203	}
204
205	let parent_id: PduId = self
206		.services
207		.timeline
208		.get_pdu_id(parent.event_id())
209		.map_ok(Into::into)
210		.await
211		.ok()?;
212
213	let replacements = self.replacement_children(parent, parent_id);
214
215	pin_mut!(replacements);
216	replacements.next().await
217}
218
219/// Stream `parent`'s valid `m.replace` children, newest `origin_server_ts`
220/// first, from the typed index. A child counts only when it shares the parent's
221/// sender and type and is not itself redacted.
222#[implement(Service)]
223fn replacement_children<'a>(
224	&'a self,
225	parent: &'a Pdu,
226	parent_id: PduId,
227) -> impl Stream<Item = Pdu> + Send + 'a {
228	let shortroomid = parent_id.shortroomid;
229	let prefix = prefix(shortroomid, parent_id.count, Tag::Replace);
230
231	let mut seek = Seek::new();
232
233	seek.extend(prefix.iter().copied());
234	seek.extend([u8::MAX; size_of::<u64>() * 2]);
235
236	self.db
237		.relatesto_typed
238		.rev_raw_keys_from(seek.as_slice())
239		.ignore_err()
240		.ready_take_while(move |key| key.starts_with(&prefix))
241		.map(|key| u64_from_u8(&key[CHILD_COUNT_OFFSET..KEY_LEN]))
242		.map(PduCount::from_unsigned)
243		.map(move |count| (shortroomid, count))
244		.filter_map(async |(shortroomid, count)| {
245			let child_id: RawPduId = PduId { shortroomid, count }.into();
246			self.services
247				.timeline
248				.get_pdu_from_id(&child_id)
249				.await
250				.ok()
251				.filter(|child| !child.is_redacted())
252				.filter(|child| child.sender() == parent.sender())
253				.filter(|child| child.kind() == parent.kind())
254		})
255}
256
257/// MSC3856: evaluate one served thread root against the requester's ignore
258/// list. A cheap participant intersection gates the reply walk; one walk then
259/// yields the replacement `latest_event`, the ignored-aware `count`, and the
260/// omit-when-every-reply-is-ignored verdict. A root whose replies are not
261/// indexed (backfilled history) adjusts nothing beyond its own redacted form.
262#[implement(Service)]
263#[tracing::instrument(skip_all, level = "trace")]
264pub async fn ignored_thread_view(
265	&self,
266	sender_user: &UserId,
267	ignored: &BTreeSet<OwnedUserId>,
268	root: &Pdu,
269) -> IgnoredThreadView {
270	let Ok(root_id) = self
271		.services
272		.timeline
273		.get_pdu_id(root.event_id())
274		.await
275	else {
276		return Unchanged;
277	};
278
279	let participants = self
280		.services
281		.threads
282		.get_participants(&root_id)
283		.await
284		.unwrap_or_default();
285
286	if !participants
287		.iter()
288		.any(|user| ignored.contains(user))
289	{
290		return Unchanged;
291	}
292
293	let root_pid: PduId = root_id.into();
294	let replies = self
295		.get_relations(
296			root_pid.shortroomid,
297			root_pid.count,
298			None,
299			Direction::Backward,
300			Some(sender_user),
301		)
302		.ready_filter_map(|(_, pdu)| {
303			pdu.get_content()
304				.is_ok_and(|content: ExtractRelatesTo| {
305					matches!(content.relates_to, Relation::Thread(_))
306				})
307				.then_some(pdu)
308		});
309
310	let fold = |(total, unignored, latest): (usize, usize, Option<Pdu>), pdu: Pdu| match ignored
311		.contains(pdu.sender())
312	{
313		| true => (total.saturating_add(1), unignored, latest),
314		| false => (total.saturating_add(1), unignored.saturating_add(1), latest.or(Some(pdu))),
315	};
316
317	let (total, unignored, latest) = replies.ready_fold((0, 0, None), fold).await;
318
319	if total == 0 {
320		return match self.redacted_root(ignored, root).await {
321			| None => Unchanged,
322			| root => Adjusted { root, count: None, latest: None },
323		};
324	}
325
326	if unignored == 0 {
327		return Omitted;
328	}
329
330	let swap = root
331		.thread_latest_event()
332		.is_some_and(|(_, sender)| ignored.contains(&sender));
333
334	let latest = match swap.then_some(latest).flatten() {
335		| None => None,
336		| Some(reply) => {
337			// MSC4025: the swapped-in reply must not reopen the erased-sender
338			// seam the bundle pass gates on the stored latest.
339			let reply = self
340				.services
341				.state_accessor
342				.erased_view(sender_user, &reply)
343				.await
344				.unwrap_or(reply);
345
346			Some(reply.into_format())
347		},
348	};
349
350	let count = unignored.ne(&total).then_some(unignored);
351
352	let root = self.redacted_root(ignored, root).await;
353
354	if root.is_none() && count.is_none() && latest.is_none() {
355		return Unchanged;
356	}
357
358	Adjusted { root, count, latest }
359}
360
361/// The spec'd redacted form of an ignored sender's thread root, content side
362/// only; `None` when the sender is not ignored, or on a redaction failure
363/// (serving unredacted then matches the reference implementation).
364#[implement(Service)]
365#[tracing::instrument(skip_all, level = "trace")]
366async fn redacted_root(&self, ignored: &BTreeSet<OwnedUserId>, root: &Pdu) -> Option<Box<Pdu>> {
367	ignored
368		.contains(root.sender())
369		.then_async(async || {
370			self.services
371				.state
372				.get_room_version_rules(root.room_id())
373				.await
374				.log_err()
375				.ok()
376				.and_then(|rules| root.redacted(&rules.redaction).log_err().ok())
377				.map(Box::new)
378		})
379		.await
380		.flatten()
381}