Skip to main content

tuwunel_api/client/
search.rs

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