Skip to main content

whatsapp_rust/features/
presence.rs

1use crate::client::Client;
2use log::{debug, warn};
3use wacore::StringEnum;
4use wacore::iq::tctoken::build_tc_token_node;
5use wacore_binary::builder::NodeBuilder;
6use wacore_binary::jid::Jid;
7
8/// Presence status for online/offline state.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)]
10pub enum PresenceStatus {
11    #[str = "available"]
12    Available,
13    #[str = "unavailable"]
14    Unavailable,
15}
16
17impl From<crate::types::presence::Presence> for PresenceStatus {
18    fn from(p: crate::types::presence::Presence) -> Self {
19        match p {
20            crate::types::presence::Presence::Available => PresenceStatus::Available,
21            crate::types::presence::Presence::Unavailable => PresenceStatus::Unavailable,
22        }
23    }
24}
25
26/// Feature handle for presence operations.
27pub struct Presence<'a> {
28    client: &'a Client,
29}
30
31impl<'a> Presence<'a> {
32    pub(crate) fn new(client: &'a Client) -> Self {
33        Self { client }
34    }
35
36    /// Set the presence status.
37    pub async fn set(&self, status: PresenceStatus) -> Result<(), anyhow::Error> {
38        let device_snapshot = self
39            .client
40            .persistence_manager()
41            .get_device_snapshot()
42            .await;
43
44        debug!(
45            "send_presence called with push_name: '{}'",
46            device_snapshot.push_name
47        );
48
49        if device_snapshot.push_name.is_empty() {
50            warn!("Cannot send presence: push_name is empty!");
51            return Err(anyhow::anyhow!(
52                "Cannot send presence without a push name set"
53            ));
54        }
55
56        if status == PresenceStatus::Available {
57            self.client.send_unified_session().await;
58        }
59
60        let presence_type = status.as_str();
61
62        let node = NodeBuilder::new("presence")
63            .attr("type", presence_type)
64            .attr("name", &device_snapshot.push_name)
65            .build();
66
67        debug!(
68            "Sending presence stanza: <presence type=\"{}\" name=\"{}\"/>",
69            presence_type,
70            node.attrs
71                .get("name")
72                .and_then(|s| s.as_str())
73                .unwrap_or("")
74        );
75
76        self.client.send_node(node).await.map_err(|e| e.into())
77    }
78
79    /// Set presence to available (online).
80    pub async fn set_available(&self) -> Result<(), anyhow::Error> {
81        self.set(PresenceStatus::Available).await
82    }
83
84    /// Set presence to unavailable (offline).
85    pub async fn set_unavailable(&self) -> Result<(), anyhow::Error> {
86        self.set(PresenceStatus::Unavailable).await
87    }
88
89    /// Subscribe to a contact's presence updates.
90    ///
91    /// Sends a `<presence type="subscribe">` stanza to the target JID.
92    /// If a valid tctoken exists for the contact, it is included as a child node.
93    ///
94    /// ## Wire Format
95    /// ```xml
96    /// <presence type="subscribe" to="user@s.whatsapp.net">
97    ///   <tctoken><!-- raw token bytes --></tctoken>
98    /// </presence>
99    /// ```
100    pub async fn subscribe(&self, jid: &Jid) -> Result<(), anyhow::Error> {
101        debug!("presence subscribe: subscribing to {}", jid);
102
103        let mut builder = NodeBuilder::new("presence")
104            .attr("type", "subscribe")
105            .attr("to", jid.to_string());
106
107        // Include tctoken if available (no t attribute, matching WhatsApp Web)
108        if let Some(token) = self.client.lookup_tc_token_for_jid(jid).await {
109            builder = builder.children([build_tc_token_node(&token)]);
110        }
111
112        let node = builder.build();
113        self.client.send_node(node).await.map_err(|e| e.into())
114    }
115}
116
117impl Client {
118    /// Access presence operations.
119    #[allow(clippy::wrong_self_convention)]
120    pub fn presence(&self) -> Presence<'_> {
121        Presence::new(self)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::bot::Bot;
129    use crate::http::{HttpClient, HttpRequest, HttpResponse};
130    use crate::store::SqliteStore;
131    use crate::store::commands::DeviceCommand;
132    use anyhow::Result;
133    use std::sync::Arc;
134    use wacore::store::traits::Backend;
135    use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory;
136
137    // Mock HTTP client for testing
138    #[derive(Debug, Clone)]
139    struct MockHttpClient;
140
141    #[async_trait::async_trait]
142    impl HttpClient for MockHttpClient {
143        async fn execute(&self, _request: HttpRequest) -> Result<HttpResponse> {
144            Ok(HttpResponse {
145                status_code: 200,
146                body: br#"self.__swData=JSON.parse(/*BTDS*/"{\"dynamic_data\":{\"SiteData\":{\"server_revision\":1026131876,\"client_revision\":1026131876}}}");"#.to_vec(),
147            })
148        }
149    }
150
151    async fn create_test_backend() -> Arc<dyn Backend> {
152        let temp_db = format!(
153            "file:memdb_presence_{}?mode=memory&cache=shared",
154            uuid::Uuid::new_v4()
155        );
156        Arc::new(
157            SqliteStore::new(&temp_db)
158                .await
159                .expect("Failed to create test SqliteStore"),
160        ) as Arc<dyn Backend>
161    }
162
163    /// Verifies WhatsApp Web behavior: presence deferred until pushname available.
164    #[tokio::test]
165    async fn test_presence_rejected_when_pushname_empty() {
166        let backend = create_test_backend().await;
167        let transport = TokioWebSocketTransportFactory::new();
168
169        let bot = Bot::builder()
170            .with_backend(backend)
171            .with_transport_factory(transport)
172            .with_http_client(MockHttpClient)
173            .build()
174            .await
175            .expect("Failed to build bot");
176
177        let client = bot.client();
178
179        let snapshot = client.persistence_manager().get_device_snapshot().await;
180        assert!(
181            snapshot.push_name.is_empty(),
182            "Pushname should be empty on fresh device"
183        );
184
185        let result: Result<(), anyhow::Error> =
186            client.presence().set(PresenceStatus::Available).await;
187
188        assert!(
189            result.is_err(),
190            "Presence should fail when pushname is empty"
191        );
192        assert!(
193            result
194                .unwrap_err()
195                .to_string()
196                .contains("Cannot send presence without a push name set"),
197            "Error should indicate missing pushname"
198        );
199    }
200
201    /// Simulates pushname arriving from app state sync (setting_pushName mutation).
202    #[tokio::test]
203    async fn test_presence_succeeds_after_pushname_set() {
204        let backend = create_test_backend().await;
205        let transport = TokioWebSocketTransportFactory::new();
206
207        let bot = Bot::builder()
208            .with_backend(backend)
209            .with_transport_factory(transport)
210            .with_http_client(MockHttpClient)
211            .build()
212            .await
213            .expect("Failed to build bot");
214
215        let client = bot.client();
216
217        client
218            .persistence_manager()
219            .process_command(DeviceCommand::SetPushName("Test User".to_string()))
220            .await;
221
222        let snapshot = client.persistence_manager().get_device_snapshot().await;
223        assert_eq!(snapshot.push_name, "Test User");
224
225        // Validation passes; error should be connection-related, not pushname
226        let result: Result<(), anyhow::Error> =
227            client.presence().set(PresenceStatus::Available).await;
228
229        if let Err(e) = result {
230            let err_msg = e.to_string();
231            assert!(
232                !err_msg.contains("push name"),
233                "Should not fail due to pushname: {}",
234                err_msg
235            );
236            assert!(
237                err_msg.contains("not connected") || err_msg.contains("NotConnected"),
238                "Expected connection error, got: {}",
239                err_msg
240            );
241        }
242    }
243
244    /// Matches WAWebPushNameSync.js: fresh pairing -> app state sync -> presence.
245    #[tokio::test]
246    async fn test_pushname_presence_flow_matches_whatsapp_web() {
247        let backend = create_test_backend().await;
248        let transport = TokioWebSocketTransportFactory::new();
249
250        let bot = Bot::builder()
251            .with_backend(backend)
252            .with_transport_factory(transport)
253            .with_http_client(MockHttpClient)
254            .build()
255            .await
256            .expect("Failed to build bot");
257
258        let client = bot.client();
259
260        // Fresh device has empty pushname
261        let snapshot = client.persistence_manager().get_device_snapshot().await;
262        assert!(snapshot.push_name.is_empty());
263
264        // Presence deferred when pushname empty
265        let result: Result<(), anyhow::Error> =
266            client.presence().set(PresenceStatus::Available).await;
267        assert!(result.is_err());
268
269        // Pushname arrives via app state sync
270        client
271            .persistence_manager()
272            .process_command(DeviceCommand::SetPushName("WhatsApp User".to_string()))
273            .await;
274
275        // Now presence validation passes
276        let result: Result<(), anyhow::Error> =
277            client.presence().set(PresenceStatus::Available).await;
278
279        if let Err(e) = result {
280            assert!(
281                !e.to_string().contains("push name"),
282                "Error should be connection-related: {}",
283                e
284            );
285        }
286    }
287}