1use crate::client::Client;
7use crate::request::IqError;
8use log::debug;
9use std::collections::HashMap;
10use std::time::Duration;
11use thiserror::Error;
12use wacore::iq::contacts::{ProfilePictureSpec, ProfilePictureType};
13use wacore::iq::usync::{IsOnWhatsAppQueryType, IsOnWhatsAppSpec, IsOnWhatsAppUser, UserInfoSpec};
14use wacore_binary::{Jid, JidExt};
15
16pub use wacore::iq::contacts::ProfilePicture;
18pub use wacore::iq::usync::{IsOnWhatsAppResult, UserInfo, UsyncSubprotocolError};
19pub use wacore::stanza::business::VerifiedName;
20
21#[derive(Debug, Error)]
24#[non_exhaustive]
25pub enum ContactError {
26 #[error("{0}")]
28 Iq(#[from] IqError),
29 #[error("unsupported contact JID: {0}")]
31 InvalidJid(String),
32}
33
34fn ensure_is_on_whatsapp_jids_supported(jids: &[Jid]) -> Result<(), ContactError> {
35 if let Some(jid) = jids.iter().find(|jid| !jid.is_pn() && !jid.is_lid()) {
36 return Err(ContactError::InvalidJid(format!(
37 "is_on_whatsapp only supports PN and LID JIDs, got {jid}"
38 )));
39 }
40 Ok(())
41}
42
43fn forward_lid_pair(r: &IsOnWhatsAppResult) -> (&Jid, Option<&Jid>) {
51 (&r.jid, r.lid.as_ref())
52}
53
54fn reverse_lid_pair(r: &IsOnWhatsAppResult) -> Option<(&Jid, Option<&Jid>)> {
56 if r.jid.is_lid() {
57 r.pn_jid.as_ref().map(|pn| (pn, Some(&r.jid)))
58 } else {
59 None
60 }
61}
62
63fn user_info_lid_pair(entry: &UserInfo) -> (&Jid, Option<&Jid>) {
65 (&entry.jid, entry.lid.as_ref())
66}
67
68pub struct Contacts<'a> {
69 client: &'a Client,
70}
71
72impl<'a> Contacts<'a> {
73 pub(crate) fn new(client: &'a Client) -> Self {
74 Self { client }
75 }
76
77 async fn persist_lid_mappings<'b, I>(&self, entries: I)
82 where
83 I: IntoIterator<Item = (&'b Jid, Option<&'b Jid>)>,
84 {
85 for (jid, lid) in entries {
86 let Some(lid) = lid else {
87 continue;
88 };
89 if !jid.is_pn() || !lid.is_lid() {
90 continue;
91 }
92 if let Err(err) = self
93 .client
94 .add_lid_pn_mapping(
95 &lid.user,
96 &jid.user,
97 crate::lid_pn_cache::LearningSource::Usync,
98 )
99 .await
100 {
101 log::warn!(
102 "Failed to persist usync LID mapping {} -> {}: {err}",
103 jid,
104 lid
105 );
106 }
107 }
108 }
109
110 pub async fn is_on_whatsapp(
116 &self,
117 jids: &[Jid],
118 ) -> Result<Vec<IsOnWhatsAppResult>, ContactError> {
119 if jids.is_empty() {
120 return Ok(Vec::new());
121 }
122 ensure_is_on_whatsapp_jids_supported(jids)?;
123
124 debug!("is_on_whatsapp: checking {} JIDs", jids.len());
125
126 let mut pn_users = Vec::new();
127 let mut lid_users = Vec::new();
128 for jid in jids {
129 if jid.is_pn() {
130 let known_lid = self.client.lid_pn_cache.get_current_lid(&jid.user).await;
131 pn_users.push(IsOnWhatsAppUser {
132 jid: jid.to_non_ad(),
133 known_lid,
134 });
135 } else if jid.is_lid() {
136 lid_users.push(IsOnWhatsAppUser {
137 jid: jid.to_non_ad(),
138 known_lid: None,
139 });
140 } else {
141 #[cfg(debug_assertions)]
142 panic!("is_on_whatsapp: unexpected JID type {jid} after validation");
143
144 #[cfg(not(debug_assertions))]
145 continue;
146 }
147 }
148
149 let pn_fut = async {
152 if pn_users.is_empty() {
153 Ok(Vec::new())
154 } else {
155 let sid = self.client.generate_request_id();
156 self.client
157 .execute(IsOnWhatsAppSpec::new(
158 pn_users,
159 sid,
160 IsOnWhatsAppQueryType::Pn,
161 ))
162 .await
163 }
164 };
165 let lid_fut = async {
166 if lid_users.is_empty() {
167 Ok(Vec::new())
168 } else {
169 let sid = self.client.generate_request_id();
170 self.client
171 .execute(IsOnWhatsAppSpec::new(
172 lid_users,
173 sid,
174 IsOnWhatsAppQueryType::Lid,
175 ))
176 .await
177 }
178 };
179 let (mut results, lid_results) = futures::try_join!(pn_fut, lid_fut)?;
186 results.extend(lid_results);
187
188 self.persist_lid_mappings(results.iter().map(forward_lid_pair))
189 .await;
190 self.persist_lid_mappings(results.iter().filter_map(reverse_lid_pair))
191 .await;
192
193 Ok(results)
194 }
195
196 pub async fn get_profile_picture(
197 &self,
198 jid: &Jid,
199 preview: bool,
200 ) -> Result<Option<ProfilePicture>, ContactError> {
201 self.get_profile_picture_with_timeout(jid, preview, None)
202 .await
203 }
204
205 pub async fn get_profile_picture_with_timeout(
207 &self,
208 jid: &Jid,
209 preview: bool,
210 timeout: Option<Duration>,
211 ) -> Result<Option<ProfilePicture>, ContactError> {
212 if jid.is_psa() {
214 return Ok(None);
215 }
216
217 debug!(
218 "get_profile_picture: fetching {} picture for {}",
219 if preview { "preview" } else { "full" },
220 jid
221 );
222
223 let picture_type = if preview {
224 ProfilePictureType::Preview
225 } else {
226 ProfilePictureType::Full
227 };
228 let mut spec = ProfilePictureSpec::new(jid, picture_type);
229 if let Some(timeout) = timeout {
230 spec = spec.with_timeout(timeout);
231 }
232
233 let is_own_jid = {
235 let snap = self.client.persistence_manager.get_device_snapshot();
236 snap.pn.as_ref().is_some_and(|pn| pn.is_same_user_as(jid))
237 || snap
238 .lid
239 .as_ref()
240 .is_some_and(|lid| lid.is_same_user_as(jid))
241 };
242 if !jid.is_group()
243 && !jid.is_newsletter()
244 && !jid.is_bot()
245 && !jid.is_broadcast_list()
246 && !jid.is_status_broadcast()
247 && !is_own_jid
248 && self
249 .client
250 .ab_props
251 .is_enabled(wacore::iq::props::stale::PROFILE_PIC_PRIVACY_TOKEN)
252 .await
253 && let Some(token) = self.client.lookup_tc_token_for_jid(jid).await
254 {
255 spec = spec.with_tc_token(token);
256 }
257
258 match self.client.execute(spec).await {
259 Ok(pic) => Ok(pic),
260 Err(IqError::ServerError { code, .. }) if code == 404 || code == 401 => Ok(None),
263 Err(e) => Err(e.into()),
264 }
265 }
266
267 pub async fn get_user_info(
268 &self,
269 jids: &[Jid],
270 ) -> Result<HashMap<Jid, UserInfo>, ContactError> {
271 let queried: Vec<Jid> = jids.iter().filter(|jid| !jid.is_psa()).cloned().collect();
273 if queried.is_empty() {
274 return Ok(HashMap::new());
275 }
276
277 debug!("get_user_info: fetching info for {} JIDs", queried.len());
278
279 let request_id = self.client.generate_request_id();
280
281 let mut tc_tokens: HashMap<String, Vec<u8>> = HashMap::new();
284 if self
285 .client
286 .ab_props()
287 .is_enabled(wacore::iq::abprops::web::PROFILE_SCRAPING_PRIVACY_TOKEN_IN_ABOUT_USYNC)
288 .await
289 {
290 let lookups = futures::future::join_all(queried.iter().map(|jid| async move {
291 (
292 jid.to_non_ad().to_string(),
293 self.client.lookup_tc_token_for_jid(jid).await,
294 )
295 }))
296 .await;
297 tc_tokens = lookups
298 .into_iter()
299 .filter_map(|(key, token)| token.map(|t| (key, t)))
300 .collect();
301 }
302
303 let mut spec = UserInfoSpec::new(queried, request_id);
304 if !tc_tokens.is_empty() {
305 spec = spec.with_tc_tokens(tc_tokens);
306 }
307
308 let info = self.client.execute(spec).await?;
309 self.persist_lid_mappings(info.values().map(user_info_lid_pair))
310 .await;
311 Ok(info)
312 }
313}
314
315impl Client {
316 pub fn contacts(&self) -> Contacts<'_> {
317 Contacts::new(self)
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn test_profile_picture_struct() {
327 let pic = ProfilePicture {
328 id: "123456789".to_string(),
329 url: "https://example.com/pic.jpg".to_string(),
330 direct_path: Some("/v/pic.jpg".to_string()),
331 hash: None,
332 };
333
334 assert_eq!(pic.id, "123456789");
335 assert_eq!(pic.url, "https://example.com/pic.jpg");
336 assert!(pic.direct_path.is_some());
337 }
338
339 #[test]
340 fn is_on_whatsapp_accepts_pn_and_lid_jids() {
341 ensure_is_on_whatsapp_jids_supported(&[Jid::pn("15550000001"), Jid::lid("100000001")])
342 .unwrap();
343 }
344
345 #[test]
346 fn is_on_whatsapp_rejects_unsupported_jid_type() {
347 let err = ensure_is_on_whatsapp_jids_supported(&[Jid::group("15550000001-1234567890")])
348 .unwrap_err();
349
350 assert!(err.to_string().contains("only supports PN and LID JIDs"));
351 }
352
353 fn psa_jid() -> Jid {
354 Jid::pn("0")
355 }
356
357 #[tokio::test]
358 async fn profile_picture_for_system_jid_short_circuits_without_iq() {
359 let client = crate::test_utils::create_test_client().await;
360
361 let result = client
362 .contacts()
363 .get_profile_picture(&psa_jid(), false)
364 .await;
365
366 assert!(matches!(result, Ok(None)));
367 }
368
369 #[tokio::test]
370 async fn profile_picture_for_regular_jid_still_hits_the_wire() {
371 let client = crate::test_utils::create_test_client().await;
372
373 let err = client
376 .contacts()
377 .get_profile_picture(&Jid::pn("12025550111"), false)
378 .await
379 .unwrap_err();
380
381 assert!(matches!(err, ContactError::Iq(IqError::NotConnected)));
382 }
383
384 #[tokio::test]
385 async fn user_info_with_only_system_jid_returns_empty_without_iq() {
386 let client = crate::test_utils::create_test_client().await;
387
388 let info = client.contacts().get_user_info(&[psa_jid()]).await.unwrap();
389
390 assert!(info.is_empty());
391 }
392
393 #[tokio::test]
394 async fn user_info_still_queries_remaining_jids_after_filtering() {
395 let client = crate::test_utils::create_test_client().await;
396
397 let err = client
398 .contacts()
399 .get_user_info(&[psa_jid(), Jid::pn("12025550111")])
400 .await
401 .unwrap_err();
402
403 assert!(matches!(err, ContactError::Iq(IqError::NotConnected)));
404 }
405}