Skip to main content

whatsapp_rust/features/
presence.rs

1use 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    /// Connection/transport failure sending the `<presence>` stanza.
16    #[error("{0}")]
17    Client(#[from] ClientError),
18    /// Catch-all for internal failures with no dedicated variant.
19    #[error("{0}")]
20    Other(#[from] anyhow::Error),
21}
22
23/// Presence status for online/offline state.
24#[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
42/// Feature handle for presence operations.
43pub 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        // Include tctoken if available (no t attribute, matching WhatsApp Web)
58        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    /// Set the presence status.
73    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        // Track receipt activity like whatsmeow: available -> active receipts,
87        // unavailable -> back to inactive (a forced value is preserved).
88        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    /// Set presence to available (online).
118    pub async fn set_available(&self) -> Result<(), PresenceError> {
119        self.set(PresenceStatus::Available).await
120    }
121
122    /// Set presence to unavailable (offline).
123    pub async fn set_unavailable(&self) -> Result<(), PresenceError> {
124        self.set(PresenceStatus::Unavailable).await
125    }
126
127    /// Subscribe to a contact's presence updates.
128    ///
129    /// Sends a `<presence type="subscribe">` stanza to the target JID.
130    /// If a valid tctoken exists for the contact, it is included as a child node.
131    ///
132    /// ## Wire Format
133    /// ```xml
134    /// <presence type="subscribe" to="user@s.whatsapp.net">
135    ///   <tctoken><!-- raw token bytes --></tctoken>
136    /// </presence>
137    /// ```
138    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    /// Re-subscribe presence if the JID has an active subscription.
148    /// Does not modify the tracking set.
149    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    /// Unsubscribe from a contact's presence updates.
166    ///
167    /// Sends a `<presence type="unsubscribe">` stanza to the target JID.
168    ///
169    /// ## Wire Format
170    /// ```xml
171    /// <presence type="unsubscribe" to="user@s.whatsapp.net"/>
172    /// ```
173    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    /// Access presence operations.
233    #[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    // Mock HTTP client for testing
254    #[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    /// Verifies WhatsApp Web behavior: presence deferred until pushname available.
280    #[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    /// Simulates pushname arriving from app state sync (setting_pushName mutation).
315    #[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        // Validation passes; error should be connection-related, not pushname
340        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    /// Matches WAWebPushNameSync.js: fresh pairing -> app state sync -> presence.
357    #[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        // Fresh device has empty pushname
374        let snapshot = client.persistence_manager().get_device_snapshot();
375        assert!(snapshot.push_name.is_empty());
376
377        // Presence deferred when pushname empty
378        let result = client.presence().set(PresenceStatus::Available).await;
379        assert!(matches!(result, Err(PresenceError::PushNameEmpty)));
380
381        // Pushname arrives via app state sync
382        client
383            .persistence_manager()
384            .process_command(DeviceCommand::SetPushName("WhatsApp User".to_string()))
385            .await;
386
387        // Now presence validation passes
388        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}