Skip to main content

whatsapp_rust/features/
contacts.rs

1//! Contact information feature.
2//!
3//! Profile picture types are defined in `wacore::iq::contacts`.
4//! Usync types are defined in `wacore::iq::usync`.
5
6use 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
16// Re-export types from wacore
17pub use wacore::iq::contacts::ProfilePicture;
18pub use wacore::iq::usync::{IsOnWhatsAppResult, UserInfo, UsyncSubprotocolError};
19pub use wacore::stanza::business::VerifiedName;
20
21/// Error returned by contact-information operations (existence checks,
22/// profile pictures, user info).
23#[derive(Debug, Error)]
24#[non_exhaustive]
25pub enum ContactError {
26    /// The usync/profile IQ to the server failed.
27    #[error("{0}")]
28    Iq(#[from] IqError),
29    /// An input JID is not supported for this query (only PN and LID are).
30    #[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
43/// Mapping extractors as fn items, NOT closures. A closure returning
44/// references tied to its argument is inferred at a concrete lifetime, and
45/// because its type is embedded in the public methods' future types, callers
46/// that box those futures (`#[async_trait]`, `Box<dyn Future + Send>`) hit
47/// "implementation of `FnOnce` is not general enough" (issue #825). Fn items
48/// implement `Fn` for every lifetime by construction.
49/// PN-primary result -> (PN, LID mapping).
50fn forward_lid_pair(r: &IsOnWhatsAppResult) -> (&Jid, Option<&Jid>) {
51    (&r.jid, r.lid.as_ref())
52}
53
54/// LID-primary result inverted to (PN, LID); None when not LID-primary.
55fn 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
63/// UserInfo entry -> (queried JID, LID mapping).
64fn 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    /// Callers must pass `fn` items (e.g. [`forward_lid_pair`]), NOT
78    /// closures: a closure returning borrowed pairs embeds a non-HRTB type in
79    /// the public caller's future and breaks `#[async_trait]` consumers
80    /// (issue #825, guarded by tests/async_trait_boxed_future_compat.rs).
81    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    /// Check if JIDs are registered on WhatsApp.
111    ///
112    /// Accepts both PN JIDs (`Jid::pn("1234567890")`) and LID JIDs (`Jid::lid("100000001")`).
113    /// PN and LID queries use different protocols (matching WA Web ExistsJob), so mixed
114    /// inputs are split into separate requests.
115    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        // PN and LID existence use different protocols (two independent IQs), so
150        // when a mixed input produces both, run them concurrently.
151        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        // try_join! fails fast: it returns the instant either query errors and
180        // drops the sibling in-flight future. That's now safe — `send_and_wait_iq`
181        // registers a `ResponseWaiterGuard` that removes the waiter on drop, so a
182        // cancelled sibling can't leak its `response_waiters` entry (which would
183        // otherwise suppress keepalives). The old sequential code also failed on
184        // the first error, so fail-fast matches the original latency profile.
185        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    /// Fetch a profile picture with an optional request timeout override.
206    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        // The system JID never answers this IQ; skip it to save the full timeout.
213        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        // Skip own JID: server never responds when tctoken is sent for self
234        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            // 404/401 = no profile picture (or not authorized to see it).
261            // WhatsApp server returns type="error" IQ for these cases.
262            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        // The system JID is not usync-eligible and would never answer.
272        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        // Attach per-user tctokens so the status/about of privacy-restricted
282        // contacts is returned, matching WA Web's USyncStatusProtocol.getUserElement.
283        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        // Disconnected client: reaching the send path is what produces this error,
374        // proving the short-circuit is scoped to the system JID.
375        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}