Skip to main content

whatsapp_rust/features/
presence.rs

1use 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/// Presence status for online/offline state.
19#[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
36/// Feature handle for presence operations.
37pub 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        // Include tctoken if available (no t attribute, matching WhatsApp Web)
52        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    /// Set the presence status.
67    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    /// Set presence to available (online).
112    pub async fn set_available(&self) -> Result<(), PresenceError> {
113        self.set(PresenceStatus::Available).await
114    }
115
116    /// Set presence to unavailable (offline).
117    pub async fn set_unavailable(&self) -> Result<(), PresenceError> {
118        self.set(PresenceStatus::Unavailable).await
119    }
120
121    /// Subscribe to a contact's presence updates.
122    ///
123    /// Sends a `<presence type="subscribe">` stanza to the target JID.
124    /// If a valid tctoken exists for the contact, it is included as a child node.
125    ///
126    /// ## Wire Format
127    /// ```xml
128    /// <presence type="subscribe" to="user@s.whatsapp.net">
129    ///   <tctoken><!-- raw token bytes --></tctoken>
130    /// </presence>
131    /// ```
132    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    /// Unsubscribe from a contact's presence updates.
144    ///
145    /// Sends a `<presence type="unsubscribe">` stanza to the target JID.
146    ///
147    /// ## Wire Format
148    /// ```xml
149    /// <presence type="unsubscribe" to="user@s.whatsapp.net"/>
150    /// ```
151    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            // Check membership before re-subscribing — a concurrent unsubscribe()
208            // call may have removed this JID while we were iterating.
209            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    /// Access presence operations.
221    #[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    // Mock HTTP client for testing
242    #[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    /// Verifies WhatsApp Web behavior: presence deferred until pushname available.
268    #[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    /// Simulates pushname arriving from app state sync (setting_pushName mutation).
303    #[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        // Validation passes; error should be connection-related, not pushname
328        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    /// Matches WAWebPushNameSync.js: fresh pairing -> app state sync -> presence.
345    #[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        // Fresh device has empty pushname
362        let snapshot = client.persistence_manager().get_device_snapshot().await;
363        assert!(snapshot.push_name.is_empty());
364
365        // Presence deferred when pushname empty
366        let result = client.presence().set(PresenceStatus::Available).await;
367        assert!(matches!(result, Err(PresenceError::PushNameEmpty)));
368
369        // Pushname arrives via app state sync
370        client
371            .persistence_manager()
372            .process_command(DeviceCommand::SetPushName("WhatsApp User".to_string()))
373            .await;
374
375        // Now presence validation passes
376        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}