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}