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