diff --git a/src/features/contacts.rs b/src/features/contacts.rs index de145637d..db736ad8d 100644 --- a/src/features/contacts.rs +++ b/src/features/contacts.rs @@ -9,12 +9,12 @@ use anyhow::Result; use log::debug; use std::collections::HashMap; use wacore::iq::contacts::{ProfilePictureSpec, ProfilePictureType}; -use wacore::iq::usync::{ContactInfoSpec, IsOnWhatsAppSpec, UserInfoSpec}; +use wacore::iq::usync::{IsOnWhatsAppQueryType, IsOnWhatsAppSpec, IsOnWhatsAppUser, UserInfoSpec}; use wacore_binary::jid::{Jid, JidExt}; // Re-export types from wacore pub use wacore::iq::contacts::ProfilePicture; -pub use wacore::iq::usync::{ContactInfo, IsOnWhatsAppResult, UserInfo}; +pub use wacore::iq::usync::{IsOnWhatsAppResult, UserInfo}; pub struct Contacts<'a> { client: &'a Client, @@ -54,35 +54,63 @@ impl<'a> Contacts<'a> { } } - pub async fn is_on_whatsapp(&self, phones: &[&str]) -> Result> { - if phones.is_empty() { + /// Check if JIDs are registered on WhatsApp. + /// + /// Accepts both PN JIDs (`Jid::pn("1234567890")`) and LID JIDs (`Jid::lid("100000001")`). + /// PN and LID queries use different protocols (matching WA Web ExistsJob), so mixed + /// inputs are split into separate requests. + pub async fn is_on_whatsapp(&self, jids: &[Jid]) -> Result> { + if jids.is_empty() { return Ok(Vec::new()); } - debug!("is_on_whatsapp: checking {} numbers", phones.len()); - - let request_id = self.client.generate_request_id(); - let phone_strings: Vec = phones.iter().map(|s| s.to_string()).collect(); - let spec = IsOnWhatsAppSpec::new(phone_strings, request_id); + debug!("is_on_whatsapp: checking {} JIDs", jids.len()); + + let mut pn_users = Vec::new(); + let mut lid_users = Vec::new(); + for jid in jids { + if jid.is_pn() { + let known_lid = self.client.lid_pn_cache.get_current_lid(&jid.user).await; + pn_users.push(IsOnWhatsAppUser { + jid: jid.to_non_ad(), + known_lid, + }); + } else if jid.is_lid() { + lid_users.push(IsOnWhatsAppUser { + jid: jid.to_non_ad(), + known_lid: None, + }); + } else { + log::warn!("is_on_whatsapp: skipping unsupported JID type: {jid}"); + } + } - Ok(self.client.execute(spec).await?) - } + let mut results = Vec::new(); - pub async fn get_info(&self, phones: &[&str]) -> Result> { - if phones.is_empty() { - return Ok(Vec::new()); + if !pn_users.is_empty() { + let sid = self.client.generate_request_id(); + let spec = IsOnWhatsAppSpec::new(pn_users, sid, IsOnWhatsAppQueryType::Pn); + results.extend(self.client.execute(spec).await?); } - debug!("get_info: fetching info for {} numbers", phones.len()); - - let request_id = self.client.generate_request_id(); - let phone_strings: Vec = phones.iter().map(|s| s.to_string()).collect(); - let spec = ContactInfoSpec::new(phone_strings, request_id); + if !lid_users.is_empty() { + let sid = self.client.generate_request_id(); + let spec = IsOnWhatsAppSpec::new(lid_users, sid, IsOnWhatsAppQueryType::Lid); + results.extend(self.client.execute(spec).await?); + } - let info = self.client.execute(spec).await?; - self.persist_lid_mappings(info.iter().map(|entry| (&entry.jid, entry.lid.as_ref()))) + self.persist_lid_mappings(results.iter().map(|r| (&r.jid, r.lid.as_ref()))) .await; - Ok(info) + self.persist_lid_mappings(results.iter().filter_map(|r| { + if r.jid.is_lid() { + r.pn_jid.as_ref().map(|pn| (pn, Some(&r.jid))) + } else { + None + } + })) + .await; + + Ok(results) } pub async fn get_profile_picture( @@ -147,29 +175,6 @@ impl Client { mod tests { use super::*; - #[test] - fn test_contact_info_struct() { - let jid: Jid = "1234567890@s.whatsapp.net" - .parse() - .expect("test JID should be valid"); - let lid: Jid = "12345678@lid".parse().expect("test JID should be valid"); - - let info = ContactInfo { - jid: jid.clone(), - lid: Some(lid.clone()), - is_registered: true, - is_business: false, - status: Some("Hey there!".to_string()), - picture_id: Some(123456789), - }; - - assert!(info.is_registered); - assert!(!info.is_business); - assert_eq!(info.status, Some("Hey there!".to_string())); - assert_eq!(info.picture_id, Some(123456789)); - assert!(info.lid.is_some()); - } - #[test] fn test_profile_picture_struct() { let pic = ProfilePicture { @@ -183,17 +188,4 @@ mod tests { assert_eq!(pic.url, "https://example.com/pic.jpg"); assert!(pic.direct_path.is_some()); } - - #[test] - fn test_is_on_whatsapp_result_struct() { - let jid: Jid = "1234567890@s.whatsapp.net" - .parse() - .expect("test JID should be valid"); - let result = IsOnWhatsAppResult { - jid, - is_registered: true, - }; - - assert!(result.is_registered); - } } diff --git a/src/features/mod.rs b/src/features/mod.rs index cb5191632..e2c673122 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -24,7 +24,7 @@ pub use community::{ pub use chatstate::{ChatStateType, Chatstate}; -pub use contacts::{ContactInfo, Contacts, IsOnWhatsAppResult, ProfilePicture, UserInfo}; +pub use contacts::{Contacts, IsOnWhatsAppResult, ProfilePicture, UserInfo}; pub use groups::{ CreateGroupResult, GroupCreateOptions, GroupDescription, GroupMetadata, GroupParticipant, diff --git a/src/lib.rs b/src/lib.rs index ed3e3aa93..5cfa0a36b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,17 +57,16 @@ pub mod usync; pub mod features; pub use features::{ Blocking, BlocklistEntry, ChatActions, ChatStateType, Chatstate, Community, CommunitySubgroup, - ContactInfo, Contacts, CreateCommunityOptions, CreateCommunityResult, CreateGroupResult, - GroupCreateOptions, GroupDescription, GroupMetadata, GroupParticipant, GroupParticipantOptions, - GroupSubject, GroupType, Groups, IsOnWhatsAppResult, JoinGroupResult, LinkSubgroupsResult, - MediaRetryResult, MediaReupload, MediaReuploadRequest, MemberAddMode, MemberLinkMode, - MembershipApprovalMode, MembershipRequest, Mex, MexError, MexErrorExtensions, MexRequest, - MexResponse, Newsletter, NewsletterMessage, NewsletterMessageType, NewsletterMetadata, - NewsletterReactionCount, NewsletterRole, NewsletterState, NewsletterVerification, - ParticipantChangeResponse, Presence, PresenceError, PresenceStatus, Profile, ProfilePicture, - SetProfilePictureResponse, Status, StatusPrivacySetting, StatusSendOptions, - SyncActionMessageRange, TcToken, UnlinkSubgroupsResult, UserInfo, group_type, message_key, - message_range, + Contacts, CreateCommunityOptions, CreateCommunityResult, CreateGroupResult, GroupCreateOptions, + GroupDescription, GroupMetadata, GroupParticipant, GroupParticipantOptions, GroupSubject, + GroupType, Groups, IsOnWhatsAppResult, JoinGroupResult, LinkSubgroupsResult, MediaRetryResult, + MediaReupload, MediaReuploadRequest, MemberAddMode, MemberLinkMode, MembershipApprovalMode, + MembershipRequest, Mex, MexError, MexErrorExtensions, MexRequest, MexResponse, Newsletter, + NewsletterMessage, NewsletterMessageType, NewsletterMetadata, NewsletterReactionCount, + NewsletterRole, NewsletterState, NewsletterVerification, ParticipantChangeResponse, Presence, + PresenceError, PresenceStatus, Profile, ProfilePicture, SetProfilePictureResponse, Status, + StatusPrivacySetting, StatusSendOptions, SyncActionMessageRange, TcToken, + UnlinkSubgroupsResult, UserInfo, group_type, message_key, message_range, }; pub mod bot; diff --git a/wacore/src/iq/usync.rs b/wacore/src/iq/usync.rs index 968d066fe..27ce057a3 100644 --- a/wacore/src/iq/usync.rs +++ b/wacore/src/iq/usync.rs @@ -1,22 +1,19 @@ //! Usync IQ specifications. //! //! The usync protocol is used for user synchronization operations including: -//! - Checking if phone numbers are registered on WhatsApp -//! - Fetching contact information (LID, status, picture, business status) +//! - Checking if phone numbers or LIDs are registered on WhatsApp //! - Fetching user information by JID //! - Fetching device lists //! //! ## Wire Format //! ```xml -//! +//! //! //! //! //! //! -//! -//! -//! +//! //! //! //! @@ -26,15 +23,26 @@ //! //! //! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! //! //! //! //! -//! +//! //! -//! -//! Hello World -//! +//! //! //! //! @@ -79,32 +87,55 @@ pub enum UsyncContext { Message, } -/// Build user nodes with phone number contact children. -fn build_phone_user_nodes(phones: &[String]) -> Vec { - phones +#[derive(Debug, Clone)] +pub struct IsOnWhatsAppUser { + pub jid: Jid, + /// Helps server optimize the lookup (WA Web pre-populates this from its LID cache). + pub known_lid: Option, +} + +fn build_user_nodes(users: &[IsOnWhatsAppUser]) -> Vec { + users .iter() - .map(|phone| { - let phone_content = if phone.starts_with('+') { - phone.clone() + .map(|user| { + if user.jid.is_pn() { + let phone = if user.jid.user.starts_with('+') { + user.jid.user.clone() + } else { + format!("+{}", user.jid.user) + }; + let mut children = vec![NodeBuilder::new("contact").string_content(phone).build()]; + if let Some(lid) = &user.known_lid { + children.push( + NodeBuilder::new("lid") + .attr("jid", Jid::lid(lid).to_string()) + .build(), + ); + } + NodeBuilder::new("user").children(children).build() } else { - format!("+{}", phone) - }; - NodeBuilder::new("user") - .children(vec![ - NodeBuilder::new("contact") - .string_content(phone_content) - .build(), - ]) - .build() + NodeBuilder::new("user") + .attr("jid", user.jid.to_non_ad().to_string()) + .build() + } }) .collect() } -/// Common fields parsed from a usync user node. +/// Parse LID JID from a `` child node. +fn parse_lid_jid(user_node: &Node) -> Option { + user_node.get_optional_child("lid").and_then(|lid_node| { + lid_node + .attrs() + .optional_string("val") + .and_then(|val| val.parse::().ok()) + }) +} + +/// Common fields parsed from a usync `` node. struct ParsedUserFields { jid: Jid, lid: Option, - is_registered: bool, is_business: bool, status: Option, } @@ -117,17 +148,7 @@ fn parse_user_common_fields(user_node: &Node) -> Option { .parse::() .ok()?; - let contact_node = user_node.get_optional_child("contact"); - let is_registered = contact_node - .map(|c| c.attrs.get("type").is_some_and(|v| v == "in")) - .unwrap_or(false); - - let lid = user_node.get_optional_child("lid").and_then(|lid_node| { - lid_node - .attrs() - .optional_string("val") - .and_then(|val| val.parse::().ok()) - }); + let lid = parse_lid_jid(user_node); let status = user_node .get_optional_child("status") @@ -146,24 +167,11 @@ fn parse_user_common_fields(user_node: &Node) -> Option { Some(ParsedUserFields { jid, lid, - is_registered, is_business, status, }) } -/// Parse picture ID as u64 (used in ContactInfo). -fn parse_picture_id_u64(user_node: &Node) -> Option { - user_node - .get_optional_child("picture") - .and_then(|pic_node| { - if pic_node.get_optional_child("error").is_some() { - return None; - } - pic_node.attrs().optional_u64("id") - }) -} - /// Parse picture ID as String (used in UserInfo). fn parse_picture_id_string(user_node: &Node) -> Option { user_node @@ -179,30 +187,18 @@ fn parse_picture_id_string(user_node: &Node) -> Option { }) } -/// Result of checking if a phone number is on WhatsApp. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct IsOnWhatsAppResult { - pub jid: Jid, - pub is_registered: bool, -} - -/// Contact information from usync. -#[derive(Debug, Clone)] -pub struct ContactInfo { pub jid: Jid, pub lid: Option, + /// From `pn_jid` response attribute; present when server returns LID as primary JID. + pub pn_jid: Option, pub is_registered: bool, pub is_business: bool, - pub status: Option, - pub picture_id: Option, } /// User information from usync. -/// -/// Note: `picture_id` is `Option` here vs `Option` in `ContactInfo`. -/// The server returns picture IDs in different formats depending on the usync mode: -/// - Query mode (ContactInfo): numeric ID that fits in u64 -/// - Full mode (UserInfo): may include non-numeric prefixes, kept as String for safety #[derive(Debug, Clone)] pub struct UserInfo { pub jid: Jid, @@ -212,31 +208,83 @@ pub struct UserInfo { pub is_business: bool, } -/// Check if phone numbers are registered on WhatsApp. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IsOnWhatsAppQueryType { + /// PN query: `` + `` + ``. + Pn, + /// LID query: `` + `` (no contact). + Lid, +} + +/// Check if JIDs are registered on WhatsApp. +/// +/// Query protocols differ by type: +/// - PN: ``, ``, `` +/// - LID: ``, `` #[derive(Debug, Clone)] pub struct IsOnWhatsAppSpec { - pub phones: Vec, + pub users: Vec, pub sid: String, + pub query_type: IsOnWhatsAppQueryType, } impl IsOnWhatsAppSpec { - pub fn new(phones: Vec, sid: impl Into) -> Self { + pub fn new( + users: Vec, + sid: impl Into, + query_type: IsOnWhatsAppQueryType, + ) -> Self { Self { - phones, + users, sid: sid.into(), + query_type, } } } +fn build_business_query_node() -> Node { + NodeBuilder::new("business") + .children(vec![NodeBuilder::new("verified_name").build()]) + .build() +} + +/// Check `` for per-protocol errors. +fn check_usync_result_errors(usync: &Node) -> Result<(), anyhow::Error> { + let Some(result_node) = usync.get_optional_child("result") else { + return Ok(()); + }; + for tag in ["contact", "lid", "business"] { + if let Some(protocol_node) = result_node.get_optional_child(tag) + && let Some(error_node) = protocol_node.get_optional_child("error") + { + let code = error_node + .attrs() + .optional_string("code") + .unwrap_or_default(); + let text = error_node + .attrs() + .optional_string("text") + .unwrap_or_default(); + return Err(anyhow!("usync {tag} error {code}: {text}")); + } + } + Ok(()) +} + impl IqSpec for IsOnWhatsAppSpec { type Response = Vec; fn build_iq(&self) -> InfoQuery<'static> { - let query_node = NodeBuilder::new("query") - .children(vec![NodeBuilder::new("contact").build()]) - .build(); + let mut query_children = Vec::new(); + if self.query_type == IsOnWhatsAppQueryType::Pn { + query_children.push(NodeBuilder::new("contact").build()); + } + query_children.push(NodeBuilder::new("lid").build()); + query_children.push(build_business_query_node()); + + let query_node = NodeBuilder::new("query").children(query_children).build(); - let user_nodes = build_phone_user_nodes(&self.phones); + let user_nodes = build_user_nodes(&self.users); let list_node = NodeBuilder::new("list").children(user_nodes).build(); let usync_node = NodeBuilder::new("usync") @@ -260,6 +308,8 @@ impl IqSpec for IsOnWhatsAppSpec { .get_optional_child("usync") .ok_or_else(|| anyhow!("Response missing node"))?; + check_usync_result_errors(usync)?; + let list = usync .get_optional_child("list") .ok_or_else(|| anyhow!("Response missing node"))?; @@ -267,95 +317,39 @@ impl IqSpec for IsOnWhatsAppSpec { let mut results = Vec::new(); for user_node in list.get_children_by_tag("user") { - let jid_str = user_node.attrs().optional_string("jid"); - - if let Some(jid_str) = jid_str - && let Ok(jid) = jid_str.parse::() - { - let contact_node = user_node.get_optional_child("contact"); - let is_registered = contact_node - .map(|c| c.attrs.get("type").is_some_and(|v| v == "in")) - .unwrap_or(false); - - results.push(IsOnWhatsAppResult { jid, is_registered }); - } - } - - Ok(results) - } -} - -/// Get contact information for phone numbers. -#[derive(Debug, Clone)] -pub struct ContactInfoSpec { - pub phones: Vec, - pub sid: String, -} - -impl ContactInfoSpec { - pub fn new(phones: Vec, sid: impl Into) -> Self { - Self { - phones, - sid: sid.into(), - } - } -} - -impl IqSpec for ContactInfoSpec { - type Response = Vec; - - fn build_iq(&self) -> InfoQuery<'static> { - let query_node = NodeBuilder::new("query") - .children(vec![ - NodeBuilder::new("contact").build(), - NodeBuilder::new("lid").build(), - NodeBuilder::new("status").build(), - NodeBuilder::new("picture").build(), - NodeBuilder::new("business").build(), - ]) - .build(); - - let user_nodes = build_phone_user_nodes(&self.phones); - let list_node = NodeBuilder::new("list").children(user_nodes).build(); - - let usync_node = NodeBuilder::new("usync") - .attr("sid", self.sid.as_str()) - .attr("mode", UsyncMode::Query.as_str()) - .attr("last", "true") - .attr("index", "0") - .attr("context", UsyncContext::Interactive.as_str()) - .children(vec![query_node, list_node]) - .build(); + let Some(jid_str) = user_node.attrs().optional_string("jid") else { + continue; + }; + let Ok(jid) = jid_str.parse::() else { + continue; + }; - InfoQuery::get( - "usync", - Jid::new("", SERVER_JID), - Some(NodeContent::Nodes(vec![usync_node])), - ) - } + let pn_jid = user_node + .attrs() + .optional_string("pn_jid") + .and_then(|s| s.parse::().ok()); - fn parse_response(&self, response: &Node) -> Result { - let usync = response - .get_optional_child("usync") - .ok_or_else(|| anyhow!("Response missing node"))?; + let lid = parse_lid_jid(user_node); - let list = usync - .get_optional_child("list") - .ok_or_else(|| anyhow!("Response missing node"))?; + let contact_node = user_node.get_optional_child("contact"); + // LID queries omit contact protocol; presence in response implies registered + let is_registered = if jid.is_lid() && contact_node.is_none() { + true + } else { + contact_node + .map(|c| c.attrs.get("type").is_some_and(|v| v == "in")) + .unwrap_or(false) + }; - let mut results = Vec::new(); + let is_business = user_node.get_optional_child("business").is_some(); - for user_node in list.get_children_by_tag("user") { - if let Some(fields) = parse_user_common_fields(user_node) { - results.push(ContactInfo { - jid: fields.jid, - lid: fields.lid, - is_registered: fields.is_registered, - is_business: fields.is_business, - status: fields.status, - picture_id: parse_picture_id_u64(user_node), - }); - } + results.push(IsOnWhatsAppResult { + jid, + lid, + pn_jid, + is_registered, + is_business, + }); } Ok(results) @@ -641,9 +635,20 @@ mod tests { assert_eq!(UsyncContext::Message.as_str(), "message"); } + fn pn_user(phone: &str) -> IsOnWhatsAppUser { + IsOnWhatsAppUser { + jid: Jid::pn(phone), + known_lid: None, + } + } + #[test] fn test_is_on_whatsapp_spec_build_iq() { - let spec = IsOnWhatsAppSpec::new(vec!["1234567890".to_string()], "test-sid"); + let spec = IsOnWhatsAppSpec::new( + vec![pn_user("1234567890")], + "test-sid", + IsOnWhatsAppQueryType::Pn, + ); let iq = spec.build_iq(); assert_eq!(iq.namespace, "usync"); @@ -660,6 +665,66 @@ mod tests { .get("context") .is_some_and(|s| s == "interactive") ); + + let query = usync.get_optional_child("query").unwrap(); + assert!(query.get_optional_child("contact").is_some()); + assert!(query.get_optional_child("lid").is_some()); + assert!(query.get_optional_child("business").is_some()); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_is_on_whatsapp_spec_build_iq_lid() { + let spec = IsOnWhatsAppSpec::new( + vec![IsOnWhatsAppUser { + jid: Jid::lid("100000001"), + known_lid: None, + }], + "test-sid", + IsOnWhatsAppQueryType::Lid, + ); + let iq = spec.build_iq(); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + let usync = &nodes[0]; + let query = usync.get_optional_child("query").unwrap(); + assert!(query.get_optional_child("contact").is_none()); + assert!(query.get_optional_child("lid").is_some()); + assert!(query.get_optional_child("business").is_some()); + + let list = usync.get_optional_child("list").unwrap(); + let user = list.get_children_by_tag("user").next().unwrap(); + assert!(user.attrs.get("jid").is_some_and(|s| s == "100000001@lid")); + assert!(user.get_optional_child("contact").is_none()); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_is_on_whatsapp_spec_build_iq_with_known_lid() { + let spec = IsOnWhatsAppSpec::new( + vec![IsOnWhatsAppUser { + jid: Jid::pn("1234567890"), + known_lid: Some("100000001".to_string()), + }], + "sid", + IsOnWhatsAppQueryType::Pn, + ); + let iq = spec.build_iq(); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + let list = nodes[0].get_optional_child("list").unwrap(); + let user = list.get_children_by_tag("user").next().unwrap(); + let lid_child = user.get_optional_child("lid").unwrap(); + assert!( + lid_child + .attrs + .get("jid") + .is_some_and(|s| s == "100000001@lid") + ); } else { panic!("Expected NodeContent::Nodes"); } @@ -667,7 +732,11 @@ mod tests { #[test] fn test_is_on_whatsapp_spec_parse_response() { - let spec = IsOnWhatsAppSpec::new(vec!["1234567890".to_string()], "test-sid"); + let spec = IsOnWhatsAppSpec::new( + vec![pn_user("1234567890")], + "test-sid", + IsOnWhatsAppQueryType::Pn, + ); let response = NodeBuilder::new("iq") .attr("type", "result") @@ -675,7 +744,11 @@ mod tests { .children([NodeBuilder::new("list") .children([NodeBuilder::new("user") .attr("jid", "1234567890@s.whatsapp.net") - .children([NodeBuilder::new("contact").attr("type", "in").build()]) + .children([ + NodeBuilder::new("contact").attr("type", "in").build(), + NodeBuilder::new("lid").attr("val", "100000001@lid").build(), + NodeBuilder::new("business").build(), + ]) .build()]) .build()]) .build()]) @@ -685,11 +758,18 @@ mod tests { assert_eq!(results.len(), 1); assert_eq!(results[0].jid.user, "1234567890"); assert!(results[0].is_registered); + assert!(results[0].is_business); + assert!(results[0].lid.is_some()); + assert_eq!(results[0].lid.as_ref().unwrap().user, "100000001"); } #[test] fn test_is_on_whatsapp_spec_parse_not_registered() { - let spec = IsOnWhatsAppSpec::new(vec!["1234567890".to_string()], "test-sid"); + let spec = IsOnWhatsAppSpec::new( + vec![pn_user("1234567890")], + "test-sid", + IsOnWhatsAppQueryType::Pn, + ); let response = NodeBuilder::new("iq") .attr("type", "result") @@ -706,48 +786,28 @@ mod tests { let results = spec.parse_response(&response).unwrap(); assert_eq!(results.len(), 1); assert!(!results[0].is_registered); + assert!(!results[0].is_business); + assert!(results[0].lid.is_none()); } #[test] - fn test_contact_info_spec_build_iq() { - let spec = ContactInfoSpec::new(vec!["1234567890".to_string()], "test-sid"); - let iq = spec.build_iq(); - - assert_eq!(iq.namespace, "usync"); - - if let Some(NodeContent::Nodes(nodes)) = &iq.content { - let usync = &nodes[0]; - let query = usync.get_optional_child("query").unwrap(); - // Should have contact, lid, status, picture, business query fields - assert!(query.get_optional_child("contact").is_some()); - assert!(query.get_optional_child("lid").is_some()); - assert!(query.get_optional_child("status").is_some()); - assert!(query.get_optional_child("picture").is_some()); - assert!(query.get_optional_child("business").is_some()); - } else { - panic!("Expected NodeContent::Nodes"); - } - } - - #[test] - fn test_contact_info_spec_parse_response() { - let spec = ContactInfoSpec::new(vec!["1234567890".to_string()], "test-sid"); + fn test_is_on_whatsapp_spec_parse_pn_jid() { + let spec = IsOnWhatsAppSpec::new( + vec![IsOnWhatsAppUser { + jid: Jid::lid("100000001"), + known_lid: None, + }], + "test-sid", + IsOnWhatsAppQueryType::Lid, + ); let response = NodeBuilder::new("iq") .attr("type", "result") .children([NodeBuilder::new("usync") .children([NodeBuilder::new("list") .children([NodeBuilder::new("user") - .attr("jid", "1234567890@s.whatsapp.net") - .children([ - NodeBuilder::new("contact").attr("type", "in").build(), - NodeBuilder::new("lid").attr("val", "100000001@lid").build(), - NodeBuilder::new("status") - .string_content("Hello World") - .build(), - NodeBuilder::new("picture").attr("id", "123456789").build(), - NodeBuilder::new("business").build(), - ]) + .attr("jid", "100000001@lid") + .attr("pn_jid", "1234567890@s.whatsapp.net") .build()]) .build()]) .build()]) @@ -755,12 +815,11 @@ mod tests { let results = spec.parse_response(&response).unwrap(); assert_eq!(results.len(), 1); - assert_eq!(results[0].jid.user, "1234567890"); + assert_eq!(results[0].jid.user, "100000001"); + assert!(results[0].jid.is_lid()); + // LID query with no contact node: presence implies registration assert!(results[0].is_registered); - assert!(results[0].is_business); - assert_eq!(results[0].status, Some("Hello World".to_string())); - assert_eq!(results[0].picture_id, Some(123456789)); - assert!(results[0].lid.is_some()); + assert_eq!(results[0].pn_jid.as_ref().unwrap().user, "1234567890"); } #[test] @@ -820,33 +879,26 @@ mod tests { } #[test] - fn test_phone_number_formatting() { - // Without plus - let spec1 = IsOnWhatsAppSpec::new(vec!["1234567890".to_string()], "sid"); - let iq1 = spec1.build_iq(); - - // With plus - let spec2 = IsOnWhatsAppSpec::new(vec!["+1234567890".to_string()], "sid"); - let iq2 = spec2.build_iq(); - - // Both should produce the same formatted phone number with + - if let (Some(NodeContent::Nodes(n1)), Some(NodeContent::Nodes(n2))) = - (&iq1.content, &iq2.content) - { - let list1 = n1[0].get_optional_child("list").unwrap(); - let list2 = n2[0].get_optional_child("list").unwrap(); - let user1 = list1.get_children_by_tag("user").next().unwrap(); - let user2 = list2.get_children_by_tag("user").next().unwrap(); - let contact1 = user1.get_optional_child("contact").unwrap(); - let contact2 = user2.get_optional_child("contact").unwrap(); - - match (&contact1.content, &contact2.content) { - (Some(NodeContent::String(s1)), Some(NodeContent::String(s2))) => { - assert_eq!(s1, "+1234567890"); - assert_eq!(s2, "+1234567890"); - } + fn test_pn_user_phone_formatting() { + // PN JIDs always have the user part without +, build_user_nodes adds + + let spec = IsOnWhatsAppSpec::new( + vec![pn_user("1234567890")], + "sid", + IsOnWhatsAppQueryType::Pn, + ); + let iq = spec.build_iq(); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + let list = nodes[0].get_optional_child("list").unwrap(); + let user = list.get_children_by_tag("user").next().unwrap(); + let contact = user.get_optional_child("contact").unwrap(); + + match &contact.content { + Some(NodeContent::String(s)) => assert_eq!(s, "+1234567890"), _ => panic!("Expected string content"), } + // PN user nodes should NOT have a jid attribute + assert!(user.attrs.get("jid").is_none()); } }