Skip to main content

tuwunel_service/rooms/threads/
mod.rs

1use std::{collections::BTreeMap, pin::pin, sync::Arc};
2
3use futures::{Stream, StreamExt, TryFutureExt, future::join3};
4use ruma::{
5	CanonicalJsonValue, EventId, OwnedEventId, OwnedUserId, RoomId, UserId,
6	api::{Direction, client::threads::get_threads::v1::IncludeThreads},
7	events::{
8		TimelineEventType,
9		relation::{BundledThread, RelationType},
10	},
11	uint,
12};
13use serde::Deserialize;
14use serde_json::json;
15use tuwunel_core::{
16	Event, Result, err,
17	matrix::pdu::{PduCount, PduEvent, PduId, RawPduId},
18	utils::{
19		ReadyExt,
20		stream::{TryIgnore, WidebandExt, automatic_width},
21	},
22};
23use tuwunel_database::{Deserialized, Map, Txn};
24
25#[cfg(test)]
26mod tests;
27
28/// Maximum relation hops walked when resolving thread membership, per
29/// the Matrix v1.4 spec recommendation (also MSC3771/MSC3773).
30const MAX_THREAD_HOPS: usize = 3;
31
32#[derive(Deserialize)]
33struct ExtractThreadRelation {
34	#[serde(rename = "m.relates_to")]
35	relates_to: ThreadRelation,
36}
37
38#[derive(Deserialize)]
39struct ThreadRelation {
40	rel_type: RelationType,
41	event_id: OwnedEventId,
42}
43
44pub struct Service {
45	db: Data,
46	services: Arc<crate::services::OnceServices>,
47}
48
49pub(super) struct Data {
50	threadid_userids: Arc<Map>,
51	threadactivityid_rootid: Arc<Map>,
52	threadrootid_latestcount: Arc<Map>,
53}
54
55impl crate::Service for Service {
56	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
57		Ok(Arc::new(Self {
58			db: Data {
59				threadid_userids: args.db["threadid_userids"].clone(),
60				threadactivityid_rootid: args.db["threadactivityid_rootid"].clone(),
61				threadrootid_latestcount: args.db["threadrootid_latestcount"].clone(),
62			},
63			services: args.services.clone(),
64		}))
65	}
66
67	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
68}
69
70impl Service {
71	/// Resolves the thread root for `event` by walking up `m.relates_to`
72	/// links, bounded at `MAX_THREAD_HOPS`. Returns `None` for events
73	/// that belong to the main timeline. Redaction events carry no
74	/// `m.relates_to` of their own; their thread is resolved from the
75	/// redacted target event per MSC3771/MSC3773.
76	pub async fn get_thread_id<E>(&self, event: &E) -> Option<OwnedEventId>
77	where
78		E: Event,
79	{
80		let initial = match event.get_content::<ExtractThreadRelation>() {
81			| Ok(t) => Some(t.relates_to),
82			| Err(_) => self.relates_to_via_redaction_target(event).await,
83		};
84
85		let mut relates_to = initial?;
86
87		for _ in 0..MAX_THREAD_HOPS {
88			if relates_to.rel_type == RelationType::Thread {
89				return Some(relates_to.event_id);
90			}
91
92			relates_to = self
93				.services
94				.timeline
95				.get_pdu(&relates_to.event_id)
96				.await
97				.ok()?
98				.get_content::<ExtractThreadRelation>()
99				.ok()?
100				.relates_to;
101		}
102
103		None
104	}
105
106	/// Resolve a redaction event's thread by looking through to the
107	/// redacted target. Returns `None` for non-redaction events and for
108	/// redactions whose target is unknown or carries no thread relation.
109	async fn relates_to_via_redaction_target<E>(&self, event: &E) -> Option<ThreadRelation>
110	where
111		E: Event,
112	{
113		if *event.kind() != TimelineEventType::RoomRedaction {
114			return None;
115		}
116
117		let room_rules = self
118			.services
119			.state
120			.get_room_version_rules(event.room_id())
121			.await
122			.ok()?;
123
124		let target_id = event.redacts_id(&room_rules)?;
125
126		self.services
127			.timeline
128			.get_pdu(&target_id)
129			.await
130			.ok()?
131			.get_content::<ExtractThreadRelation>()
132			.ok()
133			.map(|t| t.relates_to)
134	}
135
136	/// `get_thread_id` for an event referenced by id; events missing
137	/// locally resolve to `None` (the main timeline).
138	pub async fn get_thread_id_for_event(&self, event_id: &EventId) -> Option<OwnedEventId> {
139		let pdu = self
140			.services
141			.timeline
142			.get_pdu(event_id)
143			.await
144			.ok()?;
145
146		self.get_thread_id(&pdu).await
147	}
148
149	pub async fn add_to_thread<E>(
150		&self,
151		root_event_id: &EventId,
152		pdu_id: RawPduId,
153		event: &E,
154	) -> Result
155	where
156		E: Event,
157	{
158		let root_id = self
159			.services
160			.timeline
161			.get_pdu_id(root_event_id)
162			.await
163			.map_err(|e| {
164				err!(Request(InvalidParam("Invalid event_id in thread message: {e:?}")))
165			})?;
166
167		let root_pdu = self
168			.services
169			.timeline
170			.get_pdu_from_id(&root_id)
171			.await
172			.map_err(|e| err!(Request(InvalidParam("Thread root not found: {e:?}"))))?;
173
174		let mut root_pdu_json = self
175			.services
176			.timeline
177			.get_pdu_json_from_id(&root_id)
178			.await
179			.map_err(|e| err!(Request(InvalidParam("Thread root pdu not found: {e:?}"))))?;
180
181		let mut users = self
182			.get_participants(&root_id)
183			.await
184			.unwrap_or_else(|_| vec![root_pdu.sender().to_owned()]);
185
186		users.push(event.sender().to_owned());
187
188		// Commit participants and activity before the bundle so concurrent MSC3816
189		// readers never observe stale participation.
190		let mut txn = self.services.db.txn();
191
192		self.update_participants(&mut txn, &root_id, &users);
193
194		let count = pdu_id.pdu_count();
195
196		if matches!(count, PduCount::Normal(_)) {
197			txn.insert_raw(&self.db.threadactivityid_rootid, pdu_id, root_id);
198			txn.insert_raw(&self.db.threadrootid_latestcount, root_id, count.to_be_bytes());
199		}
200
201		txn.execute();
202
203		if let CanonicalJsonValue::Object(unsigned) = root_pdu_json
204			.entry("unsigned".into())
205			.or_insert_with(|| CanonicalJsonValue::Object(BTreeMap::default()))
206		{
207			if let Some(mut relations) = unsigned
208				.get("m.relations")
209				.and_then(|r| r.as_object())
210				.and_then(|r| r.get("m.thread"))
211				.and_then(|relations| {
212					serde_json::from_value::<BundledThread>(relations.clone().into()).ok()
213				}) {
214				// Thread already existed
215				relations.count = relations.count.saturating_add(uint!(1));
216				relations.latest_event = event.to_format();
217
218				let content = serde_json::to_value(relations).expect("to_value always works");
219
220				unsigned.insert(
221					"m.relations".into(),
222					json!({ "m.thread": content })
223						.try_into()
224						.expect("thread is valid json"),
225				);
226			} else {
227				// New thread
228				let relations = BundledThread {
229					latest_event: event.to_format(),
230					count: uint!(1),
231					current_user_participated: true,
232				};
233
234				let content = serde_json::to_value(relations).expect("to_value always works");
235
236				unsigned.insert(
237					"m.relations".into(),
238					json!({ "m.thread": content })
239						.try_into()
240						.expect("thread is valid json"),
241				);
242			}
243
244			self.services
245				.timeline
246				.replace_pdu(&root_id, &root_pdu_json)
247				.await?;
248		}
249
250		Ok(())
251	}
252
253	pub fn threads_until<'a>(
254		&'a self,
255		user_id: &'a UserId,
256		room_id: &'a RoomId,
257		count: PduCount,
258		include: &'a IncludeThreads,
259	) -> impl Stream<Item = Result<(PduCount, PduEvent)>> + Send {
260		let participated = matches!(include, IncludeThreads::Participated);
261
262		self.services
263			.short
264			.get_shortroomid(room_id)
265			.map_ok(move |shortroomid| PduId {
266				shortroomid,
267				count: count.saturating_sub(1),
268			})
269			.map_ok(Into::into)
270			.map_ok(move |current: RawPduId| {
271				self.db
272					.threadactivityid_rootid
273					.rev_raw_stream_from(&current)
274					.ignore_err()
275					.map(|(key, root_id)| (RawPduId::from(key), RawPduId::from(root_id)))
276					.ready_take_while(move |(activity_id, _)| {
277						activity_id.shortroomid() == current.shortroomid()
278					})
279					.map(move |(activity_id, root_id)| {
280						(activity_id, root_id, user_id, participated)
281					})
282					.wide_filter_map(async |(activity_id, root_id, user_id, participated)| {
283						self.live_thread(user_id, participated, activity_id, root_id)
284							.await
285					})
286					.map(Ok)
287			})
288			.try_flatten_stream()
289	}
290
291	/// Resolve one activity row to its thread root, skipping and reaping rows
292	/// the validity pointer has left behind.
293	async fn live_thread(
294		&self,
295		user_id: &UserId,
296		participated: bool,
297		activity_id: RawPduId,
298		root_id: RawPduId,
299	) -> Option<(PduCount, PduEvent)> {
300		let count = activity_id.pdu_count();
301
302		let pointer = self
303			.db
304			.threadrootid_latestcount
305			.get(&root_id)
306			.await
307			.deserialized()
308			.map(PduCount::from_unsigned)
309			.ok()?;
310
311		if count != pointer {
312			// A row ahead of the pointer is a write in flight; only rows behind
313			// the pointer are dead and safe to reap.
314			if count < pointer {
315				self.db
316					.threadactivityid_rootid
317					.remove(&activity_id);
318			}
319
320			return None;
321		}
322
323		if participated && !self.is_participant(&root_id, user_id).await {
324			return None;
325		}
326
327		let mut pdu = self
328			.services
329			.timeline
330			.get_pdu_from_id(&root_id)
331			.await
332			.ok()?;
333
334		if pdu.sender() != user_id {
335			pdu.as_mut_pdu().remove_transaction_id().ok();
336		}
337
338		Some((count, pdu))
339	}
340
341	async fn is_participant(&self, root_id: &RawPduId, user_id: &UserId) -> bool {
342		self.db
343			.threadid_userids
344			.get(root_id)
345			.await
346			.is_ok_and(|participants| {
347				participants
348					.split(|&byte| byte == 0xFF)
349					.any(|user| user == user_id.as_bytes())
350			})
351	}
352
353	pub(super) fn update_participants(
354		&self,
355		txn: &mut Txn,
356		root_id: &RawPduId,
357		participants: &[OwnedUserId],
358	) {
359		let users = participants
360			.iter()
361			.map(|user| user.as_bytes())
362			.collect::<Vec<_>>()
363			.join(&[0xFF][..]);
364
365		txn.insert_raw(&self.db.threadid_userids, root_id, &users);
366	}
367
368	pub(super) async fn get_participants(&self, root_id: &RawPduId) -> Result<Vec<OwnedUserId>> {
369		self.db
370			.threadid_userids
371			.get(root_id)
372			.await
373			.deserialized()
374	}
375
376	/// MSC3816: whether `user_id` has participated in the thread rooted at
377	/// `root_event_id`, having sent the root event or a threaded reply to it.
378	pub async fn user_participated(&self, root_event_id: &EventId, user_id: &UserId) -> bool {
379		let Ok(root_id) = self
380			.services
381			.timeline
382			.get_pdu_id(root_event_id)
383			.await
384		else {
385			return false;
386		};
387
388		self.is_participant(&root_id, user_id).await
389	}
390
391	#[tracing::instrument(skip(self), level = "debug")]
392	pub(super) async fn delete_all_rooms_threads(&self, room_id: &RoomId) -> Result {
393		let Ok(shortroomid) = self.services.short.get_shortroomid(room_id).await else {
394			return Ok(());
395		};
396
397		join3(
398			self.db.threadid_userids.del_prefix(&shortroomid),
399			self.db
400				.threadactivityid_rootid
401				.del_prefix(&shortroomid),
402			self.db
403				.threadrootid_latestcount
404				.del_prefix(&shortroomid),
405		)
406		.await;
407
408		Ok(())
409	}
410
411	/// Rebuild the thread activity index from every thread root. Run once at
412	/// startup behind a `global` marker, and on demand from the admin command.
413	/// Clears first so a partial or stale index is replaced wholesale.
414	pub async fn rebuild_thread_activity(&self) -> Result {
415		self.db.threadactivityid_rootid.clear().await;
416		self.db.threadrootid_latestcount.clear().await;
417
418		self.db
419			.threadid_userids
420			.raw_keys()
421			.ignore_err()
422			.map(RawPduId::from)
423			.for_each_concurrent(automatic_width(), async |root_id| {
424				self.index_thread_activity(root_id).await;
425			})
426			.await;
427
428		Ok(())
429	}
430
431	async fn index_thread_activity(&self, root_id: RawPduId) {
432		let root: PduId = root_id.into();
433
434		let replies = self
435			.services
436			.pdu_metadata
437			.get_relations(root.shortroomid, root.count, None, Direction::Backward, None)
438			.ready_filter_map(|(count, pdu)| {
439				pdu.get_content()
440					.is_ok_and(|content: ExtractThreadRelation| {
441						content.relates_to.rel_type == RelationType::Thread
442					})
443					.then_some(count)
444			});
445
446		let mut replies = pin!(replies);
447
448		let latest = replies.next().await.unwrap_or(root.count);
449
450		let activity_id: RawPduId = PduId {
451			shortroomid: root.shortroomid,
452			count: latest,
453		}
454		.into();
455
456		let mut txn = self.services.db.txn();
457
458		txn.insert_raw(&self.db.threadactivityid_rootid, activity_id, root_id);
459		txn.insert_raw(&self.db.threadrootid_latestcount, root_id, latest.to_be_bytes());
460		txn.execute();
461	}
462}