Skip to main content

tuwunel_api_client/
search.rs

1use std::collections::BTreeMap;
2
3use axum::extract::State;
4use futures::{
5	FutureExt, Stream, StreamExt, TryFutureExt, TryStreamExt,
6	future::{
7		Either::{Left, Right},
8		join,
9	},
10};
11use itertools::Itertools;
12use ruma::{
13	OwnedRoomId, RoomId, UInt, UserId,
14	api::client::search::search_events::{
15		self,
16		v3::{
17			Criteria, EventContext, EventContextResult, ResultCategories, ResultRoomEvents,
18			SearchResult,
19		},
20	},
21	events::AnyStateEvent,
22	serde::Raw,
23};
24use search_events::v3::{Request, Response};
25use tuwunel_core::{
26	Err, Result, at, is_true,
27	utils::{
28		IterStream,
29		math::usize_from_ruma_bounded,
30		option::OptionExt,
31		stream::{ReadyExt, TryIgnore, WidebandExt},
32	},
33};
34use tuwunel_matrix::{Event, PduCount};
35use tuwunel_service::{
36	Services,
37	rooms::{search::RoomQuery, timeline::PdusIterItem},
38};
39
40use super::visibility_filter;
41use crate::Ruma;
42
43type RoomStates = BTreeMap<OwnedRoomId, RoomState>;
44type RoomState = Vec<Raw<AnyStateEvent>>;
45
46const LIMIT_DEFAULT: usize = 10;
47const LIMIT_MAX: usize = 100;
48const CONTEXT_MAX: usize = 20;
49
50/// # `POST /_matrix/client/r0/search`
51///
52/// Searches rooms for messages.
53///
54/// - Only works if the user is currently joined to the room (TODO: Respect
55///   history visibility)
56pub(crate) async fn search_events_route(
57	State(services): State<crate::State>,
58	body: Ruma<Request>,
59) -> Result<Response> {
60	let sender_user = body.sender_user();
61	let next_batch = body.next_batch.as_deref();
62	let room_events = body
63		.search_categories
64		.room_events
65		.as_ref()
66		.map_async(|criteria| category_room_events(&services, sender_user, next_batch, criteria))
67		.await
68		.transpose()?;
69
70	Ok(Response {
71		search_categories: ResultCategories {
72			room_events: room_events.unwrap_or_default(),
73		},
74	})
75}
76
77#[expect(clippy::map_unwrap_or)]
78async fn category_room_events(
79	services: &Services,
80	sender_user: &UserId,
81	next_batch: Option<&str>,
82	criteria: &Criteria,
83) -> Result<ResultRoomEvents> {
84	let filter = &criteria.filter;
85
86	let limit = filter
87		.limit
88		.map_or(LIMIT_DEFAULT, |limit| usize_from_ruma_bounded(limit, LIMIT_DEFAULT, LIMIT_MAX));
89
90	let before: Option<PduCount> = next_batch.map(str::parse).transpose()?;
91
92	let rooms = filter
93		.rooms
94		.clone()
95		.map(IntoIterator::into_iter)
96		.map(IterStream::stream)
97		.map(Left)
98		.unwrap_or_else(|| {
99			let joined = services
100				.state_cache
101				.rooms_joined(sender_user)
102				.map(ToOwned::to_owned);
103
104			Right(joined)
105		});
106
107	let results: Vec<_> = rooms
108		.filter_map(async |room_id| {
109			check_room_visible(services, sender_user, &room_id, criteria)
110				.await
111				.is_ok()
112				.then_some(room_id)
113		})
114		.filter_map(async |room_id| {
115			let query = RoomQuery {
116				room_id: &room_id,
117				user_id: Some(sender_user),
118				criteria,
119				before,
120				limit,
121			};
122
123			let (count, results) = services.search.search_pdus(&query).await.ok()?;
124
125			results
126				.collect::<Vec<_>>()
127				.map(|results| (room_id.clone(), count, results))
128				.map(Some)
129				.await
130		})
131		.collect()
132		.await;
133
134	let total: UInt = results
135		.iter()
136		.fold(0, |a: usize, (_, count, _)| a.saturating_add(*count))
137		.try_into()?;
138
139	let state: RoomStates = results
140		.iter()
141		.stream()
142		.ready_filter(|_| criteria.include_state.is_some_and(is_true!()))
143		.filter_map(async |(room_id, ..)| {
144			procure_room_state(services, sender_user, room_id)
145				.map_ok(|state| (room_id.clone(), state))
146				.await
147				.ok()
148		})
149		.collect()
150		.await;
151
152	let page: Vec<_> = results
153		.into_iter()
154		.map(at!(2))
155		.kmerge_by(|(a, _), (b, _)| a > b)
156		.take(limit)
157		.collect();
158
159	let next_batch = page
160		.last()
161		.filter(|_| page.len() >= limit)
162		.map(|(count, _)| count.to_string());
163
164	let results: Vec<SearchResult> = page
165		.into_iter()
166		.map(at!(1))
167		.stream()
168		.map(Event::into_pdu)
169		.wide_then(async |pdu| {
170			let context =
171				event_context(services, sender_user, &pdu, &criteria.event_context).await;
172
173			let pdu = services
174				.pdu_metadata
175				.bundle_aggregations(sender_user, pdu)
176				.await;
177
178			SearchResult {
179				rank: None,
180				result: Some(pdu.into_format()),
181				context,
182			}
183		})
184		.collect()
185		.await;
186
187	let highlights = criteria
188		.search_term
189		.split_terminator(|c: char| !c.is_alphanumeric())
190		.map(str::to_lowercase)
191		.collect();
192
193	Ok(ResultRoomEvents {
194		count: Some(total),
195		next_batch,
196		results,
197		state,
198		highlights,
199		groups: Default::default(), // TODO
200	})
201}
202
203async fn event_context<E>(
204	services: &Services,
205	sender_user: &UserId,
206	pdu: &E,
207	event_context: &EventContext,
208) -> EventContextResult
209where
210	E: Event,
211{
212	// An absent event_context deserializes to the default 5/5; treat that as no
213	// request.
214	if event_context.is_default() {
215		return EventContextResult::default();
216	}
217
218	let Ok(base_count) = services
219		.timeline
220		.get_pdu_count(pdu.event_id())
221		.await
222	else {
223		return EventContextResult::default();
224	};
225
226	let room_id = pdu.room_id();
227	let before_limit = usize_from_ruma_bounded(event_context.before_limit, 0, CONTEXT_MAX);
228	let after_limit = usize_from_ruma_bounded(event_context.after_limit, 0, CONTEXT_MAX);
229
230	let events_before = collect_context_half(
231		services,
232		services
233			.timeline
234			.pdus_rev(Some(sender_user), room_id, Some(base_count)),
235		sender_user,
236		before_limit,
237	);
238
239	let events_after = collect_context_half(
240		services,
241		services
242			.timeline
243			.pdus(Some(sender_user), room_id, Some(base_count)),
244		sender_user,
245		after_limit,
246	);
247
248	let (events_before, events_after) = join(events_before, events_after).await;
249
250	let start = events_before
251		.last()
252		.map(at!(0))
253		.or(Some(base_count))
254		.as_ref()
255		.map(ToString::to_string);
256
257	let end = events_after
258		.last()
259		.map(at!(0))
260		.or_else(|| Some(base_count.saturating_add(1)))
261		.as_ref()
262		.map(ToString::to_string);
263
264	let events_before = events_before
265		.into_iter()
266		.map(at!(1))
267		.map(Event::into_format)
268		.collect();
269
270	let events_after = events_after
271		.into_iter()
272		.map(at!(1))
273		.map(Event::into_format)
274		.collect();
275
276	EventContextResult {
277		start,
278		end,
279		events_before,
280		events_after,
281		profile_info: BTreeMap::new(),
282	}
283}
284
285async fn collect_context_half<'a, S>(
286	services: &'a Services,
287	pdus: S,
288	sender_user: &'a UserId,
289	take: usize,
290) -> Vec<PdusIterItem>
291where
292	S: Stream<Item = Result<PdusIterItem>> + Send + 'a,
293{
294	pdus.ignore_err()
295		.wide_filter_map(|item| visibility_filter(services, item, sender_user))
296		.take(take)
297		.wide_then(async |(count, pdu)| {
298			let pdu = services
299				.pdu_metadata
300				.bundle_aggregations(sender_user, pdu)
301				.await;
302
303			(count, pdu)
304		})
305		.collect()
306		.await
307}
308
309async fn procure_room_state(
310	services: &Services,
311	sender_user: &UserId,
312	room_id: &RoomId,
313) -> Result<RoomState> {
314	let state = services
315		.state_accessor
316		.room_state_full_pdus(room_id)
317		.map_ok(Event::into_pdu)
318		.map_ok(|pdu| pdu.without_private_unless_sender(Some(sender_user)))
319		.map_ok(Event::into_format)
320		.try_collect()
321		.await?;
322
323	Ok(state)
324}
325
326async fn check_room_visible(
327	services: &Services,
328	user_id: &UserId,
329	room_id: &RoomId,
330	search: &Criteria,
331) -> Result {
332	let check_visible = search.filter.rooms.is_some();
333	let check_state = check_visible && search.include_state.is_some_and(is_true!());
334
335	let is_joined = !check_visible
336		|| services
337			.state_cache
338			.is_joined(user_id, room_id)
339			.await;
340
341	let state_visible = !check_state
342		|| services
343			.state_accessor
344			.user_can_see_state_events(user_id, room_id)
345			.await;
346
347	if !is_joined || !state_visible {
348		return Err!(Request(Forbidden("You don't have permission to view {room_id:?}")));
349	}
350
351	Ok(())
352}