whatsapp_rust/features/
presence.rs1use crate::client::{Client, ClientError};
2use log::{debug, warn};
3use thiserror::Error;
4use wacore::WireEnum;
5use wacore::iq::tctoken::build_tc_token_node;
6use wacore_binary::Jid;
7use wacore_binary::Node;
8use wacore_binary::builder::NodeBuilder;
9
10#[derive(Debug, Error)]
11#[non_exhaustive]
12pub enum PresenceError {
13 #[error("cannot send presence without a push name set")]
14 PushNameEmpty,
15 #[error("{0}")]
17 Client(#[from] ClientError),
18 #[error("{0}")]
20 Other(#[from] anyhow::Error),
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
25#[non_exhaustive]
26pub enum PresenceStatus {
27 #[wire = "available"]
28 Available,
29 #[wire = "unavailable"]
30 Unavailable,
31}
32
33impl From<crate::types::presence::Presence> for PresenceStatus {
34 fn from(p: crate::types::presence::Presence) -> Self {
35 match p {
36 crate::types::presence::Presence::Available => PresenceStatus::Available,
37 crate::types::presence::Presence::Unavailable => PresenceStatus::Unavailable,
38 }
39 }
40}
41
42pub struct Presence<'a> {
44 client: &'a Client,
45}
46
47impl<'a> Presence<'a> {
48 pub(crate) fn new(client: &'a Client) -> Self {
49 Self { client }
50 }
51
52 async fn build_subscription_node(&self, jid: &Jid) -> Node {
53 let mut builder = NodeBuilder::new("presence")
54 .attr("type", "subscribe")
55 .attr("to", jid);
56
57 if let Some(token) = self.client.lookup_tc_token_for_jid(jid).await {
59 builder = builder.children([build_tc_token_node(&token)]);
60 }
61
62 builder.build()
63 }
64
65 fn build_unsubscription_node(&self, jid: &Jid) -> Node {
66 NodeBuilder::new("presence")
67 .attr("type", "unsubscribe")
68 .attr("to", jid)
69 .build()
70 }
71
72 pub async fn set(&self, status: PresenceStatus) -> Result<(), PresenceError> {
74 let device_snapshot = self.client.persistence_manager().get_device_snapshot();
75
76 debug!(
77 "send_presence called with push_name: '{}'",
78 device_snapshot.push_name
79 );
80
81 if device_snapshot.push_name.is_empty() {
82 warn!("Cannot send presence: push_name is empty!");
83 return Err(PresenceError::PushNameEmpty);
84 }
85
86 match status {
89 PresenceStatus::Available => {
90 self.client.send_unified_session().await;
91 self.client.mark_receipts_active_on_presence();
92 }
93 PresenceStatus::Unavailable => self.client.mark_receipts_inactive_on_presence(),
94 }
95
96 let presence_type = status.as_str();
97
98 let node = NodeBuilder::new("presence")
99 .attr("type", presence_type)
100 .attr("name", &device_snapshot.push_name)
101 .build();
102
103 debug!(
104 "Sending presence stanza: <presence type=\"{}\" name=\"{}\"/>",
105 presence_type,
106 node.attrs
107 .get("name")
108 .map(|s| s.as_str())
109 .as_deref()
110 .unwrap_or("")
111 );
112
113 self.client.send_node(node).await?;
114 Ok(())
115 }
116
117 pub async fn set_available(&self) -> Result<(), PresenceError> {
119 self.set(PresenceStatus::Available).await
120 }
121
122 pub async fn set_unavailable(&self) -> Result<(), PresenceError> {
124 self.set(PresenceStatus::Unavailable).await
125 }
126
127 pub async fn subscribe(&self, jid: impl Into<Jid>) -> Result<(), PresenceError> {
139 let jid = &jid.into();
140 debug!("presence subscribe: subscribing to {}", jid);
141 let node = self.build_subscription_node(jid).await;
142 self.client.send_node(node).await?;
143 self.client.track_presence_subscription(jid.clone()).await;
144 Ok(())
145 }
146
147 pub(crate) async fn re_subscribe_when_active(&self, jid: &Jid) -> Result<(), PresenceError> {
150 if !self
151 .client
152 .presence_subscriptions
153 .lock()
154 .await
155 .contains(jid)
156 {
157 return Ok(());
158 }
159
160 let node = self.build_subscription_node(jid).await;
161 self.client.send_node(node).await?;
162 Ok(())
163 }
164
165 pub async fn unsubscribe(&self, jid: &Jid) -> Result<(), PresenceError> {
174 debug!("presence unsubscribe: unsubscribing from {}", jid);
175 let node = self.build_unsubscription_node(jid);
176 self.client.send_node(node).await?;
177 self.client.untrack_presence_subscription(jid).await;
178 Ok(())
179 }
180}
181
182impl Client {
183 pub(crate) async fn track_presence_subscription(&self, jid: Jid) {
184 self.presence_subscriptions.lock().await.insert(jid);
185 }
186
187 pub(crate) async fn untrack_presence_subscription(&self, jid: &Jid) {
188 self.presence_subscriptions.lock().await.remove(jid);
189 }
190
191 pub(crate) async fn tracked_presence_subscriptions(&self) -> Vec<Jid> {
192 self.presence_subscriptions
193 .lock()
194 .await
195 .iter()
196 .cloned()
197 .collect()
198 }
199
200 pub(crate) async fn resubscribe_presence_subscriptions(&self, expected_generation: u64) {
201 let subscribed_jids = self.tracked_presence_subscriptions().await;
202 if subscribed_jids.is_empty() {
203 return;
204 }
205
206 debug!(
207 "Re-subscribing to {} tracked presence subscriptions",
208 subscribed_jids.len()
209 );
210
211 for jid in subscribed_jids {
212 if self
213 .connection_generation
214 .load(std::sync::atomic::Ordering::SeqCst)
215 != expected_generation
216 {
217 debug!("Stopping presence re-subscribe: connection generation changed");
218 return;
219 }
220
221 if !self.is_connected() {
222 debug!("Stopping presence re-subscribe: connection closed");
223 return;
224 }
225
226 if let Err(err) = self.presence().re_subscribe_when_active(&jid).await {
227 warn!("Failed to re-subscribe to presence for {jid}: {err:?}");
228 }
229 }
230 }
231
232 #[allow(clippy::wrong_self_convention)]
234 pub fn presence(&self) -> Presence<'_> {
235 Presence::new(self)
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::TokioRuntime;
243 use crate::bot::Bot;
244 use crate::http::{HttpClient, HttpRequest, HttpResponse};
245 use crate::store::SqliteStore;
246 use crate::store::commands::DeviceCommand;
247 use anyhow::Result;
248 use std::str::FromStr;
249 use std::sync::Arc;
250 use wacore::store::traits::Backend;
251 use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory;
252
253 #[derive(Debug, Clone)]
255 struct MockHttpClient;
256
257 #[async_trait::async_trait]
258 impl HttpClient for MockHttpClient {
259 async fn execute(&self, _request: HttpRequest) -> Result<HttpResponse> {
260 Ok(HttpResponse {
261 status_code: 200,
262 body: br#"self.__swData=JSON.parse(/*BTDS*/"{\"dynamic_data\":{\"SiteData\":{\"server_revision\":1026131876,\"client_revision\":1026131876}}}");"#.to_vec(),
263 })
264 }
265 }
266
267 async fn create_test_backend() -> Arc<dyn Backend> {
268 let temp_db = format!(
269 "file:memdb_presence_{}?mode=memory&cache=shared",
270 uuid::Uuid::new_v4()
271 );
272 Arc::new(
273 SqliteStore::new(&temp_db)
274 .await
275 .expect("Failed to create test SqliteStore"),
276 ) as Arc<dyn Backend>
277 }
278
279 #[tokio::test]
281 async fn test_presence_rejected_when_pushname_empty() {
282 let backend = create_test_backend().await;
283 let transport = TokioWebSocketTransportFactory::new();
284
285 let bot = Bot::builder()
286 .with_backend_arc(backend)
287 .with_transport_factory(transport)
288 .with_http_client(MockHttpClient)
289 .with_runtime(TokioRuntime)
290 .build()
291 .await
292 .expect("Failed to build bot");
293
294 let client = bot.client();
295
296 let snapshot = client.persistence_manager().get_device_snapshot();
297 assert!(
298 snapshot.push_name.is_empty(),
299 "Pushname should be empty on fresh device"
300 );
301
302 let result = client.presence().set(PresenceStatus::Available).await;
303
304 assert!(
305 result.is_err(),
306 "Presence should fail when pushname is empty"
307 );
308 assert!(
309 matches!(result.unwrap_err(), PresenceError::PushNameEmpty),
310 "Error should be PushNameEmpty"
311 );
312 }
313
314 #[tokio::test]
316 async fn test_presence_succeeds_after_pushname_set() {
317 let backend = create_test_backend().await;
318 let transport = TokioWebSocketTransportFactory::new();
319
320 let bot = Bot::builder()
321 .with_backend_arc(backend)
322 .with_transport_factory(transport)
323 .with_http_client(MockHttpClient)
324 .with_runtime(TokioRuntime)
325 .build()
326 .await
327 .expect("Failed to build bot");
328
329 let client = bot.client();
330
331 client
332 .persistence_manager()
333 .process_command(DeviceCommand::SetPushName("Test User".to_string()))
334 .await;
335
336 let snapshot = client.persistence_manager().get_device_snapshot();
337 assert_eq!(snapshot.push_name, "Test User");
338
339 let result = client.presence().set(PresenceStatus::Available).await;
341
342 if let Err(e) = result {
343 assert!(
344 !matches!(e, PresenceError::PushNameEmpty),
345 "Should not fail due to pushname, got: {}",
346 e
347 );
348 assert!(
349 matches!(e, PresenceError::Client(_)),
350 "Expected connection error (Client), got: {}",
351 e
352 );
353 }
354 }
355
356 #[tokio::test]
358 async fn test_pushname_presence_flow_matches_whatsapp_web() {
359 let backend = create_test_backend().await;
360 let transport = TokioWebSocketTransportFactory::new();
361
362 let bot = Bot::builder()
363 .with_backend_arc(backend)
364 .with_transport_factory(transport)
365 .with_http_client(MockHttpClient)
366 .with_runtime(TokioRuntime)
367 .build()
368 .await
369 .expect("Failed to build bot");
370
371 let client = bot.client();
372
373 let snapshot = client.persistence_manager().get_device_snapshot();
375 assert!(snapshot.push_name.is_empty());
376
377 let result = client.presence().set(PresenceStatus::Available).await;
379 assert!(matches!(result, Err(PresenceError::PushNameEmpty)));
380
381 client
383 .persistence_manager()
384 .process_command(DeviceCommand::SetPushName("WhatsApp User".to_string()))
385 .await;
386
387 let result = client.presence().set(PresenceStatus::Available).await;
389
390 if let Err(e) = result {
391 assert!(
392 !matches!(e, PresenceError::PushNameEmpty),
393 "Error should be connection-related: {}",
394 e
395 );
396 }
397 }
398
399 #[tokio::test]
400 async fn test_presence_subscription_tracking_is_deduplicated() {
401 let backend = create_test_backend().await;
402 let transport = TokioWebSocketTransportFactory::new();
403
404 let bot = Bot::builder()
405 .with_backend_arc(backend)
406 .with_transport_factory(transport)
407 .with_http_client(MockHttpClient)
408 .with_runtime(TokioRuntime)
409 .build()
410 .await
411 .expect("Failed to build bot");
412
413 let client = bot.client();
414 let jid = Jid::from_str("1234567890@s.whatsapp.net").expect("valid jid");
415
416 client.track_presence_subscription(jid.clone()).await;
417 client.track_presence_subscription(jid.clone()).await;
418
419 let tracked = client.tracked_presence_subscriptions().await;
420 assert_eq!(tracked, vec![jid]);
421 }
422
423 #[tokio::test]
424 async fn test_presence_unsubscription_removes_tracked_jid() {
425 let backend = create_test_backend().await;
426 let transport = TokioWebSocketTransportFactory::new();
427
428 let bot = Bot::builder()
429 .with_backend_arc(backend)
430 .with_transport_factory(transport)
431 .with_http_client(MockHttpClient)
432 .with_runtime(TokioRuntime)
433 .build()
434 .await
435 .expect("Failed to build bot");
436
437 let client = bot.client();
438 let jid = Jid::from_str("1234567890@s.whatsapp.net").expect("valid jid");
439
440 client.track_presence_subscription(jid.clone()).await;
441 client.untrack_presence_subscription(&jid).await;
442
443 assert!(
444 client.tracked_presence_subscriptions().await.is_empty(),
445 "unsubscribe tracking should remove the jid"
446 );
447 }
448
449 #[tokio::test]
450 async fn test_unsubscribe_builds_expected_presence_stanza() {
451 let jid = Jid::from_str("1234567890@s.whatsapp.net").expect("valid jid");
452 let backend = create_test_backend().await;
453 let transport = TokioWebSocketTransportFactory::new();
454
455 let bot = Bot::builder()
456 .with_backend_arc(backend)
457 .with_transport_factory(transport)
458 .with_http_client(MockHttpClient)
459 .with_runtime(TokioRuntime)
460 .build()
461 .await
462 .expect("Failed to build bot");
463
464 let client = bot.client();
465 let node = client.presence().build_unsubscription_node(&jid);
466
467 assert_eq!(node.tag, "presence");
468 assert!(node.attrs.get("type").is_some_and(|v| v == "unsubscribe"));
469 assert_eq!(
470 node.attrs.get("to").map(ToString::to_string),
471 Some(jid.to_string())
472 );
473 assert!(
474 node.content.is_none(),
475 "unsubscribe stanza should not have children"
476 );
477 }
478}