wacore_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    /// Returns true if this is a hosted/Cloud API device.
262    /// Hosted devices have device ID 99 or use @hosted/@hosted.lid server.
263    /// These devices should be excluded from group message fanout.
264    fn is_hosted(&self) -> bool {
265        self.device() == 99 || self.server() == HOSTED_SERVER || self.server() == HOSTED_LID_SERVER
266    }
267
268    fn is_empty(&self) -> bool {
269        self.server().is_empty()
270    }
271
272    fn is_same_user_as(&self, other: &impl JidExt) -> bool {
273        self.user() == other.user()
274    }
275}
276
277#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
278#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
279pub struct Jid {
280    pub user: String,
281    pub server: String,
282    pub agent: u8,
283    pub device: u16,
284    pub integrator: u16,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Hash)]
288pub struct JidRef<'a> {
289    pub user: Cow<'a, str>,
290    pub server: Cow<'a, str>,
291    pub agent: u8,
292    pub device: u16,
293    pub integrator: u16,
294}
295
296impl JidExt for Jid {
297    fn user(&self) -> &str {
298        &self.user
299    }
300    fn server(&self) -> &str {
301        &self.server
302    }
303    fn device(&self) -> u16 {
304        self.device
305    }
306    fn integrator(&self) -> u16 {
307        self.integrator
308    }
309}
310
311impl Jid {
312    pub fn new(user: &str, server: &str) -> Self {
313        Self {
314            user: user.to_string(),
315            server: server.to_string(),
316            ..Default::default()
317        }
318    }
319
320    /// Create a phone number JID (s.whatsapp.net)
321    pub fn pn(user: impl Into<String>) -> Self {
322        Self {
323            user: user.into(),
324            server: DEFAULT_USER_SERVER.to_string(),
325            ..Default::default()
326        }
327    }
328
329    /// Create a LID JID (lid server)
330    pub fn lid(user: impl Into<String>) -> Self {
331        Self {
332            user: user.into(),
333            server: HIDDEN_USER_SERVER.to_string(),
334            ..Default::default()
335        }
336    }
337
338    /// Create a group JID (g.us)
339    pub fn group(id: impl Into<String>) -> Self {
340        Self {
341            user: id.into(),
342            server: GROUP_SERVER.to_string(),
343            ..Default::default()
344        }
345    }
346
347    /// Create a phone number JID with device ID
348    pub fn pn_device(user: impl Into<String>, device: u16) -> Self {
349        Self {
350            user: user.into(),
351            server: DEFAULT_USER_SERVER.to_string(),
352            device,
353            ..Default::default()
354        }
355    }
356
357    /// Create a LID JID with device ID
358    pub fn lid_device(user: impl Into<String>, device: u16) -> Self {
359        Self {
360            user: user.into(),
361            server: HIDDEN_USER_SERVER.to_string(),
362            device,
363            ..Default::default()
364        }
365    }
366
367    /// Returns true if this is a Phone Number based JID (s.whatsapp.net)
368    #[inline]
369    pub fn is_pn(&self) -> bool {
370        self.server == DEFAULT_USER_SERVER
371    }
372
373    /// Returns true if this is a LID based JID
374    #[inline]
375    pub fn is_lid(&self) -> bool {
376        self.server == HIDDEN_USER_SERVER
377    }
378
379    /// Returns the user part without the device ID suffix (e.g., "123:4" -> "123")
380    #[inline]
381    pub fn user_base(&self) -> &str {
382        if let Some((base, _)) = self.user.split_once(':') {
383            base
384        } else {
385            &self.user
386        }
387    }
388
389    /// Helper to construct a specific device JID from this one
390    pub fn with_device(&self, device_id: u16) -> Self {
391        Self {
392            user: self.user.clone(),
393            server: self.server.clone(),
394            agent: self.agent,
395            device: device_id,
396            integrator: self.integrator,
397        }
398    }
399
400    pub fn actual_agent(&self) -> u8 {
401        match self.server.as_str() {
402            DEFAULT_USER_SERVER => 0,
403            // For LID (HIDDEN_USER_SERVER), use the parsed agent value.
404            // LID user identifiers can contain dots (e.g., "100000000000001.1"),
405            // which are part of the identity, not agent separators.
406            // Only non-device LID JIDs (without ':') may have an agent suffix.
407            HIDDEN_USER_SERVER => self.agent,
408            _ => self.agent,
409        }
410    }
411
412    pub fn to_non_ad(&self) -> Self {
413        Self {
414            user: self.user.clone(),
415            server: self.server.clone(),
416            integrator: self.integrator,
417            ..Default::default()
418        }
419    }
420
421    /// Check if this JID matches the user or their LID.
422    /// Useful for checking if a participant is "us" in group messages.
423    #[inline]
424    pub fn matches_user_or_lid(&self, user: &Jid, lid: Option<&Jid>) -> bool {
425        self.is_same_user_as(user) || lid.is_some_and(|l| self.is_same_user_as(l))
426    }
427
428    pub fn to_ad_string(&self) -> String {
429        if self.user.is_empty() {
430            self.server.clone()
431        } else {
432            format!(
433                "{}.{}:{}@{}",
434                self.user, self.agent, self.device, self.server
435            )
436        }
437    }
438}
439
440impl<'a> JidExt for JidRef<'a> {
441    fn user(&self) -> &str {
442        &self.user
443    }
444    fn server(&self) -> &str {
445        &self.server
446    }
447    fn device(&self) -> u16 {
448        self.device
449    }
450    fn integrator(&self) -> u16 {
451        self.integrator
452    }
453}
454
455impl<'a> JidRef<'a> {
456    pub fn new(user: Cow<'a, str>, server: Cow<'a, str>) -> Self {
457        Self {
458            user,
459            server,
460            agent: 0,
461            device: 0,
462            integrator: 0,
463        }
464    }
465
466    pub fn to_owned(&self) -> Jid {
467        Jid {
468            user: self.user.to_string(),
469            server: self.server.to_string(),
470            agent: self.agent,
471            device: self.device,
472            integrator: self.integrator,
473        }
474    }
475}
476
477impl FromStr for Jid {
478    type Err = JidError;
479    fn from_str(s: &str) -> Result<Self, Self::Err> {
480        // Try fast path first for well-formed JIDs
481        if let Some(parts) = parse_jid_fast(s) {
482            return Ok(Jid {
483                user: parts.user.to_string(),
484                server: parts.server.to_string(),
485                agent: parts.agent,
486                device: parts.device,
487                integrator: parts.integrator,
488            });
489        }
490
491        // Fallback to original parsing for edge cases and validation
492        // Keep server as &str to avoid allocation until we need it
493        let (user_part, server) = match s.split_once('@') {
494            Some((u, s)) => (u, s),
495            None => ("", s),
496        };
497
498        if user_part.is_empty() {
499            let known_servers = [
500                DEFAULT_USER_SERVER,
501                GROUP_SERVER,
502                LEGACY_USER_SERVER,
503                BROADCAST_SERVER,
504                HIDDEN_USER_SERVER,
505                NEWSLETTER_SERVER,
506                HOSTED_SERVER,
507                MESSENGER_SERVER,
508                INTEROP_SERVER,
509                BOT_SERVER,
510                STATUS_BROADCAST_USER,
511            ];
512            if !known_servers.contains(&server) {
513                return Err(JidError::InvalidFormat(format!(
514                    "Invalid JID format: unknown server '{}'",
515                    server
516                )));
517            }
518        }
519
520        // Special handling for LID JIDs, as their user part can contain dots
521        // that should not be interpreted as agent separators.
522        if server == HIDDEN_USER_SERVER {
523            let (user, device) = if let Some((u, d_str)) = user_part.rsplit_once(':') {
524                (u, d_str.parse()?)
525            } else {
526                (user_part, 0)
527            };
528            return Ok(Jid {
529                user: user.to_string(),
530                server: server.to_string(),
531                device,
532                agent: 0,
533                integrator: 0,
534            });
535        }
536
537        // Fallback to existing logic for other JID types (s.whatsapp.net, etc.)
538        let mut user = user_part;
539        let mut device = 0;
540        let mut agent = 0;
541
542        if let Some((u, d_str)) = user_part.rsplit_once(':') {
543            user = u;
544            device = d_str.parse()?;
545        }
546
547        if server != DEFAULT_USER_SERVER
548            && server != HIDDEN_USER_SERVER
549            && let Some((u, last_part)) = user.rsplit_once('.')
550            && let Ok(num_val) = last_part.parse::<u16>()
551        {
552            user = u;
553            agent = num_val as u8;
554        }
555
556        if let Some((u, last_part)) = user_part.rsplit_once('.')
557            && let Ok(num_val) = last_part.parse::<u16>()
558        {
559            if server == DEFAULT_USER_SERVER {
560                user = u;
561                device = num_val;
562            } else {
563                user = u;
564                if num_val > u8::MAX as u16 {
565                    return Err(JidError::InvalidFormat(format!(
566                        "Agent component out of range: {num_val}"
567                    )));
568                }
569                agent = num_val as u8;
570            }
571        }
572
573        Ok(Jid {
574            user: user.to_string(),
575            server: server.to_string(),
576            agent,
577            device,
578            integrator: 0,
579        })
580    }
581}
582
583impl fmt::Display for Jid {
584    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
585        if self.user.is_empty() {
586            // Server-only JID (e.g., "s.whatsapp.net") - no @ prefix
587            write!(f, "{}", self.server)
588        } else {
589            write!(f, "{}", self.user)?;
590
591            // The agent is encoded in the server type for AD JIDs.
592            // We should NOT append it to the user string for standard servers.
593            // Only non-standard servers might use an agent suffix.
594            // The old JS logic appears to never append the agent for s.whatsapp.net or lid.
595            if self.agent > 0 {
596                // This is a guess based on the failure. The old JS logic is complex.
597                // We will only append the agent if the server is NOT s.whatsapp.net or lid.
598                // AND the server is not one that is derived *from* the agent (like 'hosted').
599                let server_str = self.server(); // Use trait method
600                if server_str != DEFAULT_USER_SERVER
601                    && server_str != HIDDEN_USER_SERVER
602                    && server_str != HOSTED_SERVER
603                {
604                    write!(f, ".{}", self.agent)?;
605                }
606            }
607
608            if self.device > 0 {
609                write!(f, ":{}", self.device)?;
610            }
611
612            write!(f, "@{}", self.server)
613        }
614    }
615}
616
617impl<'a> fmt::Display for JidRef<'a> {
618    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
619        if self.user.is_empty() {
620            // Server-only JID (e.g., "s.whatsapp.net") - no @ prefix
621            write!(f, "{}", self.server)
622        } else {
623            write!(f, "{}", self.user)?;
624
625            // The agent is encoded in the server type for AD JIDs.
626            // We should NOT append it to the user string for standard servers.
627            // Only non-standard servers might use an agent suffix.
628            // The old JS logic appears to never append the agent for s.whatsapp.net or lid.
629            if self.agent > 0 {
630                // This is a guess based on the failure. The old JS logic is complex.
631                // We will only append the agent if the server is NOT s.whatsapp.net or lid.
632                // AND the server is not one that is derived *from* the agent (like 'hosted').
633                let server_str = self.server(); // Use trait method
634                if server_str != DEFAULT_USER_SERVER
635                    && server_str != HIDDEN_USER_SERVER
636                    && server_str != HOSTED_SERVER
637                {
638                    write!(f, ".{}", self.agent)?;
639                }
640            }
641
642            if self.device > 0 {
643                write!(f, ":{}", self.device)?;
644            }
645
646            write!(f, "@{}", self.server)
647        }
648    }
649}
650
651impl From<Jid> for String {
652    fn from(jid: Jid) -> Self {
653        jid.to_string()
654    }
655}
656
657impl<'a> From<JidRef<'a>> for String {
658    fn from(jid: JidRef<'a>) -> Self {
659        jid.to_string()
660    }
661}
662
663impl TryFrom<String> for Jid {
664    type Error = JidError;
665    fn try_from(value: String) -> Result<Self, Self::Error> {
666        Jid::from_str(&value)
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use std::str::FromStr;
674
675    /// Helper function to test a full parsing and display round-trip.
676    fn assert_jid_roundtrip(
677        input: &str,
678        expected_user: &str,
679        expected_server: &str,
680        expected_device: u16,
681        expected_agent: u8,
682    ) {
683        assert_jid_parse_and_display(
684            input,
685            expected_user,
686            expected_server,
687            expected_device,
688            expected_agent,
689            input,
690        );
691    }
692
693    /// Helper function to test parsing and display with a custom expected output.
694    fn assert_jid_parse_and_display(
695        input: &str,
696        expected_user: &str,
697        expected_server: &str,
698        expected_device: u16,
699        expected_agent: u8,
700        expected_output: &str,
701    ) {
702        // 1. Test parsing from string (FromStr trait)
703        let jid = Jid::from_str(input).unwrap_or_else(|_| panic!("Failed to parse JID: {}", input));
704
705        assert_eq!(
706            jid.user, expected_user,
707            "User part did not match for {}",
708            input
709        );
710        assert_eq!(
711            jid.server, expected_server,
712            "Server part did not match for {}",
713            input
714        );
715        assert_eq!(
716            jid.device, expected_device,
717            "Device part did not match for {}",
718            input
719        );
720        assert_eq!(
721            jid.agent, expected_agent,
722            "Agent part did not match for {}",
723            input
724        );
725
726        // 2. Test formatting back to string (Display trait)
727        let formatted = jid.to_string();
728        assert_eq!(
729            formatted, expected_output,
730            "Formatted string did not match expected output for {}",
731            input
732        );
733    }
734
735    #[test]
736    fn test_jid_parsing_and_display_roundtrip() {
737        // Standard cases
738        assert_jid_roundtrip(
739            "1234567890@s.whatsapp.net",
740            "1234567890",
741            "s.whatsapp.net",
742            0,
743            0,
744        );
745        assert_jid_roundtrip(
746            "1234567890:15@s.whatsapp.net",
747            "1234567890",
748            "s.whatsapp.net",
749            15,
750            0,
751        );
752        assert_jid_roundtrip("123-456@g.us", "123-456", "g.us", 0, 0);
753
754        // Server-only JID: parsing "s.whatsapp.net" should display as "s.whatsapp.net" (no @ prefix)
755        // This matches WhatsApp Web behavior where server-only JIDs don't have @ prefix
756        assert_jid_roundtrip("s.whatsapp.net", "", "s.whatsapp.net", 0, 0);
757
758        // LID JID cases (critical for the bug)
759        assert_jid_roundtrip("12345.6789@lid", "12345.6789", "lid", 0, 0);
760        assert_jid_roundtrip("12345.6789:25@lid", "12345.6789", "lid", 25, 0);
761    }
762
763    #[test]
764    fn test_special_from_str_parsing() {
765        // Test parsing of JIDs with an agent, which should be stored in the struct
766        let jid = Jid::from_str("1234567890.2:15@hosted").expect("test hosted JID should be valid");
767        assert_eq!(jid.user, "1234567890");
768        assert_eq!(jid.server, "hosted");
769        assert_eq!(jid.device, 15);
770        assert_eq!(jid.agent, 2);
771    }
772
773    #[test]
774    fn test_manual_jid_formatting_edge_cases() {
775        // This test directly validates the fixes for the parity failures.
776        // We manually construct the Jid struct as the binary decoder would,
777        // then we assert that its string representation is correct.
778
779        // Failure Case 1: An AD-JID for s.whatsapp.net decoded with an agent.
780        // The Display trait MUST NOT show the agent number.
781        let jid1 = Jid {
782            user: "1234567890".to_string(),
783            server: "s.whatsapp.net".to_string(),
784            device: 15,
785            agent: 2, // This agent would be decoded from binary but should be ignored in display
786            integrator: 0,
787        };
788        // Expected: "1234567890:15@s.whatsapp.net" (agent is omitted)
789        // Buggy: "1234567890.2:15@s.whatsapp.net"
790        assert_eq!(jid1.to_string(), "1234567890:15@s.whatsapp.net");
791
792        // Failure Case 2: A LID JID with a device, decoded with an agent.
793        // The Display trait MUST NOT show the agent number.
794        let jid2 = Jid {
795            user: "12345.6789".to_string(),
796            server: "lid".to_string(),
797            device: 25,
798            agent: 1, // This agent would be decoded from binary but should be ignored in display
799            integrator: 0,
800        };
801        // Expected: "12345.6789:25@lid"
802        // Buggy: "12345.6789.1:25@lid"
803        assert_eq!(jid2.to_string(), "12345.6789:25@lid");
804
805        // Failure Case 3: A JID that was decoded as "hosted" because of its agent.
806        // The Display trait MUST NOT show the agent number.
807        let jid3 = Jid {
808            user: "1234567890".to_string(),
809            server: "hosted".to_string(),
810            device: 15,
811            agent: 2,
812            integrator: 0,
813        };
814        // Expected: "1234567890:15@hosted"
815        // Buggy: "1234567890.2:15@hosted"
816        assert_eq!(jid3.to_string(), "1234567890:15@hosted");
817
818        // Verification Case: A generic JID where the agent SHOULD be displayed.
819        let jid4 = Jid {
820            user: "user".to_string(),
821            server: "custom.net".to_string(),
822            device: 10,
823            agent: 5,
824            integrator: 0,
825        };
826        // The agent should be displayed because the server is not a special AD-JID type
827        assert_eq!(jid4.to_string(), "user.5:10@custom.net");
828    }
829
830    #[test]
831    fn test_invalid_jids_should_fail_to_parse() {
832        assert!(Jid::from_str("thisisnotajid").is_err());
833        assert!(Jid::from_str("").is_err());
834        // "@s.whatsapp.net" is now valid - it's the protocol format for server-only JIDs
835        assert!(Jid::from_str("@s.whatsapp.net").is_ok());
836        // But "@unknown.server" should still fail
837        assert!(Jid::from_str("@unknown.server").is_err());
838        // Jid::from_str("2") should not be possible due to type constraints,
839        // but if it were, it should fail. The string must contain '@'.
840        assert!(Jid::from_str("2").is_err());
841    }
842
843    /// Tests for HOSTED device detection (`is_hosted()` method).
844    ///
845    /// # Context: What are HOSTED devices?
846    ///
847    /// HOSTED devices (also known as Cloud API or Meta Business API devices) are
848    /// WhatsApp Business accounts that use Meta's server-side infrastructure instead
849    /// of traditional end-to-end encryption with Signal protocol.
850    ///
851    /// ## Key characteristics:
852    /// - Device ID is always 99 (`:99`)
853    /// - Server is `@hosted` (phone-based) or `@hosted.lid` (LID-based)
854    /// - They do NOT use Signal protocol prekeys
855    /// - They should be EXCLUDED from group message fanout
856    /// - They CAN receive 1:1 messages (but prekey fetch will fail, causing graceful skip)
857    ///
858    /// ## Why exclude from groups?
859    /// WhatsApp Web explicitly filters hosted devices from group SKDM (Sender Key
860    /// Distribution Message) distribution. From WhatsApp Web JS (`getFanOutList`):
861    /// ```javascript
862    /// var isHosted = e.id === 99 || e.isHosted === true;
863    /// var includeInFanout = !isHosted || isOneToOneChat;
864    /// ```
865    ///
866    /// ## JID formats:
867    /// - Phone-based: `5511999887766:99@hosted`
868    /// - LID-based: `100000012345678:99@hosted.lid`
869    /// - Regular device with ID 99: `5511999887766:99@s.whatsapp.net` (also hosted!)
870    #[test]
871    fn test_is_hosted_device_detection() {
872        // === HOSTED DEVICES (should return true) ===
873
874        // Case 1: Device ID 99 on regular server (Cloud API business account)
875        // This is the most common case - a business using Meta's Cloud API
876        let cloud_api_device: Jid = "5511999887766:99@s.whatsapp.net"
877            .parse()
878            .expect("test JID should be valid");
879        assert!(
880            cloud_api_device.is_hosted(),
881            "Device ID 99 on s.whatsapp.net should be detected as hosted (Cloud API)"
882        );
883
884        // Case 2: Device ID 99 on LID server
885        let cloud_api_lid: Jid = "100000012345678:99@lid"
886            .parse()
887            .expect("test JID should be valid");
888        assert!(
889            cloud_api_lid.is_hosted(),
890            "Device ID 99 on lid server should be detected as hosted"
891        );
892
893        // Case 3: Explicit @hosted server (phone-based hosted JID)
894        let hosted_server: Jid = "5511999887766:99@hosted"
895            .parse()
896            .expect("test JID should be valid");
897        assert!(
898            hosted_server.is_hosted(),
899            "JID with @hosted server should be detected as hosted"
900        );
901
902        // Case 4: Explicit @hosted.lid server (LID-based hosted JID)
903        let hosted_lid_server: Jid = "100000012345678:99@hosted.lid"
904            .parse()
905            .expect("test JID should be valid");
906        assert!(
907            hosted_lid_server.is_hosted(),
908            "JID with @hosted.lid server should be detected as hosted"
909        );
910
911        // Case 5: @hosted server with different device ID (edge case)
912        // Even with device ID != 99, if server is @hosted, it's a hosted device
913        let hosted_server_other_device: Jid = "5511999887766:0@hosted"
914            .parse()
915            .expect("test JID should be valid");
916        assert!(
917            hosted_server_other_device.is_hosted(),
918            "JID with @hosted server should be hosted regardless of device ID"
919        );
920
921        // === NON-HOSTED DEVICES (should return false) ===
922
923        // Case 6: Regular phone device (primary phone, device 0)
924        let regular_phone: Jid = "5511999887766:0@s.whatsapp.net"
925            .parse()
926            .expect("test JID should be valid");
927        assert!(
928            !regular_phone.is_hosted(),
929            "Regular phone device (ID 0) should NOT be hosted"
930        );
931
932        // Case 7: Companion device (WhatsApp Web, device 33+)
933        let companion_device: Jid = "5511999887766:33@s.whatsapp.net"
934            .parse()
935            .expect("test JID should be valid");
936        assert!(
937            !companion_device.is_hosted(),
938            "Companion device (ID 33) should NOT be hosted"
939        );
940
941        // Case 8: Regular LID device
942        let regular_lid: Jid = "100000012345678:0@lid"
943            .parse()
944            .expect("test JID should be valid");
945        assert!(
946            !regular_lid.is_hosted(),
947            "Regular LID device should NOT be hosted"
948        );
949
950        // Case 9: LID companion device
951        let lid_companion: Jid = "100000012345678:33@lid"
952            .parse()
953            .expect("test JID should be valid");
954        assert!(
955            !lid_companion.is_hosted(),
956            "LID companion device (ID 33) should NOT be hosted"
957        );
958
959        // Case 10: Group JID (not a device at all)
960        let group_jid: Jid = "120363012345678@g.us"
961            .parse()
962            .expect("test JID should be valid");
963        assert!(
964            !group_jid.is_hosted(),
965            "Group JID should NOT be detected as hosted"
966        );
967
968        // Case 11: User JID without device
969        let user_jid: Jid = "5511999887766@s.whatsapp.net"
970            .parse()
971            .expect("test JID should be valid");
972        assert!(
973            !user_jid.is_hosted(),
974            "User JID without device should NOT be hosted"
975        );
976
977        // Case 12: Bot device
978        let bot_jid: Jid = "13136555001:0@s.whatsapp.net"
979            .parse()
980            .expect("test JID should be valid");
981        assert!(
982            !bot_jid.is_hosted(),
983            "Bot JID should NOT be detected as hosted (different mechanism)"
984        );
985    }
986
987    /// Tests that document the filtering behavior for group messages.
988    ///
989    /// # Why this matters:
990    /// When sending a group message, we distribute Sender Key Distribution Messages
991    /// (SKDM) to all participant devices. However, HOSTED devices:
992    /// 1. Don't use Signal protocol, so they can't process SKDM
993    /// 2. WhatsApp Web explicitly excludes them from group fanout
994    /// 3. Including them would cause unnecessary prekey fetch failures
995    ///
996    /// This test documents the expected behavior when filtering device lists.
997    #[test]
998    fn test_hosted_device_filtering_for_groups() {
999        // Simulate a group with mixed device types
1000        let devices: Vec<Jid> = vec![
1001            // Regular devices that SHOULD receive SKDM
1002            "5511999887766:0@s.whatsapp.net"
1003                .parse()
1004                .expect("test JID should be valid"), // Phone
1005            "5511999887766:33@s.whatsapp.net"
1006                .parse()
1007                .expect("test JID should be valid"), // WhatsApp Web
1008            "5521988776655:0@s.whatsapp.net"
1009                .parse()
1010                .expect("test JID should be valid"), // Another user's phone
1011            "100000012345678:0@lid"
1012                .parse()
1013                .expect("test JID should be valid"), // LID device
1014            "100000012345678:33@lid"
1015                .parse()
1016                .expect("test JID should be valid"), // LID companion
1017            // HOSTED devices that should be EXCLUDED from group SKDM
1018            "5531977665544:99@s.whatsapp.net"
1019                .parse()
1020                .expect("test JID should be valid"), // Cloud API business
1021            "100000087654321:99@lid"
1022                .parse()
1023                .expect("test JID should be valid"), // Cloud API on LID
1024            "5541966554433:99@hosted"
1025                .parse()
1026                .expect("test JID should be valid"), // Explicit hosted
1027        ];
1028
1029        // Filter out hosted devices (this is what prepare_group_stanza does)
1030        let filtered: Vec<&Jid> = devices.iter().filter(|jid| !jid.is_hosted()).collect();
1031
1032        // Verify correct filtering
1033        assert_eq!(
1034            filtered.len(),
1035            5,
1036            "Should have 5 non-hosted devices after filtering"
1037        );
1038
1039        // All filtered devices should NOT be hosted
1040        for jid in &filtered {
1041            assert!(
1042                !jid.is_hosted(),
1043                "Filtered list should not contain hosted devices: {}",
1044                jid
1045            );
1046        }
1047
1048        // Count how many hosted devices were filtered out
1049        let hosted_count = devices.iter().filter(|jid| jid.is_hosted()).count();
1050        assert_eq!(hosted_count, 3, "Should have filtered out 3 hosted devices");
1051    }
1052
1053    #[test]
1054    fn test_jid_pn_factory() {
1055        let jid = Jid::pn("1234567890");
1056        assert_eq!(jid.user, "1234567890");
1057        assert_eq!(jid.server, DEFAULT_USER_SERVER);
1058        assert_eq!(jid.device, 0);
1059        assert!(jid.is_pn());
1060    }
1061
1062    #[test]
1063    fn test_jid_lid_factory() {
1064        let jid = Jid::lid("100000012345678");
1065        assert_eq!(jid.user, "100000012345678");
1066        assert_eq!(jid.server, HIDDEN_USER_SERVER);
1067        assert_eq!(jid.device, 0);
1068        assert!(jid.is_lid());
1069    }
1070
1071    #[test]
1072    fn test_jid_group_factory() {
1073        let jid = Jid::group("123456789-1234567890");
1074        assert_eq!(jid.user, "123456789-1234567890");
1075        assert_eq!(jid.server, GROUP_SERVER);
1076        assert!(jid.is_group());
1077    }
1078
1079    #[test]
1080    fn test_jid_pn_device_factory() {
1081        let jid = Jid::pn_device("1234567890", 5);
1082        assert_eq!(jid.user, "1234567890");
1083        assert_eq!(jid.server, DEFAULT_USER_SERVER);
1084        assert_eq!(jid.device, 5);
1085        assert!(jid.is_pn());
1086        assert!(jid.is_ad());
1087    }
1088
1089    #[test]
1090    fn test_jid_lid_device_factory() {
1091        let jid = Jid::lid_device("100000012345678", 33);
1092        assert_eq!(jid.user, "100000012345678");
1093        assert_eq!(jid.server, HIDDEN_USER_SERVER);
1094        assert_eq!(jid.device, 33);
1095        assert!(jid.is_lid());
1096        assert!(jid.is_ad());
1097    }
1098
1099    #[test]
1100    fn test_jid_factories_with_string_types() {
1101        // Test with &str
1102        let jid1 = Jid::pn("123");
1103        assert_eq!(jid1.user, "123");
1104
1105        // Test with String
1106        let jid2 = Jid::lid(String::from("456"));
1107        assert_eq!(jid2.user, "456");
1108
1109        // Test with owned String
1110        let user = "789".to_string();
1111        let jid3 = Jid::group(user);
1112        assert_eq!(jid3.user, "789");
1113    }
1114}