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