Skip to main content

tuwunel_service/presence/
pipeline.rs

1//! Presence update pipeline.
2//!
3//! This module centralizes the write path for presence updates. It keeps the
4//! aggregation and timer logic in one place so the public `Service` surface
5//! remains small and the update flow is easy to review.
6
7use std::time::Duration;
8
9use futures::TryFutureExt;
10use ruma::{
11	DeviceId, OwnedUserId, UInt, UserId, events::presence::PresenceEvent, presence::PresenceState,
12};
13use tokio::time::sleep;
14use tuwunel_core::{
15	Error, Result, debug,
16	debug::INFO_SPAN_LEVEL,
17	error,
18	result::LogErr,
19	trace,
20	utils::{future::OptionFutureExt, option::OptionExt},
21};
22
23use super::{
24	Ping, Service, TimerFired,
25	aggregate::{self, StatusMsg},
26};
27
28impl Service {
29	fn device_key(device_id: Option<&DeviceId>, is_remote: bool) -> aggregate::DeviceKey {
30		if is_remote {
31			return aggregate::DeviceKey::Remote;
32		}
33
34		match device_id {
35			| Some(device_id) => aggregate::DeviceKey::Device(device_id.to_owned()),
36			| None => aggregate::DeviceKey::UnknownLocal,
37		}
38	}
39
40	fn schedule_presence_timer(
41		&self,
42		user_id: &UserId,
43		presence_state: &PresenceState,
44		count: u64,
45	) -> Result {
46		if !(self.timeout_remote_users || self.services.globals.user_is_local(user_id))
47			|| user_id == self.services.globals.server_user
48		{
49			return Ok(());
50		}
51
52		let timeout = match presence_state {
53			| PresenceState::Online =>
54				self.services
55					.server
56					.config
57					.presence_idle_timeout_s,
58			| _ =>
59				self.services
60					.server
61					.config
62					.presence_offline_timeout_s,
63		};
64
65		self.timer_channel
66			.0
67			.send((user_id.to_owned(), Duration::from_secs(timeout), count))
68			.map_err(|e| {
69				error!("Failed to add presence timer: {}", e);
70				Error::bad_database("Failed to add presence timer")
71			})
72	}
73
74	fn refresh_skip_decision(
75		refresh_window_ms: Option<u64>,
76		last_event: Option<&PresenceEvent>,
77		last_count: Option<u64>,
78	) -> Option<(u64, u64)> {
79		let (Some(refresh_ms), Some(event), Some(count)) =
80			(refresh_window_ms, last_event, last_count)
81		else {
82			return None;
83		};
84
85		let last_last_active_ago: u64 = event.content.last_active_ago?.into();
86
87		(last_last_active_ago < refresh_ms).then_some((count, last_last_active_ago))
88	}
89
90	fn timer_is_stale(expected_count: u64, current_count: u64) -> bool {
91		expected_count != current_count
92	}
93
94	#[tracing::instrument(
95		name = "presence",
96		level = INFO_SPAN_LEVEL,
97		skip_all,
98		fields(
99			%user_id,
100			?device_key,
101			%state,
102			?currently_active,
103		),
104	)]
105	#[expect(clippy::too_many_arguments)]
106	async fn apply_device_presence_update(
107		&self,
108		user_id: &UserId,
109		device_key: aggregate::DeviceKey,
110		state: &PresenceState,
111		currently_active: Option<bool>,
112		last_active_ago: Option<UInt>,
113		status_msg: StatusMsg,
114		refresh_window_ms: Option<u64>,
115	) -> Result {
116		let now = tuwunel_core::utils::millis_since_unix_epoch();
117		let preserve_status = matches!(status_msg, StatusMsg::Unchanged);
118
119		// 1) Capture per-device presence snapshot for aggregation.
120		debug!(
121			?user_id,
122			?device_key,
123			?state,
124			currently_active,
125			last_active_ago = last_active_ago.map(u64::from),
126			"Presence update received"
127		);
128
129		self.device_presence
130			.update(
131				user_id,
132				device_key,
133				state,
134				currently_active,
135				last_active_ago,
136				status_msg,
137				now,
138			)
139			.await;
140
141		// 2) Compute the aggregated presence across all devices.
142		let aggregated = self
143			.device_presence
144			.aggregate(user_id, now, self.idle_timeout, self.offline_timeout)
145			.await;
146
147		debug!(
148			?user_id,
149			agg_state = ?aggregated.state,
150			agg_currently_active = aggregated.currently_active,
151			agg_last_active_ts = aggregated.last_active_ts,
152			agg_device_count = aggregated.device_count,
153			"Presence aggregate computed"
154		);
155
156		// 3) Load the last persisted presence to decide whether to skip or merge.
157		let last_presence = self.db.get_presence(user_id).await;
158		let (last_count, last_event) = match last_presence {
159			| Ok((count, event)) => (Some(count), Some(event)),
160			| Err(_) => (None, None),
161		};
162
163		let last_state = last_event
164			.as_ref()
165			.map(|event| event.content.presence.clone());
166
167		let state_changed = match &last_event {
168			| Some(event) => event.content.presence != aggregated.state,
169			| None => true,
170		};
171
172		// 4) For rapid pings with no state change, skip writes and reschedule.
173		if !state_changed
174			&& let Some((count, last_last_active_ago)) =
175				Self::refresh_skip_decision(refresh_window_ms, last_event.as_ref(), last_count)
176		{
177			let presence = last_event
178				.as_ref()
179				.map(|event| &event.content.presence)
180				.unwrap_or(state);
181
182			self.schedule_presence_timer(user_id, presence, count)
183				.log_err()
184				.ok();
185
186			debug!(
187				?user_id,
188				?state,
189				last_last_active_ago,
190				"Skipping presence update: refresh window (timer rescheduled)"
191			);
192
193			return Ok(());
194		}
195
196		// 5) If we just transitioned away from online, flush suppressed pushes.
197		if matches!(last_state, Some(PresenceState::Online))
198			&& aggregated.state != PresenceState::Online
199		{
200			debug!(
201				?user_id,
202				from = ?PresenceState::Online,
203				to = ?aggregated.state,
204				"Presence went inactive; flushing suppressed pushes"
205			);
206
207			self.services
208				.sending
209				.schedule_flush_suppressed_for_user(
210					user_id.to_owned(),
211					"presence->inactive (aggregate)",
212				);
213		}
214
215		// 6) Unchanged preserves the last non-empty status; explicit None clears it.
216		let fallback_status = || {
217			last_event
218				.and_then(|event| event.content.status_msg)
219				.filter(|msg| !msg.is_empty())
220		};
221
222		let status_msg = aggregated
223			.status_msg
224			.or_else(|| preserve_status.then(fallback_status).flatten());
225
226		let last_active_ago =
227			Some(UInt::new_saturating(now.saturating_sub(aggregated.last_active_ts)));
228
229		self.set_presence(
230			user_id,
231			&aggregated.state,
232			Some(aggregated.currently_active),
233			last_active_ago,
234			status_msg,
235		)
236		.await
237	}
238
239	/// Pings the presence of the given user, defaulting the state to online.
240	///
241	/// Requests authenticated with an appservice token do not imply user
242	/// activity. In particular, they must not update presence or device
243	/// last-seen data. Explicit appservice presence updates use
244	/// [`Self::set_presence_for_device`] instead.
245	pub async fn maybe_ping_presence(&self, user_id: &UserId, args: Ping<'_>) -> Result {
246		const REFRESH_TIMEOUT: u64 = 30 * 1000;
247
248		if args.appservice.is_some()
249			|| !self.services.server.config.allow_local_presence
250			|| self.services.db.is_read_only()
251		{
252			return Ok(());
253		}
254
255		let update_device_seen = args.device_id.map_async(|device_id| {
256			self.services
257				.users
258				.update_device_last_seen(user_id, device_id, args.client_ip, None)
259		});
260
261		let new_state = args.new_state.unwrap_or(&PresenceState::Online);
262		let currently_active = *new_state == PresenceState::Online;
263		let set_presence = self.apply_device_presence_update(
264			user_id,
265			Self::device_key(args.device_id, false),
266			new_state,
267			Some(currently_active),
268			UInt::new(0),
269			StatusMsg::Unchanged,
270			Some(REFRESH_TIMEOUT),
271		);
272
273		debug!(?user_id, ?new_state, currently_active, "Presence ping accepted");
274
275		futures::future::try_join(set_presence, update_device_seen.unwrap_or(Ok(())))
276			.map_ok(|_| ())
277			.await
278	}
279
280	/// Applies an explicit presence update for a local device.
281	pub async fn set_presence_for_device(
282		&self,
283		user_id: &UserId,
284		device_id: Option<&DeviceId>,
285		state: &PresenceState,
286		status_msg: Option<String>,
287	) -> Result {
288		let currently_active = *state == PresenceState::Online;
289		self.apply_device_presence_update(
290			user_id,
291			Self::device_key(device_id, false),
292			state,
293			Some(currently_active),
294			None,
295			StatusMsg::Set(status_msg),
296			None,
297		)
298		.await
299	}
300
301	/// Applies a presence update received over federation.
302	pub async fn set_presence_from_federation(
303		&self,
304		user_id: &UserId,
305		state: &PresenceState,
306		currently_active: bool,
307		last_active_ago: UInt,
308		status_msg: Option<String>,
309	) -> Result {
310		self.apply_device_presence_update(
311			user_id,
312			Self::device_key(None, true),
313			state,
314			Some(currently_active),
315			Some(last_active_ago),
316			StatusMsg::Set(status_msg),
317			None,
318		)
319		.await
320	}
321
322	/// Adds a presence event which will be saved until a new event replaces it.
323	pub async fn set_presence(
324		&self,
325		user_id: &UserId,
326		state: &PresenceState,
327		currently_active: Option<bool>,
328		last_active_ago: Option<UInt>,
329		status_msg: Option<String>,
330	) -> Result {
331		let presence_state = match state.as_str() {
332			| "" => &PresenceState::Offline, // default an empty string to 'offline'
333			| &_ => state,
334		};
335
336		let count = self
337			.db
338			.set_presence(user_id, presence_state, currently_active, last_active_ago, status_msg)
339			.await?;
340
341		if let Some(count) = count {
342			let is_local = self.services.globals.user_is_local(user_id);
343			let is_server_user = user_id == self.services.globals.server_user;
344			let allow_timeout = self.timeout_remote_users || is_local;
345
346			if allow_timeout && !is_server_user {
347				self.schedule_presence_timer(user_id, presence_state, count)?;
348			}
349		}
350
351		Ok(())
352	}
353
354	pub(super) async fn process_presence_timer(
355		&self,
356		user_id: &OwnedUserId,
357		expected_count: u64,
358	) -> Result {
359		let Ok((current_count, presence)) = self.db.get_presence_raw(user_id).await else {
360			return Ok(());
361		};
362
363		if Self::timer_is_stale(expected_count, current_count) {
364			trace!(?user_id, expected_count, current_count, "Skipping stale presence timer");
365			return Ok(());
366		}
367
368		let presence_state = presence.state.clone();
369		let now = tuwunel_core::utils::millis_since_unix_epoch();
370		let aggregated = self
371			.device_presence
372			.aggregate(user_id, now, self.idle_timeout, self.offline_timeout)
373			.await;
374
375		if aggregated.device_count == 0 {
376			let last_active_ago =
377				Some(UInt::new_saturating(now.saturating_sub(presence.last_active_ts)));
378			let status_msg = presence.status_msg;
379
380			let new_state = match (&presence_state, last_active_ago.map(u64::from)) {
381				| (PresenceState::Online, Some(ago)) if ago >= self.idle_timeout =>
382					Some(PresenceState::Unavailable),
383				| (PresenceState::Unavailable, Some(ago)) if ago >= self.offline_timeout =>
384					Some(PresenceState::Offline),
385				| _ => None,
386			};
387
388			debug!(
389				"Processed presence timer for user '{user_id}': Old state = {presence_state}, \
390				 New state = {new_state:?}"
391			);
392
393			if let Some(new_state) = new_state {
394				if matches!(new_state, PresenceState::Unavailable | PresenceState::Offline) {
395					self.services
396						.sending
397						.schedule_flush_suppressed_for_user(
398							user_id.to_owned(),
399							"presence->inactive",
400						);
401				}
402				self.set_presence(user_id, &new_state, Some(false), last_active_ago, status_msg)
403					.await?;
404			}
405
406			return Ok(());
407		}
408
409		if aggregated.state == presence_state {
410			self.schedule_presence_timer(user_id, &presence_state, current_count)
411				.log_err()
412				.ok();
413			return Ok(());
414		}
415
416		if matches!(aggregated.state, PresenceState::Unavailable | PresenceState::Offline) {
417			self.services
418				.sending
419				.schedule_flush_suppressed_for_user(user_id.to_owned(), "presence->inactive");
420		}
421
422		let status_msg = aggregated.status_msg.or(presence.status_msg);
423		let last_active_ago =
424			Some(UInt::new_saturating(now.saturating_sub(aggregated.last_active_ts)));
425
426		self.set_presence(
427			user_id,
428			&aggregated.state,
429			Some(aggregated.currently_active),
430			last_active_ago,
431			status_msg,
432		)
433		.await?;
434
435		Ok(())
436	}
437}
438
439pub(super) async fn presence_timer(
440	user_id: OwnedUserId,
441	timeout: Duration,
442	count: u64,
443) -> TimerFired {
444	sleep(timeout).await;
445
446	(user_id, count)
447}
448
449#[cfg(test)]
450mod tests {
451	use ruma::{uint, user_id};
452
453	use super::*;
454
455	#[test]
456	fn refresh_window_skip_decision() {
457		let user_id = user_id!("@alice:example.com");
458		let event = PresenceEvent {
459			sender: user_id.to_owned(),
460			content: ruma::events::presence::PresenceEventContent {
461				presence: PresenceState::Online,
462				status_msg: None,
463				currently_active: Some(true),
464				last_active_ago: Some(uint!(10)),
465				avatar_url: None,
466				displayname: None,
467			},
468		};
469
470		let decision = Service::refresh_skip_decision(Some(20), Some(&event), Some(5));
471		assert_eq!(decision, Some((5, 10)));
472
473		let decision = Service::refresh_skip_decision(Some(5), Some(&event), Some(5));
474		assert_eq!(decision, None);
475
476		let event_missing_ago = PresenceEvent {
477			sender: user_id.to_owned(),
478			content: ruma::events::presence::PresenceEventContent {
479				presence: PresenceState::Online,
480				status_msg: None,
481				currently_active: Some(true),
482				last_active_ago: None,
483				avatar_url: None,
484				displayname: None,
485			},
486		};
487
488		let decision =
489			Service::refresh_skip_decision(Some(20), Some(&event_missing_ago), Some(5));
490		assert_eq!(decision, None);
491
492		let decision = Service::refresh_skip_decision(Some(20), None, Some(5));
493		assert_eq!(decision, None);
494	}
495
496	#[test]
497	fn timer_stale_detection() {
498		assert!(Service::timer_is_stale(2, 3));
499		assert!(!Service::timer_is_stale(2, 2));
500	}
501}