Skip to main content

wa_rs_binary/
jid.rs

1use std::borrow::Cow;
2use std::fmt;
3use std::str::FromStr;
4
5/// Intermediate result from fast JID parsing.
6/// This avoids allocations by returning byte indices into the original string.
7#[derive(Debug, Clone, Copy)]
8pub struct ParsedJidParts<'a> {
9    pub user: &'a str,
10    pub server: &'a str,
11    pub agent: u8,
12    pub device: u16,
13    pub integrator: u16,
14}
15
16/// Single-pass JID parser optimized for hot paths.
17/// Scans the input string once to find all relevant separators (@, :)
18/// and returns slices into the original string without allocation.
19///
20/// Returns `None` for JIDs that need full validation (edge cases, unknown servers, etc.)
21#[inline]
22pub fn parse_jid_fast(s: &str) -> Option<ParsedJidParts<'_>> {
23    if s.is_empty() {
24        return None;
25    }
26
27    let bytes = s.as_bytes();
28
29    // Single pass to find key separator positions
30    let mut at_pos: Option<usize> = None;
31    let mut colon_pos: Option<usize> = None;
32    let mut last_dot_pos: Option<usize> = None;
33
34    for (i, &b) in bytes.iter().enumerate() {
35        match b {
36            b'@' => {
37                if at_pos.is_none() {
38                    at_pos = Some(i);
39                }
40            }
41            b':' => {
42                // Only track colon in user part (before @)
43                if at_pos.is_none() {
44                    colon_pos = Some(i);
45                }
46            }
47            b'.' => {
48                // Only track dots in user part (before @ and before :)
49                if at_pos.is_none() && colon_pos.is_none() {
50                    last_dot_pos = Some(i);
51                }
52            }
53            _ => {}
54        }
55    }
56
57    // Extract at_pos as concrete value - after this point we know @ exists
58    let at = match at_pos {
59        Some(pos) => pos,
60        None => {
61            // Server-only JID - let the fallback validate it
62            return None;
63        }
64    };
65
66    let user_part = &s[..at];
67    let server = &s[at + 1..];
68
69    // Validate that user_part is not empty
70    if user_part.is_empty() {
71        return None;
72    }
73
74    // Fast path for LID JIDs - dots in user are not agent separators
75    if server == HIDDEN_USER_SERVER {
76        let (user, device) = match colon_pos {
77            Some(pos) if pos < at => {
78                let device_slice = &s[pos + 1..at];
79                (&s[..pos], device_slice.parse::<u16>().unwrap_or(0))
80            }
81            _ => (user_part, 0),
82        };
83        return Some(ParsedJidParts {
84            user,
85            server,
86            agent: 0,
87            device,
88            integrator: 0,
89        });
90    }
91
92    // For DEFAULT_USER_SERVER (s.whatsapp.net), handle legacy dot format as device
93    if server == DEFAULT_USER_SERVER {
94        // Check for colon format first (modern: user:device@server)
95        if let Some(pos) = colon_pos {
96            let user_end = pos;
97            let device_start = pos + 1;
98            let device_slice = &s[device_start..at];
99            let device = device_slice.parse::<u16>().unwrap_or(0);
100            return Some(ParsedJidParts {
101                user: &s[..user_end],
102                server,
103                agent: 0,
104                device,
105                integrator: 0,
106            });
107        }
108        // Check for legacy dot format (legacy: user.device@server)
109        if let Some(dot_pos) = last_dot_pos {
110            // dot_pos is absolute position in s
111            let suffix = &s[dot_pos + 1..at];
112            if let Ok(device_val) = suffix.parse::<u16>() {
113                return Some(ParsedJidParts {
114                    user: &s[..dot_pos],
115                    server,
116                    agent: 0,
117                    device: device_val,
118                    integrator: 0,
119                });
120            }
121        }
122        // No device component
123        return Some(ParsedJidParts {
124            user: user_part,
125            server,
126            agent: 0,
127            device: 0,
128            integrator: 0,
129        });
130    }
131
132    // Parse device from colon separator (user:device@server)
133    let (user_before_colon, device) = match colon_pos {
134        Some(pos) => {
135            // Colon is at `pos` in the original string
136            let user_end = pos;
137            let device_start = pos + 1;
138            let device_slice = &s[device_start..at];
139            (&s[..user_end], device_slice.parse::<u16>().unwrap_or(0))
140        }
141        None => (user_part, 0),
142    };
143
144    // Parse agent from last dot in user part (for non-default, non-LID servers)
145    let user_to_check = user_before_colon;
146    let (final_user, agent) = {
147        if let Some(dot_pos) = user_to_check.rfind('.') {
148            let suffix = &user_to_check[dot_pos + 1..];
149            if let Ok(agent_val) = suffix.parse::<u16>() {
150                if agent_val <= u8::MAX as u16 {
151                    (&user_to_check[..dot_pos], agent_val as u8)
152                } else {
153                    (user_to_check, 0)
154                }
155            } else {
156                (user_to_check, 0)
157            }
158        } else {
159            (user_to_check, 0)
160        }
161    };
162
163    Some(ParsedJidParts {
164        user: final_user,
165        server,
166        agent,
167        device,
168        integrator: 0,
169    })
170}
171
172pub const DEFAULT_USER_SERVER: &str = "s.whatsapp.net";
173pub const SERVER_JID: &str = "s.whatsapp.net";
174pub const GROUP_SERVER: &str = "g.us";
175pub const LEGACY_USER_SERVER: &str = "c.us";
176pub const BROADCAST_SERVER: &str = "broadcast";
177pub const HIDDEN_USER_SERVER: &str = "lid";
178pub const NEWSLETTER_SERVER: &str = "newsletter";
179pub const HOSTED_SERVER: &str = "hosted";
180pub const HOSTED_LID_SERVER: &str = "hosted.lid";
181pub const MESSENGER_SERVER: &str = "msgr";
182pub const INTEROP_SERVER: &str = "interop";
183pub const BOT_SERVER: &str = "bot";
184pub const STATUS_BROADCAST_USER: &str = "status";
185
186pub type MessageId = String;
187pub type MessageServerId = i32;
188#[derive(Debug)]
189pub enum JidError {
190    // REMOVE: #[error("...")]
191    InvalidFormat(String),
192    // REMOVE: #[error("...")]
193    Parse(std::num::ParseIntError),
194}
195
196impl fmt::Display for JidError {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        match self {
199            JidError::InvalidFormat(s) => write!(f, "Invalid JID format: {s}"),
200            JidError::Parse(e) => write!(f, "Failed to parse component: {e}"),
201        }
202    }
203}
204
205impl std::error::Error for JidError {
206    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
207        match self {
208            JidError::Parse(e) => Some(e),
209            _ => None,
210        }
211    }
212}
213
214// Add From impl
215impl From<std::num::ParseIntError> for JidError {
216    fn from(err: std::num::ParseIntError) -> Self {
217        JidError::Parse(err)
218    }
219}
220
221pub trait JidExt {
222    fn user(&self) -> &str;
223    fn server(&self) -> &str;
224    fn device(&self) -> u16;
225    fn integrator(&self) -> u16;
226
227    fn is_ad(&self) -> bool {
228        self.device() > 0
229            && (self.server() == DEFAULT_USER_SERVER
230                || self.server() == HIDDEN_USER_SERVER
231                || self.server() == HOSTED_SERVER)
232    }
233
234    fn is_interop(&self) -> bool {
235        self.server() == INTEROP_SERVER && self.integrator() > 0
236    }
237
238    fn is_messenger(&self) -> bool {
239        self.server() == MESSENGER_SERVER && self.device() > 0
240    }
241
242    fn is_group(&self) -> bool {
243        self.server() == GROUP_SERVER
244    }
245
246    fn is_broadcast_list(&self) -> bool {
247        self.server() == BROADCAST_SERVER && self.user() != STATUS_BROADCAST_USER
248    }
249
250    fn is_status_broadcast(&self) -> bool {
251        self.server() == BROADCAST_SERVER && self.user() == STATUS_BROADCAST_USER
252    }
253
254    fn is_bot(&self) -> bool {
255        (self.server() == DEFAULT_USER_SERVER
256            && self.device() == 0
257            && (self.user().starts_with("1313555") || self.user().starts_with("131655500")))
258            || self.server() == BOT_SERVER
259    }
260
261    fn is_newsletter(&self) -> bool {
262        self.server() == NEWSLETTER_SERVER
263    }
264
265    /// Returns true if this is a hosted/Cloud API device.
266    /// Hosted devices have device ID 99 or use @hosted/@hosted.lid server.
267    /// These devices should be excluded from group message fanout.
268    fn is_hosted(&self) -> bool {
269        self.device() == 99 || self.server() == HOSTED_SERVER || self.server() == HOSTED_LID_SERVER
270    }
271
272    fn is_empty(&self) -> bool {
273        self.server().is_empty()
274    }
275
276    fn is_same_user_as(&self, other: &impl JidExt) -> bool {
277        self.user() == other.user()
278    }
279}
280
281#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
282#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
283pub struct Jid {
284    pub user: String,
285    pub server: String,
286    pub agent: u8,
287    pub device: u16,
288    pub integrator: u16,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq, Hash)]
292pub struct JidRef<'a> {
293    pub user: Cow<'a, str>,
294    pub server: Cow<'a, str>,
295    pub agent: u8,
296    pub device: u16,
297    pub integrator: u16,
298}
299
300impl JidExt for Jid {
301    fn user(&self) -> &str {
302        &self.user
303    }
304    fn server(&self) -> &str {
305        &self.server
306    }
307    fn device(&self) -> u16 {
308        self.device
309    }
310    fn integrator(&self) -> u16 {
311        self.integrator
312    }
313}
314
315impl Jid {
316    pub fn new(user: &str, server: &str) -> Self {
317        Self {
318            user: user.to_string(),
319            server: server.to_string(),
320            ..Default::default()
321        }
322    }
323
324    /// Create a phone number JID (s.whatsapp.net)
325    pub fn pn(user: impl Into<String>) -> Self {
326        Self {
327            user: user.into(),
328            server: DEFAULT_USER_SERVER.to_string(),
329            ..Default::default()
330        }
331    }
332
333    /// Create a LID JID (lid server)
334    pub fn lid(user: impl Into<String>) -> Self {
335        Self {
336            user: user.into(),
337            server: HIDDEN_USER_SERVER.to_string(),
338            ..Default::default()
339        }
340    }
341
342    /// Create a group JID (g.us)
343    pub fn group(id: impl Into<String>) -> Self {
344        Self {
345            user: id.into(),
346            server: GROUP_SERVER.to_string(),
347            ..Default::default()
348        }
349    }
350
351    /// Create a phone number JID with device ID
352    pub fn pn_device(user: impl Into<String>, device: u16) -> Self {
353        Self {
354            user: user.into(),
355            server: DEFAULT_USER_SERVER.to_string(),
356            device,
357            ..Default::default()
358        }
359    }
360
361    /// Create a LID JID with device ID
362    pub fn lid_device(user: impl Into<String>, device: u16) -> Self {
363        Self {
364            user: user.into(),
365            server: HIDDEN_USER_SERVER.to_string(),
366            device,
367            ..Default::default()
368        }
369    }
370
371    /// Returns true if this is a Phone Number based JID (s.whatsapp.net)
372    #[inline]
373    pub fn is_pn(&self) -> bool {
374        self.server == DEFAULT_USER_SERVER
375    }
376
377    /// Returns true if this is a LID based JID
378    #[inline]
379    pub fn is_lid(&self) -> bool {
380        self.server == HIDDEN_USER_SERVER
381    }
382
383    /// Returns the user part without the device ID suffix (e.g., "123:4" -> "123")
384    #[inline]
385    pub fn user_base(&self) -> &str {
386        if let Some((base, _)) = self.user.split_once(':') {
387            base
388        } else {
389            &self.user
390        }
391    }
392
393    /// Helper to construct a specific device JID from this one
394    pub fn with_device(&self, device_id: u16) -> Self {
395        Self {
396            user: self.user.clone(),
397            server: self.server.clone(),
398            agent: self.agent,
399            device: device_id,
400            integrator: self.integrator,
401        }
402    }
403
404    pub fn actual_agent(&self) -> u8 {
405        match self.server.as_str() {
406            DEFAULT_USER_SERVER => 0,
407            // For LID (HIDDEN_USER_SERVER), use the parsed agent value.
408            // LID user identifiers can contain dots (e.g., "100000000000001.1"),
409            // which are part of the identity, not agent separators.
410            // Only non-device LID JIDs (without ':') may have an agent suffix.
411            HIDDEN_USER_SERVER => self.agent,
412            _ => self.agent,
413        }
414    }
415
416    pub fn to_non_ad(&self) -> Self {
417        Self {
418            user: self.user.clone(),
419            server: self.server.clone(),
420            integrator: self.integrator,
421            ..Default::default()
422        }
423    }
424
425    /// Check if this JID matches the user or their LID.
426    /// Useful for checking if a participant is "us" in group messages.
427    #[inline]
428    pub fn matches_user_or_lid(&self, user: &Jid, lid: Option<&Jid>) -> bool {
429        self.is_same_user_as(user) || lid.is_some_and(|l| self.is_same_user_as(l))
430    }
431
432    /// Normalize the JID for use in pre-key bundle storage and lookup.
433    ///
434    /// WhatsApp servers may return JIDs with varied agent fields, or we might derive them
435    /// with agent fields in some contexts. However, pre-key bundles are stored and looked up
436    /// using a normalized key where the agent is 0 for standard servers (s.whatsapp.net, lid).
437    pub fn normalize_for_prekey_bundle(&self) -> Self {
438        let mut jid = self.clone();
439        if jid.server == DEFAULT_USER_SERVER || jid.server == HIDDEN_USER_SERVER {
440            jid.agent = 0;
441        }
442        jid
443    }
444
445    pub fn to_ad_string(&self) -> String {
446        if self.user.is_empty() {
447            self.server.clone()
448        } else {
449            format!(
450                "{}.{}:{}@{}",
451                self.user, self.agent, self.device, self.server
452            )
453        }
454    }
455
456    /// Compare device identity (user, server, device) without allocation.
457    #[inline]
458    pub fn device_eq(&self, other: &Jid) -> bool {
459        self.user == other.user && self.server == other.server && self.device == other.device
460    }
461
462    /// Get a borrowing key for O(1) HashSet lookups by device identity.
463    #[inline]
464    pub fn device_key(&self) -> DeviceKey<'_> {
465        DeviceKey {
466            user: &self.user,
467            server: &self.server,
468            device: self.device,
469        }
470    }
471}
472
473/// Borrowing key for device identity (user, server, device). Use with HashSet for O(1) lookups.
474#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
475pub struct DeviceKey<'a> {
476    pub user: &'a str,
477    pub server: &'a str,
478    pub device: u16,
479}
480
481impl<'a> JidExt for JidRef<'a> {
482    fn user(&self) -> &str {
483        &self.user
484    }
485    fn server(&self) -> &str {
486        &self.server
487    }
488    fn device(&self) -> u16 {
489        self.device
490    }
491    fn integrator(&self) -> u16 {
492        self.integrator
493    }
494}
495
496impl<'a> JidRef<'a> {
497    pub fn new(user: Cow<'a, str>, server: Cow<'a, str>) -> Self {
498        Self {
499            user,
500            server,
501            agent: 0,
502            device: 0,
503            integrator: 0,
504        }
505    }
506
507    pub fn to_owned(&self) -> Jid {
508        Jid {
509            user: self.user.to_string(),
510            server: self.server.to_string(),
511            agent: self.agent,
512            device: self.device,
513            integrator: self.integrator,
514        }
515    }
516}
517
518impl FromStr for Jid {
519    type Err = JidError;
520    fn from_str(s: &str) -> Result<Self, Self::Err> {
521        // Try fast path first for well-formed JIDs
522        if let Some(parts) = parse_jid_fast(s) {
523            return Ok(Jid {
524                user: parts.user.to_string(),
525                server: parts.server.to_string(),
526                agent: parts.agent,
527                device: parts.device,
528                integrator: parts.integrator,
529            });
530        }
531
532        // Fallback to original parsing for edge cases and validation
533        // Keep server as &str to avoid allocation until we need it
534        let (user_part, server) = match s.split_once('@') {
535            Some((u, s)) => (u, s),
536            None => ("", s),
537        };
538
539        if user_part.is_empty() {
540            let known_servers = [
541                DEFAULT_USER_SERVER,
542                GROUP_SERVER,
543                LEGACY_USER_SERVER,
544                BROADCAST_SERVER,
545                HIDDEN_USER_SERVER,
546                NEWSLETTER_SERVER,
547                HOSTED_SERVER,
548                MESSENGER_SERVER,
549                INTEROP_SERVER,
550                BOT_SERVER,
551                STATUS_BROADCAST_USER,
552            ];
553            if !known_servers.contains(&server) {
554                return Err(JidError::InvalidFormat(format!(
555                    "Invalid JID format: unknown server '{}'",
556                    server
557                )));
558            }
559        }
560
561        // Special handling for LID JIDs, as their user part can contain dots
562        // that should not be interpreted as agent separators.
563        if server == HIDDEN_USER_SERVER {
564            let (user, device) = if let Some((u, d_str)) = user_part.rsplit_once(':') {
565                (u, d_str.parse()?)
566            } else {
567                (user_part, 0)
568            };
569            return Ok(Jid {
570                user: user.to_string(),
571                server: server.to_string(),
572                device,
573                agent: 0,
574                integrator: 0,
575            });
576        }
577
578        // Fallback to existing logic for other JID types (s.whatsapp.net, etc.)
579        let mut user = user_part;
580        let mut device = 0;
581        let mut agent = 0;
582
583        if let Some((u, d_str)) = user_part.rsplit_once(':') {
584            user = u;
585            device = d_str.parse()?;
586        }
587
588        if server != DEFAULT_USER_SERVER
589            && server != HIDDEN_USER_SERVER
590            && let Some((u, last_part)) = user.rsplit_once('.')
591            && let Ok(num_val) = last_part.parse::<u16>()
592        {
593            user = u;
594            agent = num_val as u8;
595        }
596
597        if let Some((u, last_part)) = user_part.rsplit_once('.')
598            && let Ok(num_val) = last_part.parse::<u16>()
599        {
600            if server == DEFAULT_USER_SERVER {
601                user = u;
602                device = num_val;
603            } else {
604                user = u;
605                if num_val > u8::MAX as u16 {
606                    return Err(JidError::InvalidFormat(format!(
607                        "Agent component out of range: {num_val}"
608                    )));
609                }
610                agent = num_val as u8;
611            }
612        }
613
614        Ok(Jid {
615            user: user.to_string(),
616            server: server.to_string(),
617            agent,
618            device,
619            integrator: 0,
620        })
621    }
622}
623
624impl fmt::Display for Jid {
625    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
626        if self.user.is_empty() {
627            // Server-only JID (e.g., "s.whatsapp.net") - no @ prefix
628            write!(f, "{}", self.server)
629        } else {
630            write!(f, "{}", self.user)?;
631
632            // The agent is encoded in the server type for AD JIDs.
633            // We should NOT append it to the user string for standard servers.
634            // Only non-standard servers might use an agent suffix.
635            // The old JS logic appears to never append the agent for s.whatsapp.net or lid.
636            if self.agent > 0 {
637                // This is a guess based on the failure. The old JS logic is complex.
638                // We will only append the agent if the server is NOT s.whatsapp.net or lid.
639                // AND the server is not one that is derived *from* the agent (like 'hosted').
640                let server_str = self.server(); // Use trait method
641                if server_str != DEFAULT_USER_SERVER
642                    && server_str != HIDDEN_USER_SERVER
643                    && server_str != HOSTED_SERVER
644                {
645                    write!(f, ".{}", self.agent)?;
646                }
647            }
648
649            if self.device > 0 {
650                write!(f, ":{}", self.device)?;
651            }
652
653            write!(f, "@{}", self.server)
654        }
655    }
656}
657
658impl<'a> fmt::Display for JidRef<'a> {
659    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
660        if self.user.is_empty() {
661            // Server-only JID (e.g., "s.whatsapp.net") - no @ prefix
662            write!(f, "{}", self.server)
663        } else {
664            write!(f, "{}", self.user)?;
665
666            // The agent is encoded in the server type for AD JIDs.
667            // We should NOT append it to the user string for standard servers.
668            // Only non-standard servers might use an agent suffix.
669            // The old JS logic appears to never append the agent for s.whatsapp.net or lid.
670            if self.agent > 0 {
671                // This is a guess based on the failure. The old JS logic is complex.
672                // We will only append the agent if the server is NOT s.whatsapp.net or lid.
673                // AND the server is not one that is derived *from* the agent (like 'hosted').
674                let server_str = self.server(); // Use trait method
675                if server_str != DEFAULT_USER_SERVER
676                    && server_str != HIDDEN_USER_SERVER
677                    && server_str != HOSTED_SERVER
678                {
679                    write!(f, ".{}", self.agent)?;
680                }
681            }
682
683            if self.device > 0 {
684                write!(f, ":{}", self.device)?;
685            }
686
687            write!(f, "@{}", self.server)
688        }
689    }
690}
691
692impl From<Jid> for String {
693    fn from(jid: Jid) -> Self {
694        jid.to_string()
695    }
696}
697
698impl<'a> From<JidRef<'a>> for String {
699    fn from(jid: JidRef<'a>) -> Self {
700        jid.to_string()
701    }
702}
703
704impl TryFrom<String> for Jid {
705    type Error = JidError;
706    fn try_from(value: String) -> Result<Self, Self::Error> {
707        Jid::from_str(&value)
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use std::str::FromStr;
715
716    /// Helper function to test a full parsing and display round-trip.
717    fn assert_jid_roundtrip(
718        input: &str,
719        expected_user: &str,
720        expected_server: &str,
721        expected_device: u16,
722        expected_agent: u8,
723    ) {
724        assert_jid_parse_and_display(
725            input,
726            expected_user,
727            expected_server,
728            expected_device,
729            expected_agent,
730            input,
731        );
732    }
733
734    /// Helper function to test parsing and display with a custom expected output.
735    fn assert_jid_parse_and_display(
736        input: &str,
737        expected_user: &str,
738        expected_server: &str,
739        expected_device: u16,
740        expected_agent: u8,
741        expected_output: &str,
742    ) {
743        // 1. Test parsing from string (FromStr trait)
744        let jid = Jid::from_str(input).unwrap_or_else(|_| panic!("Failed to parse JID: {}", input));
745
746        assert_eq!(
747            jid.user, expected_user,
748            "User part did not match for {}",
749            input
750        );
751        assert_eq!(
752            jid.server, expected_server,
753            "Server part did not match for {}",
754            input
755        );
756        assert_eq!(
757            jid.device, expected_device,
758            "Device part did not match for {}",
759            input
760        );
761        assert_eq!(
762            jid.agent, expected_agent,
763            "Agent part did not match for {}",
764            input
765        );
766
767        // 2. Test formatting back to string (Display trait)
768        let formatted = jid.to_string();
769        assert_eq!(
770            formatted, expected_output,
771            "Formatted string did not match expected output for {}",
772            input
773        );
774    }
775
776    #[test]
777    fn test_jid_parsing_and_display_roundtrip() {
778        // Standard cases
779        assert_jid_roundtrip(
780            "1234567890@s.whatsapp.net",
781            "1234567890",
782            "s.whatsapp.net",
783            0,
784            0,
785        );
786        assert_jid_roundtrip(
787            "1234567890:15@s.whatsapp.net",
788            "1234567890",
789            "s.whatsapp.net",
790            15,
791            0,
792        );
793        assert_jid_roundtrip("123-456@g.us", "123-456", "g.us", 0, 0);
794
795        // Server-only JID: parsing "s.whatsapp.net" should display as "s.whatsapp.net" (no @ prefix)
796        // This matches WhatsApp Web behavior where server-only JIDs don't have @ prefix
797        assert_jid_roundtrip("s.whatsapp.net", "", "s.whatsapp.net", 0, 0);
798
799        // LID JID cases (critical for the bug)
800        assert_jid_roundtrip("12345.6789@lid", "12345.6789", "lid", 0, 0);
801        assert_jid_roundtrip("12345.6789:25@lid", "12345.6789", "lid", 25, 0);
802    }
803
804    #[test]
805    fn test_special_from_str_parsing() {
806        // Test parsing of JIDs with an agent, which should be stored in the struct
807        let jid = Jid::from_str("1234567890.2:15@hosted").expect("test hosted JID should be valid");
808        assert_eq!(jid.user, "1234567890");
809        assert_eq!(jid.server, "hosted");
810        assert_eq!(jid.device, 15);
811        assert_eq!(jid.agent, 2);
812    }
813
814    #[test]
815    fn test_manual_jid_formatting_edge_cases() {
816        // This test directly validates the fixes for the parity failures.
817        // We manually construct the Jid struct as the binary decoder would,
818        // then we assert that its string representation is correct.
819
820        // Failure Case 1: An AD-JID for s.whatsapp.net decoded with an agent.
821        // The Display trait MUST NOT show the agent number.
822        let jid1 = Jid {
823            user: "1234567890".to_string(),
824            server: "s.whatsapp.net".to_string(),
825            device: 15,
826            agent: 2, // This agent would be decoded from binary but should be ignored in display
827            integrator: 0,
828        };
829        // Expected: "1234567890:15@s.whatsapp.net" (agent is omitted)
830        // Buggy: "1234567890.2:15@s.whatsapp.net"
831        assert_eq!(jid1.to_string(), "1234567890:15@s.whatsapp.net");
832
833        // Failure Case 2: A LID JID with a device, decoded with an agent.
834        // The Display trait MUST NOT show the agent number.
835        let jid2 = Jid {
836            user: "12345.6789".to_string(),
837            server: "lid".to_string(),
838            device: 25,
839            agent: 1, // This agent would be decoded from binary but should be ignored in display
840            integrator: 0,
841        };
842        // Expected: "12345.6789:25@lid"
843        // Buggy: "12345.6789.1:25@lid"
844        assert_eq!(jid2.to_string(), "12345.6789:25@lid");
845
846        // Failure Case 3: A JID that was decoded as "hosted" because of its agent.
847        // The Display trait MUST NOT show the agent number.
848        let jid3 = Jid {
849            user: "1234567890".to_string(),
850            server: "hosted".to_string(),
851            device: 15,
852            agent: 2,
853            integrator: 0,
854        };
855        // Expected: "1234567890:15@hosted"
856        // Buggy: "1234567890.2:15@hosted"
857        assert_eq!(jid3.to_string(), "1234567890:15@hosted");
858
859        // Verification Case: A generic JID where the agent SHOULD be displayed.
860        let jid4 = Jid {
861            user: "user".to_string(),
862            server: "custom.net".to_string(),
863            device: 10,
864            agent: 5,
865            integrator: 0,
866        };
867        // The agent should be displayed because the server is not a special AD-JID type
868        assert_eq!(jid4.to_string(), "user.5:10@custom.net");
869    }
870
871    #[test]
872    fn test_invalid_jids_should_fail_to_parse() {
873        assert!(Jid::from_str("thisisnotajid").is_err());
874        assert!(Jid::from_str("").is_err());
875        // "@s.whatsapp.net" is now valid - it's the protocol format for server-only JIDs
876        assert!(Jid::from_str("@s.whatsapp.net").is_ok());
877        // But "@unknown.server" should still fail
878        assert!(Jid::from_str("@unknown.server").is_err());
879        // Jid::from_str("2") should not be possible due to type constraints,
880        // but if it were, it should fail. The string must contain '@'.
881        assert!(Jid::from_str("2").is_err());
882    }
883
884    /// Tests for HOSTED device detection (`is_hosted()` method).
885    ///
886    /// # Context: What are HOSTED devices?
887    ///
888    /// HOSTED devices (also known as Cloud API or Meta Business API devices) are
889    /// WhatsApp Business accounts that use Meta's server-side infrastructure instead
890    /// of traditional end-to-end encryption with Signal protocol.
891    ///
892    /// ## Key characteristics:
893    /// - Device ID is always 99 (`:99`)
894    /// - Server is `@hosted` (phone-based) or `@hosted.lid` (LID-based)
895    /// - They do NOT use Signal protocol prekeys
896    /// - They should be EXCLUDED from group message fanout
897    /// - They CAN receive 1:1 messages (but prekey fetch will fail, causing graceful skip)
898    ///
899    /// ## Why exclude from groups?
900    /// WhatsApp Web explicitly filters hosted devices from group SKDM (Sender Key
901    /// Distribution Message) distribution. From WhatsApp Web JS (`getFanOutList`):
902    /// ```javascript
903    /// var isHosted = e.id === 99 || e.isHosted === true;
904    /// var includeInFanout = !isHosted || isOneToOneChat;
905    /// ```
906    ///
907    /// ## JID formats:
908    /// - Phone-based: `5511999887766:99@hosted`
909    /// - LID-based: `100000012345678:99@hosted.lid`
910    /// - Regular device with ID 99: `5511999887766:99@s.whatsapp.net` (also hosted!)
911    #[test]
912    fn test_is_hosted_device_detection() {
913        // === HOSTED DEVICES (should return true) ===
914
915        // Case 1: Device ID 99 on regular server (Cloud API business account)
916        // This is the most common case - a business using Meta's Cloud API
917        let cloud_api_device: Jid = "5511999887766:99@s.whatsapp.net"
918            .parse()
919            .expect("test JID should be valid");
920        assert!(
921            cloud_api_device.is_hosted(),
922            "Device ID 99 on s.whatsapp.net should be detected as hosted (Cloud API)"
923        );
924
925        // Case 2: Device ID 99 on LID server
926        let cloud_api_lid: Jid = "100000012345678:99@lid"
927            .parse()
928            .expect("test JID should be valid");
929        assert!(
930            cloud_api_lid.is_hosted(),
931            "Device ID 99 on lid server should be detected as hosted"
932        );
933
934        // Case 3: Explicit @hosted server (phone-based hosted JID)
935        let hosted_server: Jid = "5511999887766:99@hosted"
936            .parse()
937            .expect("test JID should be valid");
938        assert!(
939            hosted_server.is_hosted(),
940            "JID with @hosted server should be detected as hosted"
941        );
942
943        // Case 4: Explicit @hosted.lid server (LID-based hosted JID)
944        let hosted_lid_server: Jid = "100000012345678:99@hosted.lid"
945            .parse()
946            .expect("test JID should be valid");
947        assert!(
948            hosted_lid_server.is_hosted(),
949            "JID with @hosted.lid server should be detected as hosted"
950        );
951
952        // Case 5: @hosted server with different device ID (edge case)
953        // Even with device ID != 99, if server is @hosted, it's a hosted device
954        let hosted_server_other_device: Jid = "5511999887766:0@hosted"
955            .parse()
956            .expect("test JID should be valid");
957        assert!(
958            hosted_server_other_device.is_hosted(),
959            "JID with @hosted server should be hosted regardless of device ID"
960        );
961
962        // === NON-HOSTED DEVICES (should return false) ===
963
964        // Case 6: Regular phone device (primary phone, device 0)
965        let regular_phone: Jid = "5511999887766:0@s.whatsapp.net"
966            .parse()
967            .expect("test JID should be valid");
968        assert!(
969            !regular_phone.is_hosted(),
970            "Regular phone device (ID 0) should NOT be hosted"
971        );
972
973        // Case 7: Companion device (WhatsApp Web, device 33+)
974        let companion_device: Jid = "5511999887766:33@s.whatsapp.net"
975            .parse()
976            .expect("test JID should be valid");
977        assert!(
978            !companion_device.is_hosted(),
979            "Companion device (ID 33) should NOT be hosted"
980        );
981
982        // Case 8: Regular LID device
983        let regular_lid: Jid = "100000012345678:0@lid"
984            .parse()
985            .expect("test JID should be valid");
986        assert!(
987            !regular_lid.is_hosted(),
988            "Regular LID device should NOT be hosted"
989        );
990
991        // Case 9: LID companion device
992        let lid_companion: Jid = "100000012345678:33@lid"
993            .parse()
994            .expect("test JID should be valid");
995        assert!(
996            !lid_companion.is_hosted(),
997            "LID companion device (ID 33) should NOT be hosted"
998        );
999
1000        // Case 10: Group JID (not a device at all)
1001        let group_jid: Jid = "120363012345678@g.us"
1002            .parse()
1003            .expect("test JID should be valid");
1004        assert!(
1005            !group_jid.is_hosted(),
1006            "Group JID should NOT be detected as hosted"
1007        );
1008
1009        // Case 11: User JID without device
1010        let user_jid: Jid = "5511999887766@s.whatsapp.net"
1011            .parse()
1012            .expect("test JID should be valid");
1013        assert!(
1014            !user_jid.is_hosted(),
1015            "User JID without device should NOT be hosted"
1016        );
1017
1018        // Case 12: Bot device
1019        let bot_jid: Jid = "13136555001:0@s.whatsapp.net"
1020            .parse()
1021            .expect("test JID should be valid");
1022        assert!(
1023            !bot_jid.is_hosted(),
1024            "Bot JID should NOT be detected as hosted (different mechanism)"
1025        );
1026    }
1027
1028    /// Tests that document the filtering behavior for group messages.
1029    ///
1030    /// # Why this matters:
1031    /// When sending a group message, we distribute Sender Key Distribution Messages
1032    /// (SKDM) to all participant devices. However, HOSTED devices:
1033    /// 1. Don't use Signal protocol, so they can't process SKDM
1034    /// 2. WhatsApp Web explicitly excludes them from group fanout
1035    /// 3. Including them would cause unnecessary prekey fetch failures
1036    ///
1037    /// This test documents the expected behavior when filtering device lists.
1038    #[test]
1039    fn test_hosted_device_filtering_for_groups() {
1040        // Simulate a group with mixed device types
1041        let devices: Vec<Jid> = vec![
1042            // Regular devices that SHOULD receive SKDM
1043            "5511999887766:0@s.whatsapp.net"
1044                .parse()
1045                .expect("test JID should be valid"), // Phone
1046            "5511999887766:33@s.whatsapp.net"
1047                .parse()
1048                .expect("test JID should be valid"), // WhatsApp Web
1049            "5521988776655:0@s.whatsapp.net"
1050                .parse()
1051                .expect("test JID should be valid"), // Another user's phone
1052            "100000012345678:0@lid"
1053                .parse()
1054                .expect("test JID should be valid"), // LID device
1055            "100000012345678:33@lid"
1056                .parse()
1057                .expect("test JID should be valid"), // LID companion
1058            // HOSTED devices that should be EXCLUDED from group SKDM
1059            "5531977665544:99@s.whatsapp.net"
1060                .parse()
1061                .expect("test JID should be valid"), // Cloud API business
1062            "100000087654321:99@lid"
1063                .parse()
1064                .expect("test JID should be valid"), // Cloud API on LID
1065            "5541966554433:99@hosted"
1066                .parse()
1067                .expect("test JID should be valid"), // Explicit hosted
1068        ];
1069
1070        // Filter out hosted devices (this is what prepare_group_stanza does)
1071        let filtered: Vec<&Jid> = devices.iter().filter(|jid| !jid.is_hosted()).collect();
1072
1073        // Verify correct filtering
1074        assert_eq!(
1075            filtered.len(),
1076            5,
1077            "Should have 5 non-hosted devices after filtering"
1078        );
1079
1080        // All filtered devices should NOT be hosted
1081        for jid in &filtered {
1082            assert!(
1083                !jid.is_hosted(),
1084                "Filtered list should not contain hosted devices: {}",
1085                jid
1086            );
1087        }
1088
1089        // Count how many hosted devices were filtered out
1090        let hosted_count = devices.iter().filter(|jid| jid.is_hosted()).count();
1091        assert_eq!(hosted_count, 3, "Should have filtered out 3 hosted devices");
1092    }
1093
1094    #[test]
1095    fn test_jid_pn_factory() {
1096        let jid = Jid::pn("1234567890");
1097        assert_eq!(jid.user, "1234567890");
1098        assert_eq!(jid.server, DEFAULT_USER_SERVER);
1099        assert_eq!(jid.device, 0);
1100        assert!(jid.is_pn());
1101    }
1102
1103    #[test]
1104    fn test_jid_lid_factory() {
1105        let jid = Jid::lid("100000012345678");
1106        assert_eq!(jid.user, "100000012345678");
1107        assert_eq!(jid.server, HIDDEN_USER_SERVER);
1108        assert_eq!(jid.device, 0);
1109        assert!(jid.is_lid());
1110    }
1111
1112    #[test]
1113    fn test_jid_group_factory() {
1114        let jid = Jid::group("123456789-1234567890");
1115        assert_eq!(jid.user, "123456789-1234567890");
1116        assert_eq!(jid.server, GROUP_SERVER);
1117        assert!(jid.is_group());
1118    }
1119
1120    #[test]
1121    fn test_jid_pn_device_factory() {
1122        let jid = Jid::pn_device("1234567890", 5);
1123        assert_eq!(jid.user, "1234567890");
1124        assert_eq!(jid.server, DEFAULT_USER_SERVER);
1125        assert_eq!(jid.device, 5);
1126        assert!(jid.is_pn());
1127        assert!(jid.is_ad());
1128    }
1129
1130    #[test]
1131    fn test_jid_lid_device_factory() {
1132        let jid = Jid::lid_device("100000012345678", 33);
1133        assert_eq!(jid.user, "100000012345678");
1134        assert_eq!(jid.server, HIDDEN_USER_SERVER);
1135        assert_eq!(jid.device, 33);
1136        assert!(jid.is_lid());
1137        assert!(jid.is_ad());
1138    }
1139
1140    #[test]
1141    fn test_jid_factories_with_string_types() {
1142        // Test with &str
1143        let jid1 = Jid::pn("123");
1144        assert_eq!(jid1.user, "123");
1145
1146        // Test with String
1147        let jid2 = Jid::lid(String::from("456"));
1148        assert_eq!(jid2.user, "456");
1149
1150        // Test with owned String
1151        let user = "789".to_string();
1152        let jid3 = Jid::group(user);
1153        assert_eq!(jid3.user, "789");
1154    }
1155}