Skip to main content

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