whatsapp_rust/features/
presence.rs1use 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#[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
26pub 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 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 pub async fn set_available(&self) -> Result<(), anyhow::Error> {
81 self.set(PresenceStatus::Available).await
82 }
83
84 pub async fn set_unavailable(&self) -> Result<(), anyhow::Error> {
86 self.set(PresenceStatus::Unavailable).await
87 }
88
89 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 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 #[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 #[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 #[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 #[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 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 #[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 let snapshot = client.persistence_manager().get_device_snapshot().await;
262 assert!(snapshot.push_name.is_empty());
263
264 let result: Result<(), anyhow::Error> =
266 client.presence().set(PresenceStatus::Available).await;
267 assert!(result.is_err());
268
269 client
271 .persistence_manager()
272 .process_command(DeviceCommand::SetPushName("WhatsApp User".to_string()))
273 .await;
274
275 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}