whatsapp_rust/features/
contacts.rs

1use crate::client::Client;
2use crate::jid_utils::server_jid;
3use crate::request::InfoQuery;
4use anyhow::{Result, anyhow};
5use log::debug;
6use std::collections::HashMap;
7use wacore_binary::builder::NodeBuilder;
8use wacore_binary::jid::Jid;
9use wacore_binary::node::{Node, NodeContent};
10
11#[derive(Debug, Clone)]
12pub struct IsOnWhatsAppResult {
13    pub jid: Jid,
14    pub is_registered: bool,
15}
16
17#[derive(Debug, Clone)]
18pub struct ContactInfo {
19    pub jid: Jid,
20
21    pub lid: Option<Jid>,
22
23    pub is_registered: bool,
24
25    pub is_business: bool,
26
27    pub status: Option<String>,
28
29    pub picture_id: Option<u64>,
30}
31
32#[derive(Debug, Clone)]
33pub struct ProfilePicture {
34    pub id: String,
35
36    pub url: String,
37
38    pub direct_path: Option<String>,
39}
40
41#[derive(Debug, Clone)]
42pub struct UserInfo {
43    pub jid: Jid,
44
45    pub lid: Option<Jid>,
46
47    pub status: Option<String>,
48
49    pub picture_id: Option<String>,
50
51    pub is_business: bool,
52}
53
54pub struct Contacts<'a> {
55    client: &'a Client,
56}
57
58impl<'a> Contacts<'a> {
59    pub(crate) fn new(client: &'a Client) -> Self {
60        Self { client }
61    }
62
63    pub async fn is_on_whatsapp(&self, phones: &[&str]) -> Result<Vec<IsOnWhatsAppResult>> {
64        if phones.is_empty() {
65            return Ok(Vec::new());
66        }
67
68        let request_id = self.client.generate_request_id();
69        debug!("is_on_whatsapp: checking {} numbers", phones.len());
70
71        let query_node = NodeBuilder::new("query")
72            .children(vec![NodeBuilder::new("contact").build()])
73            .build();
74
75        let user_nodes: Vec<Node> = phones
76            .iter()
77            .map(|phone| {
78                let phone_content = if phone.starts_with('+') {
79                    phone.to_string()
80                } else {
81                    format!("+{}", phone)
82                };
83                NodeBuilder::new("user")
84                    .children(vec![
85                        NodeBuilder::new("contact")
86                            .string_content(phone_content)
87                            .build(),
88                    ])
89                    .build()
90            })
91            .collect();
92
93        let list_node = NodeBuilder::new("list").children(user_nodes).build();
94
95        let usync_node = NodeBuilder::new("usync")
96            .attr("sid", request_id.as_str())
97            .attr("mode", "query")
98            .attr("last", "true")
99            .attr("index", "0")
100            .attr("context", "interactive")
101            .children(vec![query_node, list_node])
102            .build();
103
104        let iq = InfoQuery::get(
105            "usync",
106            server_jid(),
107            Some(NodeContent::Nodes(vec![usync_node])),
108        );
109
110        let response_node = self.client.send_iq(iq).await?;
111        Self::parse_is_on_whatsapp_response(&response_node)
112    }
113
114    pub async fn get_info(&self, phones: &[&str]) -> Result<Vec<ContactInfo>> {
115        if phones.is_empty() {
116            return Ok(Vec::new());
117        }
118
119        let request_id = self.client.generate_request_id();
120        debug!("get_info: fetching info for {} numbers", phones.len());
121
122        let query_node = NodeBuilder::new("query")
123            .children(vec![
124                NodeBuilder::new("contact").build(),
125                NodeBuilder::new("lid").build(),
126                NodeBuilder::new("status").build(),
127                NodeBuilder::new("picture").build(),
128                NodeBuilder::new("business").build(),
129            ])
130            .build();
131
132        let user_nodes: Vec<Node> = phones
133            .iter()
134            .map(|phone| {
135                let phone_content = if phone.starts_with('+') {
136                    phone.to_string()
137                } else {
138                    format!("+{}", phone)
139                };
140                NodeBuilder::new("user")
141                    .children(vec![
142                        NodeBuilder::new("contact")
143                            .string_content(phone_content)
144                            .build(),
145                    ])
146                    .build()
147            })
148            .collect();
149
150        let list_node = NodeBuilder::new("list").children(user_nodes).build();
151
152        let usync_node = NodeBuilder::new("usync")
153            .attr("sid", request_id.as_str())
154            .attr("mode", "query")
155            .attr("last", "true")
156            .attr("index", "0")
157            .attr("context", "interactive")
158            .children(vec![query_node, list_node])
159            .build();
160
161        let iq = InfoQuery::get(
162            "usync",
163            server_jid(),
164            Some(NodeContent::Nodes(vec![usync_node])),
165        );
166
167        let response_node = self.client.send_iq(iq).await?;
168        Self::parse_contact_info_response(&response_node)
169    }
170
171    pub async fn get_profile_picture(
172        &self,
173        jid: &Jid,
174        preview: bool,
175    ) -> Result<Option<ProfilePicture>> {
176        debug!(
177            "get_profile_picture: fetching {} picture for {}",
178            if preview { "preview" } else { "full" },
179            jid
180        );
181
182        let picture_type = if preview { "preview" } else { "image" };
183        let picture_node = NodeBuilder::new("picture")
184            .attr("type", picture_type)
185            .attr("query", "url")
186            .build();
187
188        let iq = InfoQuery::get(
189            "w:profile:picture",
190            server_jid(),
191            Some(NodeContent::Nodes(vec![picture_node])),
192        )
193        .with_target(jid.clone());
194
195        let response_node = self.client.send_iq(iq).await?;
196        Self::parse_profile_picture_response(&response_node)
197    }
198
199    pub async fn get_user_info(&self, jids: &[Jid]) -> Result<HashMap<Jid, UserInfo>> {
200        if jids.is_empty() {
201            return Ok(HashMap::new());
202        }
203
204        let request_id = self.client.generate_request_id();
205        debug!("get_user_info: fetching info for {} JIDs", jids.len());
206
207        let query_node = NodeBuilder::new("query")
208            .children(vec![
209                NodeBuilder::new("business")
210                    .children(vec![NodeBuilder::new("verified_name").build()])
211                    .build(),
212                NodeBuilder::new("status").build(),
213                NodeBuilder::new("picture").build(),
214                NodeBuilder::new("devices").attr("version", "2").build(),
215                NodeBuilder::new("lid").build(),
216            ])
217            .build();
218
219        let user_nodes: Vec<Node> = jids
220            .iter()
221            .map(|jid| {
222                NodeBuilder::new("user")
223                    .attr("jid", jid.to_non_ad().to_string())
224                    .build()
225            })
226            .collect();
227
228        let list_node = NodeBuilder::new("list").children(user_nodes).build();
229
230        let usync_node = NodeBuilder::new("usync")
231            .attr("sid", request_id.as_str())
232            .attr("mode", "full")
233            .attr("last", "true")
234            .attr("index", "0")
235            .attr("context", "background")
236            .children(vec![query_node, list_node])
237            .build();
238
239        let iq = InfoQuery::get(
240            "usync",
241            server_jid(),
242            Some(NodeContent::Nodes(vec![usync_node])),
243        );
244
245        let response_node = self.client.send_iq(iq).await?;
246        Self::parse_user_info_response(&response_node)
247    }
248
249    fn parse_is_on_whatsapp_response(node: &Node) -> Result<Vec<IsOnWhatsAppResult>> {
250        let usync = node
251            .get_optional_child("usync")
252            .ok_or_else(|| anyhow!("Response missing <usync> node"))?;
253
254        let list = usync
255            .get_optional_child("list")
256            .ok_or_else(|| anyhow!("Response missing <list> node"))?;
257
258        let mut results = Vec::new();
259
260        for user_node in list.get_children_by_tag("user") {
261            let jid_str = user_node.attrs().optional_string("jid");
262
263            if let Some(jid_str) = jid_str
264                && let Ok(jid) = jid_str.parse::<Jid>()
265            {
266                let contact_node = user_node.get_optional_child("contact");
267                let is_registered = contact_node
268                    .map(|c| c.attrs().optional_string("type") == Some("in"))
269                    .unwrap_or(false);
270
271                results.push(IsOnWhatsAppResult { jid, is_registered });
272            }
273        }
274
275        Ok(results)
276    }
277
278    fn parse_contact_info_response(node: &Node) -> Result<Vec<ContactInfo>> {
279        let usync = node
280            .get_optional_child("usync")
281            .ok_or_else(|| anyhow!("Response missing <usync> node"))?;
282
283        let list = usync
284            .get_optional_child("list")
285            .ok_or_else(|| anyhow!("Response missing <list> node"))?;
286
287        let mut results = Vec::new();
288
289        for user_node in list.get_children_by_tag("user") {
290            let jid_str = user_node.attrs().optional_string("jid");
291
292            if let Some(jid_str) = jid_str
293                && let Ok(jid) = jid_str.parse::<Jid>()
294            {
295                let contact_node = user_node.get_optional_child("contact");
296                let is_registered = contact_node
297                    .map(|c| c.attrs().optional_string("type") == Some("in"))
298                    .unwrap_or(false);
299
300                let lid = user_node.get_optional_child("lid").and_then(|lid_node| {
301                    lid_node
302                        .attrs()
303                        .optional_string("val")
304                        .and_then(|val| val.parse::<Jid>().ok())
305                });
306
307                let status = user_node
308                    .get_optional_child("status")
309                    .and_then(|status_node| {
310                        if status_node.get_optional_child("error").is_some() {
311                            return None;
312                        }
313                        match &status_node.content {
314                            Some(NodeContent::String(s)) if !s.is_empty() => Some(s.clone()),
315                            _ => None,
316                        }
317                    });
318
319                let picture_id = user_node
320                    .get_optional_child("picture")
321                    .and_then(|pic_node| {
322                        if pic_node.get_optional_child("error").is_some() {
323                            return None;
324                        }
325                        pic_node.attrs().optional_u64("id")
326                    });
327
328                let is_business = user_node.get_optional_child("business").is_some();
329
330                results.push(ContactInfo {
331                    jid,
332                    lid,
333                    is_registered,
334                    is_business,
335                    status,
336                    picture_id,
337                });
338            }
339        }
340
341        Ok(results)
342    }
343
344    fn parse_profile_picture_response(node: &Node) -> Result<Option<ProfilePicture>> {
345        let picture_node = match node.get_optional_child("picture") {
346            Some(p) => p,
347            None => return Ok(None),
348        };
349
350        if let Some(error_node) = picture_node.get_optional_child("error") {
351            let code = error_node.attrs().optional_string("code").unwrap_or("0");
352            if code == "404" || code == "401" {
353                return Ok(None);
354            }
355            let text = error_node
356                .attrs()
357                .optional_string("text")
358                .unwrap_or("unknown error");
359            return Err(anyhow!("Profile picture error {}: {}", code, text));
360        }
361
362        let id = picture_node
363            .attrs()
364            .optional_string("id")
365            .map(|s| s.to_string())
366            .unwrap_or_default();
367
368        let url = picture_node
369            .attrs()
370            .optional_string("url")
371            .map(|s| s.to_string())
372            .ok_or_else(|| anyhow!("Picture response missing 'url' attribute"))?;
373
374        let direct_path = picture_node
375            .attrs()
376            .optional_string("direct_path")
377            .map(|s| s.to_string());
378
379        Ok(Some(ProfilePicture {
380            id,
381            url,
382            direct_path,
383        }))
384    }
385
386    fn parse_user_info_response(node: &Node) -> Result<HashMap<Jid, UserInfo>> {
387        let usync = node
388            .get_optional_child("usync")
389            .ok_or_else(|| anyhow!("Response missing <usync> node"))?;
390
391        let list = usync
392            .get_optional_child("list")
393            .ok_or_else(|| anyhow!("Response missing <list> node"))?;
394
395        let mut results = HashMap::new();
396
397        for user_node in list.get_children_by_tag("user") {
398            let jid_str = user_node.attrs().optional_string("jid");
399
400            if let Some(jid_str) = jid_str
401                && let Ok(jid) = jid_str.parse::<Jid>()
402            {
403                let lid = user_node.get_optional_child("lid").and_then(|lid_node| {
404                    lid_node
405                        .attrs()
406                        .optional_string("val")
407                        .and_then(|val| val.parse::<Jid>().ok())
408                });
409
410                let status = user_node
411                    .get_optional_child("status")
412                    .and_then(|status_node| {
413                        if status_node.get_optional_child("error").is_some() {
414                            return None;
415                        }
416                        match &status_node.content {
417                            Some(NodeContent::String(s)) if !s.is_empty() => Some(s.clone()),
418                            _ => None,
419                        }
420                    });
421
422                let picture_id = user_node
423                    .get_optional_child("picture")
424                    .and_then(|pic_node| {
425                        if pic_node.get_optional_child("error").is_some() {
426                            return None;
427                        }
428                        pic_node
429                            .attrs()
430                            .optional_string("id")
431                            .map(|s| s.to_string())
432                    });
433
434                let is_business = user_node.get_optional_child("business").is_some();
435
436                results.insert(
437                    jid.clone(),
438                    UserInfo {
439                        jid,
440                        lid,
441                        status,
442                        picture_id,
443                        is_business,
444                    },
445                );
446            }
447        }
448
449        Ok(results)
450    }
451}
452
453impl Client {
454    pub fn contacts(&self) -> Contacts<'_> {
455        Contacts::new(self)
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn test_contact_info_struct() {
465        let jid: Jid = "1234567890@s.whatsapp.net"
466            .parse()
467            .expect("test JID should be valid");
468        let lid: Jid = "12345678@lid".parse().expect("test JID should be valid");
469
470        let info = ContactInfo {
471            jid: jid.clone(),
472            lid: Some(lid.clone()),
473            is_registered: true,
474            is_business: false,
475            status: Some("Hey there!".to_string()),
476            picture_id: Some(123456789),
477        };
478
479        assert!(info.is_registered);
480        assert!(!info.is_business);
481        assert_eq!(info.status, Some("Hey there!".to_string()));
482        assert_eq!(info.picture_id, Some(123456789));
483        assert!(info.lid.is_some());
484    }
485
486    #[test]
487    fn test_profile_picture_struct() {
488        let pic = ProfilePicture {
489            id: "123456789".to_string(),
490            url: "https://example.com/pic.jpg".to_string(),
491            direct_path: Some("/v/pic.jpg".to_string()),
492        };
493
494        assert_eq!(pic.id, "123456789");
495        assert_eq!(pic.url, "https://example.com/pic.jpg");
496        assert!(pic.direct_path.is_some());
497    }
498
499    #[test]
500    fn test_is_on_whatsapp_result_struct() {
501        let jid: Jid = "1234567890@s.whatsapp.net"
502            .parse()
503            .expect("test JID should be valid");
504        let result = IsOnWhatsAppResult {
505            jid,
506            is_registered: true,
507        };
508
509        assert!(result.is_registered);
510    }
511}