Skip to main content

wacore_binary/
jid.rs

1use crate::node::NodeStr;
2use compact_str::CompactString;
3use std::fmt;
4use std::str::FromStr;
5
6/// Intermediate result from fast JID parsing.
7/// This avoids allocations by returning byte indices into the original string.
8#[derive(Debug, Clone, Copy)]
9pub struct ParsedJidParts<'a> {
10    pub user: &'a str,
11    pub server: &'a str,
12    pub agent: u8,
13    pub device: u16,
14    pub integrator: u16,
15}
16
17/// Single-pass JID parser optimized for hot paths.
18/// Scans the input string once to find all relevant separators (@, :)
19/// and returns slices into the original string without allocation.
20///
21/// Returns `None` for JIDs that need full validation (edge cases, unknown servers, etc.)
22#[inline]
23pub fn parse_jid_fast(s: &str) -> Option<ParsedJidParts<'_>> {
24    if s.is_empty() {
25        return None;
26    }
27
28    let bytes = s.as_bytes();
29
30    // Single pass to find key separator positions
31    let mut at_pos: Option<usize> = None;
32    let mut colon_pos: Option<usize> = None;
33    let mut last_dot_pos: Option<usize> = None;
34
35    for (i, &b) in bytes.iter().enumerate() {
36        match b {
37            b'@' if at_pos.is_none() => at_pos = Some(i),
38            // Only track colon in user part (before @)
39            b':' if at_pos.is_none() => colon_pos = Some(i),
40            // Only track dots in user part (before @ and before :)
41            b'.' if at_pos.is_none() && colon_pos.is_none() => last_dot_pos = Some(i),
42            _ => {}
43        }
44    }
45
46    // Extract at_pos as concrete value - after this point we know @ exists
47    let at = match at_pos {
48        Some(pos) => pos,
49        None => {
50            // Server-only JID - let the fallback validate it
51            return None;
52        }
53    };
54
55    let user_part = &s[..at];
56    let server = &s[at + 1..];
57
58    // Validate that user_part is not empty
59    if user_part.is_empty() {
60        return None;
61    }
62
63    // Fast path for LID JIDs - dots in user are not agent separators
64    if server == HIDDEN_USER_SERVER {
65        let (user, device) = match colon_pos {
66            Some(pos) if pos < at => {
67                let device_slice = &s[pos + 1..at];
68                (&s[..pos], device_slice.parse::<u16>().unwrap_or(0))
69            }
70            _ => (user_part, 0),
71        };
72        return Some(ParsedJidParts {
73            user,
74            server,
75            agent: 0,
76            device,
77            integrator: 0,
78        });
79    }
80
81    // For DEFAULT_USER_SERVER (s.whatsapp.net), handle legacy dot format as device
82    if server == DEFAULT_USER_SERVER {
83        // Check for colon format first (modern: user:device@server)
84        if let Some(pos) = colon_pos {
85            let user_end = pos;
86            let device_start = pos + 1;
87            let device_slice = &s[device_start..at];
88            let device = device_slice.parse::<u16>().unwrap_or(0);
89            return Some(ParsedJidParts {
90                user: &s[..user_end],
91                server,
92                agent: 0,
93                device,
94                integrator: 0,
95            });
96        }
97        // Check for legacy dot format (legacy: user.device@server)
98        if let Some(dot_pos) = last_dot_pos {
99            // dot_pos is absolute position in s
100            let suffix = &s[dot_pos + 1..at];
101            if let Ok(device_val) = suffix.parse::<u16>() {
102                return Some(ParsedJidParts {
103                    user: &s[..dot_pos],
104                    server,
105                    agent: 0,
106                    device: device_val,
107                    integrator: 0,
108                });
109            }
110        }
111        // No device component
112        return Some(ParsedJidParts {
113            user: user_part,
114            server,
115            agent: 0,
116            device: 0,
117            integrator: 0,
118        });
119    }
120
121    // Parse device from colon separator (user:device@server)
122    let (user_before_colon, device) = match colon_pos {
123        Some(pos) => {
124            // Colon is at `pos` in the original string
125            let user_end = pos;
126            let device_start = pos + 1;
127            let device_slice = &s[device_start..at];
128            (&s[..user_end], device_slice.parse::<u16>().unwrap_or(0))
129        }
130        None => (user_part, 0),
131    };
132
133    // Parse agent from last dot in user part (for non-default, non-LID servers)
134    let user_to_check = user_before_colon;
135    let (final_user, agent) = {
136        if let Some(dot_pos) = user_to_check.rfind('.') {
137            let suffix = &user_to_check[dot_pos + 1..];
138            if let Ok(agent_val) = suffix.parse::<u16>() {
139                if agent_val <= u8::MAX as u16 {
140                    (&user_to_check[..dot_pos], agent_val as u8)
141                } else {
142                    (user_to_check, 0)
143                }
144            } else {
145                (user_to_check, 0)
146            }
147        } else {
148            (user_to_check, 0)
149        }
150    };
151
152    Some(ParsedJidParts {
153        user: final_user,
154        server,
155        agent,
156        device,
157        integrator: 0,
158    })
159}
160
161/// Known WhatsApp server identifiers.
162///
163/// Maps to the wire protocol's AD_JID domain type (u8) and the `@server` suffix
164/// in JID string representation.
165#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
166#[repr(u8)]
167pub enum Server {
168    #[default]
169    Pn = 0,
170    Lid = 1,
171    Group = 2,
172    Broadcast = 3,
173    Newsletter = 4,
174    Hosted = 5,
175    HostedLid = 6,
176    Messenger = 7,
177    Interop = 8,
178    Bot = 9,
179    Legacy = 10,
180}
181
182#[cfg(feature = "serde")]
183impl serde::Serialize for Server {
184    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
185        serializer.serialize_str(self.as_str())
186    }
187}
188
189#[cfg(feature = "serde")]
190impl<'de> serde::Deserialize<'de> for Server {
191    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
192        let s = <&str>::deserialize(deserializer)?;
193        Server::try_from(s).map_err(serde::de::Error::custom)
194    }
195}
196
197impl Server {
198    #[inline]
199    pub fn as_str(self) -> &'static str {
200        match self {
201            Self::Pn => "s.whatsapp.net",
202            Self::Lid => "lid",
203            Self::Group => "g.us",
204            Self::Broadcast => "broadcast",
205            Self::Newsletter => "newsletter",
206            Self::Hosted => "hosted",
207            Self::HostedLid => "hosted.lid",
208            Self::Messenger => "msgr",
209            Self::Interop => "interop",
210            Self::Bot => "bot",
211            Self::Legacy => "c.us",
212        }
213    }
214
215    /// Phone-number-namespaced servers (`@s.whatsapp.net`, `@hosted`).
216    /// The PN side of the LID↔PN mapping treats these as a single class.
217    #[inline]
218    pub fn is_pn_family(self) -> bool {
219        matches!(self, Self::Pn | Self::Hosted)
220    }
221
222    /// LID-namespaced servers (`@lid`, `@hosted.lid`).
223    #[inline]
224    pub fn is_lid_family(self) -> bool {
225        matches!(self, Self::Lid | Self::HostedLid)
226    }
227}
228
229impl fmt::Display for Server {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        f.write_str(self.as_str())
232    }
233}
234
235impl PartialEq<str> for Server {
236    fn eq(&self, other: &str) -> bool {
237        self.as_str() == other
238    }
239}
240
241impl PartialEq<&str> for Server {
242    fn eq(&self, other: &&str) -> bool {
243        self.as_str() == *other
244    }
245}
246
247impl TryFrom<&str> for Server {
248    type Error = JidError;
249    fn try_from(s: &str) -> std::result::Result<Self, Self::Error> {
250        match s {
251            "s.whatsapp.net" => Ok(Self::Pn),
252            "lid" => Ok(Self::Lid),
253            "g.us" => Ok(Self::Group),
254            "broadcast" => Ok(Self::Broadcast),
255            "newsletter" => Ok(Self::Newsletter),
256            "hosted" => Ok(Self::Hosted),
257            "hosted.lid" => Ok(Self::HostedLid),
258            "msgr" => Ok(Self::Messenger),
259            "interop" => Ok(Self::Interop),
260            "bot" => Ok(Self::Bot),
261            "c.us" => Ok(Self::Legacy),
262            other => Err(JidError::InvalidFormat(format!("unknown server: {other}"))),
263        }
264    }
265}
266
267// Keep string constants for backward compatibility and use in non-JID contexts
268pub const DEFAULT_USER_SERVER: &str = "s.whatsapp.net";
269pub const SERVER_JID: &str = "s.whatsapp.net";
270pub const GROUP_SERVER: &str = "g.us";
271pub const LEGACY_USER_SERVER: &str = "c.us";
272pub const BROADCAST_SERVER: &str = "broadcast";
273pub const HIDDEN_USER_SERVER: &str = "lid";
274pub const NEWSLETTER_SERVER: &str = "newsletter";
275pub const HOSTED_SERVER: &str = "hosted";
276pub const HOSTED_LID_SERVER: &str = "hosted.lid";
277pub const MESSENGER_SERVER: &str = "msgr";
278pub const INTEROP_SERVER: &str = "interop";
279pub const BOT_SERVER: &str = "bot";
280pub const STATUS_BROADCAST_USER: &str = "status";
281
282pub type MessageId = String;
283pub type MessageServerId = i32;
284#[derive(Debug)]
285pub enum JidError {
286    // REMOVE: #[error("...")]
287    InvalidFormat(String),
288    // REMOVE: #[error("...")]
289    Parse(std::num::ParseIntError),
290}
291
292impl fmt::Display for JidError {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        match self {
295            JidError::InvalidFormat(s) => write!(f, "Invalid JID format: {s}"),
296            JidError::Parse(e) => write!(f, "Failed to parse component: {e}"),
297        }
298    }
299}
300
301impl std::error::Error for JidError {
302    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
303        match self {
304            JidError::Parse(e) => Some(e),
305            _ => None,
306        }
307    }
308}
309
310// Add From impl
311impl From<std::num::ParseIntError> for JidError {
312    fn from(err: std::num::ParseIntError) -> Self {
313        JidError::Parse(err)
314    }
315}
316
317pub trait JidExt {
318    fn user(&self) -> &str;
319    fn server(&self) -> Server;
320    fn device(&self) -> u16;
321    fn integrator(&self) -> u16;
322
323    fn is_ad(&self) -> bool {
324        self.device() > 0
325            && matches!(
326                self.server(),
327                Server::Pn | Server::Lid | Server::Hosted | Server::HostedLid
328            )
329    }
330
331    fn is_interop(&self) -> bool {
332        self.server() == Server::Interop && self.integrator() > 0
333    }
334
335    fn is_messenger(&self) -> bool {
336        self.server() == Server::Messenger && self.device() > 0
337    }
338
339    fn is_group(&self) -> bool {
340        self.server() == Server::Group
341    }
342
343    fn is_broadcast_list(&self) -> bool {
344        self.server() == Server::Broadcast && self.user() != STATUS_BROADCAST_USER
345    }
346
347    fn is_status_broadcast(&self) -> bool {
348        self.server() == Server::Broadcast && self.user() == STATUS_BROADCAST_USER
349    }
350
351    fn is_bot(&self) -> bool {
352        (self.server() == Server::Pn
353            && self.device() == 0
354            && (self.user().starts_with("1313555") || self.user().starts_with("131655500")))
355            || self.server() == Server::Bot
356    }
357
358    fn is_newsletter(&self) -> bool {
359        self.server() == Server::Newsletter
360    }
361
362    /// Returns true if this is a hosted/Cloud API device.
363    /// Hosted devices have device ID 99 or use @hosted/@hosted.lid server.
364    /// These devices should be excluded from group message fanout.
365    fn is_hosted(&self) -> bool {
366        self.device() == 99 || matches!(self.server(), Server::Hosted | Server::HostedLid)
367    }
368
369    fn is_empty(&self) -> bool {
370        self.user().is_empty()
371    }
372
373    fn is_same_user_as(&self, other: &impl JidExt) -> bool {
374        self.user() == other.user()
375    }
376}
377
378#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
379#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
380pub struct Jid {
381    pub user: CompactString,
382    pub server: Server,
383    pub agent: u8,
384    pub device: u16,
385    pub integrator: u16,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq, Hash, yoke::Yokeable)]
389pub struct JidRef<'a> {
390    pub user: NodeStr<'a>,
391    pub server: Server,
392    pub agent: u8,
393    pub device: u16,
394    pub integrator: u16,
395}
396
397impl JidExt for Jid {
398    fn user(&self) -> &str {
399        &self.user
400    }
401    fn server(&self) -> Server {
402        self.server
403    }
404    fn device(&self) -> u16 {
405        self.device
406    }
407    fn integrator(&self) -> u16 {
408        self.integrator
409    }
410}
411
412impl Jid {
413    pub fn new(user: impl Into<CompactString>, server: Server) -> Self {
414        Self {
415            user: user.into(),
416            server,
417            ..Default::default()
418        }
419    }
420
421    /// Create a phone number JID (s.whatsapp.net)
422    pub fn pn(user: impl Into<CompactString>) -> Self {
423        Self {
424            user: user.into(),
425            server: Server::Pn,
426            ..Default::default()
427        }
428    }
429
430    /// Create a LID JID (lid server)
431    pub fn lid(user: impl Into<CompactString>) -> Self {
432        Self {
433            user: user.into(),
434            server: Server::Lid,
435            ..Default::default()
436        }
437    }
438
439    /// Creates the `status@broadcast` JID used for status/story updates.
440    pub fn status_broadcast() -> Self {
441        Self {
442            user: CompactString::from(STATUS_BROADCAST_USER),
443            server: Server::Broadcast,
444            agent: 0,
445            device: 0,
446            integrator: 0,
447        }
448    }
449
450    /// Create a group JID (g.us).
451    pub fn group(id: impl Into<CompactString>) -> Self {
452        Self {
453            user: id.into(),
454            server: Server::Group,
455            ..Default::default()
456        }
457    }
458
459    /// Create a newsletter (channel) JID (newsletter server).
460    pub fn newsletter(id: impl Into<CompactString>) -> Self {
461        Self {
462            user: id.into(),
463            server: Server::Newsletter,
464            ..Default::default()
465        }
466    }
467
468    /// Create a phone number JID with device ID
469    pub fn pn_device(user: impl Into<CompactString>, device: u16) -> Self {
470        Self {
471            user: user.into(),
472            server: Server::Pn,
473            device,
474            ..Default::default()
475        }
476    }
477
478    /// Create a LID JID with device ID
479    pub fn lid_device(user: impl Into<CompactString>, device: u16) -> Self {
480        Self {
481            user: user.into(),
482            server: Server::Lid,
483            device,
484            ..Default::default()
485        }
486    }
487
488    /// Returns true if this is a Phone Number based JID (s.whatsapp.net)
489    #[inline]
490    pub fn is_pn(&self) -> bool {
491        self.server == Server::Pn
492    }
493
494    /// Returns true if this is a LID based JID
495    #[inline]
496    pub fn is_lid(&self) -> bool {
497        self.server == Server::Lid
498    }
499
500    /// Returns the user part without the device ID suffix (e.g., "123:4" -> "123")
501    #[inline]
502    pub fn user_base(&self) -> &str {
503        if let Some((base, _)) = self.user.split_once(':') {
504            base
505        } else {
506            &self.user
507        }
508    }
509
510    /// Helper to construct a specific device JID from this one
511    pub fn with_device(&self, device_id: u16) -> Self {
512        Self {
513            user: self.user.clone(),
514            server: self.server,
515            agent: self.agent,
516            device: device_id,
517            integrator: self.integrator,
518        }
519    }
520
521    pub fn to_non_ad(&self) -> Self {
522        Self {
523            user: self.user.clone(),
524            server: self.server,
525            integrator: self.integrator,
526            ..Default::default()
527        }
528    }
529
530    /// Check if this JID matches the user or their LID.
531    /// Useful for checking if a participant is "us" in group messages.
532    #[inline]
533    pub fn matches_user_or_lid(&self, user: &Jid, lid: Option<&Jid>) -> bool {
534        self.is_same_user_as(user) || lid.is_some_and(|l| self.is_same_user_as(l))
535    }
536
537    /// Normalize the JID for use in pre-key bundle storage and lookup.
538    ///
539    /// WhatsApp servers may return JIDs with varied agent fields, or we might derive them
540    /// with agent fields in some contexts. However, pre-key bundles are stored and looked up
541    /// using a normalized key where the agent is 0 for standard servers (s.whatsapp.net, lid).
542    pub fn normalize_for_prekey_bundle(&self) -> Self {
543        let mut jid = self.clone();
544        if matches!(jid.server, Server::Pn | Server::Lid) {
545            jid.agent = 0;
546        }
547        jid
548    }
549
550    pub fn to_ad_string(&self) -> String {
551        if self.user.is_empty() {
552            return self.server.as_str().to_string();
553        }
554        let mut s = String::with_capacity(self.user.len() + 20);
555        s.push_str(&self.user);
556        s.push('.');
557        s.push_str(itoa::Buffer::new().format(self.agent));
558        s.push(':');
559        s.push_str(itoa::Buffer::new().format(self.device));
560        s.push('@');
561        s.push_str(self.server.as_str());
562        s
563    }
564
565    /// Append the Display representation to `buf` using direct push operations,
566    /// bypassing `fmt::Display` and `dyn Write` dispatch.
567    #[inline]
568    pub fn push_to(&self, buf: &mut String) {
569        push_jid_to_string(&self.user, self.server, self.agent, self.device, buf);
570    }
571
572    /// Compare device identity (user, server, device) without allocation.
573    #[inline]
574    pub fn device_eq(&self, other: &Jid) -> bool {
575        self.user == other.user && self.server == other.server && self.device == other.device
576    }
577
578    /// Get a borrowing key for O(1) HashSet lookups by device identity.
579    #[inline]
580    pub fn device_key(&self) -> DeviceKey<'_> {
581        DeviceKey {
582            user: &self.user,
583            server: self.server,
584            device: self.device,
585        }
586    }
587}
588
589/// Borrowing key for device identity (user, server, device). Use with HashSet for O(1) lookups.
590#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
591pub struct DeviceKey<'a> {
592    pub user: &'a str,
593    pub server: Server,
594    pub device: u16,
595}
596
597impl<'a> JidExt for JidRef<'a> {
598    fn user(&self) -> &str {
599        &self.user
600    }
601    fn server(&self) -> Server {
602        self.server
603    }
604    fn device(&self) -> u16 {
605        self.device
606    }
607    fn integrator(&self) -> u16 {
608        self.integrator
609    }
610}
611
612impl<'a> JidRef<'a> {
613    pub fn to_owned(&self) -> Jid {
614        Jid {
615            user: self.user.to_compact_string(),
616            server: self.server,
617            agent: self.agent,
618            device: self.device,
619            integrator: self.integrator,
620        }
621    }
622}
623
624#[cfg(feature = "serde")]
625impl serde::Serialize for JidRef<'_> {
626    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
627        use serde::ser::SerializeStruct;
628        let mut s = serializer.serialize_struct("Jid", 5)?;
629        s.serialize_field("user", &*self.user)?;
630        s.serialize_field("server", &self.server)?;
631        s.serialize_field("agent", &self.agent)?;
632        s.serialize_field("device", &self.device)?;
633        s.serialize_field("integrator", &self.integrator)?;
634        s.end()
635    }
636}
637
638impl FromStr for Jid {
639    type Err = JidError;
640    fn from_str(s: &str) -> Result<Self, Self::Err> {
641        // Try fast path first for well-formed JIDs
642        if let Some(parts) = parse_jid_fast(s) {
643            return Ok(Jid {
644                user: CompactString::from(parts.user),
645                server: Server::try_from(parts.server)?,
646                agent: parts.agent,
647                device: parts.device,
648                integrator: parts.integrator,
649            });
650        }
651
652        // Fallback to original parsing for edge cases and validation
653        // Keep server as &str to avoid allocation until we need it
654        let (user_part, server) = match s.split_once('@') {
655            Some((u, s)) => (u, s),
656            None => ("", s),
657        };
658
659        if user_part.is_empty() && Server::try_from(server).is_err() {
660            return Err(JidError::InvalidFormat(format!(
661                "unknown server '{server}'"
662            )));
663        }
664
665        // Special handling for LID JIDs, as their user part can contain dots
666        // that should not be interpreted as agent separators.
667        if server == HIDDEN_USER_SERVER {
668            let (user, device) = if let Some((u, d_str)) = user_part.rsplit_once(':') {
669                (u, d_str.parse()?)
670            } else {
671                (user_part, 0)
672            };
673            return Ok(Jid {
674                user: CompactString::from(user),
675                server: Server::try_from(server)?,
676                device,
677                agent: 0,
678                integrator: 0,
679            });
680        }
681
682        // Fallback to existing logic for other JID types (s.whatsapp.net, etc.)
683        let mut user = user_part;
684        let mut device = 0;
685        let mut agent = 0;
686
687        if let Some((u, d_str)) = user_part.rsplit_once(':') {
688            user = u;
689            device = d_str.parse()?;
690        }
691
692        if server != DEFAULT_USER_SERVER
693            && server != HIDDEN_USER_SERVER
694            && let Some((u, last_part)) = user.rsplit_once('.')
695            && let Ok(num_val) = last_part.parse::<u16>()
696        {
697            if num_val > u8::MAX as u16 {
698                return Err(JidError::InvalidFormat(format!(
699                    "Agent component out of range: {num_val}"
700                )));
701            }
702            user = u;
703            agent = num_val as u8;
704        }
705
706        Ok(Jid {
707            user: CompactString::from(user),
708            server: Server::try_from(server)?,
709            agent,
710            device,
711            integrator: 0,
712        })
713    }
714}
715
716/// Core JID formatting logic used by `fmt::Display`, `push_jid_to_string`, and
717/// `push_jid_to_compact`. Writes `{user}[.{agent}][:{device}]@{server}`.
718///
719/// Two flavors via `$append`:
720/// - **fallible** (`f.write_str(s)?`): for `fmt::Formatter` which returns `fmt::Result`
721/// - **infallible** (`$buf.push_str(s)`): for `String`/`CompactString`
722macro_rules! write_jid {
723    // Infallible variant: push_str/push into a growable buffer
724    (infallible $buf:expr, $user:expr, $server:expr, $agent:expr, $device:expr) => {{
725        let (user, server, agent, device) = ($user, $server, $agent, $device);
726        if user.is_empty() {
727            $buf.push_str(server.as_str());
728            return;
729        }
730        $buf.push_str(user);
731        if agent > 0
732            && !matches!(
733                server,
734                Server::Pn | Server::Lid | Server::Hosted | Server::HostedLid
735            )
736        {
737            $buf.push('.');
738            $buf.push_str(itoa::Buffer::new().format(agent));
739        }
740        if device > 0 {
741            $buf.push(':');
742            $buf.push_str(itoa::Buffer::new().format(device));
743        }
744        $buf.push('@');
745        $buf.push_str(server.as_str());
746    }};
747    // Fallible variant: write_str into fmt::Formatter
748    (fallible $f:expr, $user:expr, $server:expr, $agent:expr, $device:expr) => {{
749        let (user, server, agent, device) = ($user, $server, $agent, $device);
750        if user.is_empty() {
751            return $f.write_str(server.as_str());
752        }
753        $f.write_str(user)?;
754        if agent > 0
755            && !matches!(
756                server,
757                Server::Pn | Server::Lid | Server::Hosted | Server::HostedLid
758            )
759        {
760            $f.write_str(".")?;
761            $f.write_str(itoa::Buffer::new().format(agent))?;
762        }
763        if device > 0 {
764            $f.write_str(":")?;
765            $f.write_str(itoa::Buffer::new().format(device))?;
766        }
767        $f.write_str("@")?;
768        $f.write_str(server.as_str())
769    }};
770}
771
772/// Write the JID display representation directly into a `String`,
773/// bypassing `fmt::Display` and `dyn Write` dispatch entirely.
774#[inline]
775pub fn push_jid_to_string(user: &str, server: Server, agent: u8, device: u16, buf: &mut String) {
776    write_jid!(infallible buf, user, server, agent, device);
777}
778
779/// Write the JID display representation directly into a `CompactString`,
780/// bypassing `fmt::Display` and `dyn Write` dispatch entirely.
781#[inline]
782pub fn push_jid_to_compact(
783    user: &str,
784    server: Server,
785    agent: u8,
786    device: u16,
787    buf: &mut CompactString,
788) {
789    write_jid!(infallible buf, user, server, agent, device);
790}
791
792impl fmt::Display for Jid {
793    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
794        write_jid!(fallible f, &*self.user, self.server, self.agent, self.device)
795    }
796}
797
798impl<'a> fmt::Display for JidRef<'a> {
799    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
800        write_jid!(fallible f, &*self.user, self.server, self.agent, self.device)
801    }
802}
803
804impl From<Jid> for String {
805    fn from(jid: Jid) -> Self {
806        jid.to_string()
807    }
808}
809
810impl<'a> From<JidRef<'a>> for String {
811    fn from(jid: JidRef<'a>) -> Self {
812        jid.to_string()
813    }
814}
815
816impl TryFrom<String> for Jid {
817    type Error = JidError;
818    fn try_from(value: String) -> Result<Self, Self::Error> {
819        Jid::from_str(&value)
820    }
821}
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826    use std::str::FromStr;
827
828    /// Helper function to test a full parsing and display round-trip.
829    fn assert_jid_roundtrip(
830        input: &str,
831        expected_user: &str,
832        expected_server: &str,
833        expected_device: u16,
834        expected_agent: u8,
835    ) {
836        assert_jid_parse_and_display(
837            input,
838            expected_user,
839            expected_server,
840            expected_device,
841            expected_agent,
842            input,
843        );
844    }
845
846    /// Helper function to test parsing and display with a custom expected output.
847    fn assert_jid_parse_and_display(
848        input: &str,
849        expected_user: &str,
850        expected_server: &str,
851        expected_device: u16,
852        expected_agent: u8,
853        expected_output: &str,
854    ) {
855        // 1. Test parsing from string (FromStr trait)
856        let jid = Jid::from_str(input).unwrap_or_else(|_| panic!("Failed to parse JID: {}", input));
857
858        assert_eq!(
859            jid.user, expected_user,
860            "User part did not match for {}",
861            input
862        );
863        assert_eq!(
864            jid.server, expected_server,
865            "Server part did not match for {}",
866            input
867        );
868        assert_eq!(
869            jid.device, expected_device,
870            "Device part did not match for {}",
871            input
872        );
873        assert_eq!(
874            jid.agent, expected_agent,
875            "Agent part did not match for {}",
876            input
877        );
878
879        // 2. Test formatting back to string (Display trait)
880        let formatted = jid.to_string();
881        assert_eq!(
882            formatted, expected_output,
883            "Formatted string did not match expected output for {}",
884            input
885        );
886    }
887
888    #[test]
889    fn test_jid_parsing_and_display_roundtrip() {
890        // Standard cases
891        assert_jid_roundtrip(
892            "1234567890@s.whatsapp.net",
893            "1234567890",
894            "s.whatsapp.net",
895            0,
896            0,
897        );
898        assert_jid_roundtrip(
899            "1234567890:15@s.whatsapp.net",
900            "1234567890",
901            "s.whatsapp.net",
902            15,
903            0,
904        );
905        assert_jid_roundtrip("123-456@g.us", "123-456", "g.us", 0, 0);
906
907        // Server-only JID: parsing "s.whatsapp.net" should display as "s.whatsapp.net" (no @ prefix)
908        // This matches WhatsApp Web behavior where server-only JIDs don't have @ prefix
909        assert_jid_roundtrip("s.whatsapp.net", "", "s.whatsapp.net", 0, 0);
910
911        // LID JID cases (critical for the bug)
912        assert_jid_roundtrip("12345.6789@lid", "12345.6789", "lid", 0, 0);
913        assert_jid_roundtrip("12345.6789:25@lid", "12345.6789", "lid", 25, 0);
914    }
915
916    #[test]
917    fn test_special_from_str_parsing() {
918        // Test parsing of JIDs with an agent, which should be stored in the struct
919        let jid = Jid::from_str("1234567890.2:15@hosted").expect("test hosted JID should be valid");
920        assert_eq!(jid.user, "1234567890");
921        assert_eq!(jid.server, "hosted");
922        assert_eq!(jid.device, 15);
923        assert_eq!(jid.agent, 2);
924    }
925
926    #[test]
927    fn test_manual_jid_formatting_edge_cases() {
928        // This test directly validates the fixes for the parity failures.
929        // We manually construct the Jid struct as the binary decoder would,
930        // then we assert that its string representation is correct.
931
932        // Failure Case 1: An AD-JID for s.whatsapp.net decoded with an agent.
933        // The Display trait MUST NOT show the agent number.
934        let jid1 = Jid {
935            user: "1234567890".into(),
936            server: Server::Pn,
937            device: 15,
938            agent: 2,
939            integrator: 0,
940        };
941        assert_eq!(jid1.to_string(), "1234567890:15@s.whatsapp.net");
942
943        let jid2 = Jid {
944            user: "12345.6789".into(),
945            server: Server::Lid,
946            device: 25,
947            agent: 1,
948            integrator: 0,
949        };
950        assert_eq!(jid2.to_string(), "12345.6789:25@lid");
951
952        let jid3 = Jid {
953            user: "1234567890".into(),
954            server: Server::Hosted,
955            device: 15,
956            agent: 2,
957            integrator: 0,
958        };
959        assert_eq!(jid3.to_string(), "1234567890:15@hosted");
960
961        // Agent SHOULD be displayed for non-AD servers (e.g., bot, interop)
962        let jid4 = Jid {
963            user: "user".into(),
964            server: Server::Bot,
965            device: 10,
966            agent: 5,
967            integrator: 0,
968        };
969        assert_eq!(jid4.to_string(), "user.5:10@bot");
970    }
971
972    #[test]
973    fn test_invalid_jids_should_fail_to_parse() {
974        assert!(Jid::from_str("thisisnotajid").is_err());
975        assert!(Jid::from_str("").is_err());
976        // "@s.whatsapp.net" is now valid - it's the protocol format for server-only JIDs
977        assert!(Jid::from_str("@s.whatsapp.net").is_ok());
978        // But "@unknown.server" should still fail
979        assert!(Jid::from_str("@unknown.server").is_err());
980        // Jid::from_str("2") should not be possible due to type constraints,
981        // but if it were, it should fail. The string must contain '@'.
982        assert!(Jid::from_str("2").is_err());
983    }
984
985    /// Tests for HOSTED device detection (`is_hosted()` method).
986    ///
987    /// # Context: What are HOSTED devices?
988    ///
989    /// HOSTED devices (also known as Cloud API or Meta Business API devices) are
990    /// WhatsApp Business accounts that use Meta's server-side infrastructure instead
991    /// of traditional end-to-end encryption with Signal protocol.
992    ///
993    /// ## Key characteristics:
994    /// - Device ID is always 99 (`:99`)
995    /// - Server is `@hosted` (phone-based) or `@hosted.lid` (LID-based)
996    /// - They do NOT use Signal protocol prekeys
997    /// - They should be EXCLUDED from group message fanout
998    /// - They CAN receive 1:1 messages (but prekey fetch will fail, causing graceful skip)
999    ///
1000    /// ## Why exclude from groups?
1001    /// WhatsApp Web explicitly filters hosted devices from group SKDM (Sender Key
1002    /// Distribution Message) distribution. From WhatsApp Web JS (`getFanOutList`):
1003    /// ```javascript
1004    /// var isHosted = e.id === 99 || e.isHosted === true;
1005    /// var includeInFanout = !isHosted || isOneToOneChat;
1006    /// ```
1007    ///
1008    /// ## JID formats:
1009    /// - Phone-based: `5511999887766:99@hosted`
1010    /// - LID-based: `100000012345678:99@hosted.lid`
1011    /// - Regular device with ID 99: `5511999887766:99@s.whatsapp.net` (also hosted!)
1012    #[test]
1013    fn test_is_hosted_device_detection() {
1014        // === HOSTED DEVICES (should return true) ===
1015
1016        // Case 1: Device ID 99 on regular server (Cloud API business account)
1017        // This is the most common case - a business using Meta's Cloud API
1018        let cloud_api_device: Jid = "5511999887766:99@s.whatsapp.net"
1019            .parse()
1020            .expect("test JID should be valid");
1021        assert!(
1022            cloud_api_device.is_hosted(),
1023            "Device ID 99 on s.whatsapp.net should be detected as hosted (Cloud API)"
1024        );
1025
1026        // Case 2: Device ID 99 on LID server
1027        let cloud_api_lid: Jid = "100000012345678:99@lid"
1028            .parse()
1029            .expect("test JID should be valid");
1030        assert!(
1031            cloud_api_lid.is_hosted(),
1032            "Device ID 99 on lid server should be detected as hosted"
1033        );
1034
1035        // Case 3: Explicit @hosted server (phone-based hosted JID)
1036        let hosted_server: Jid = "5511999887766:99@hosted"
1037            .parse()
1038            .expect("test JID should be valid");
1039        assert!(
1040            hosted_server.is_hosted(),
1041            "JID with @hosted server should be detected as hosted"
1042        );
1043
1044        // Case 4: Explicit @hosted.lid server (LID-based hosted JID)
1045        let hosted_lid_server: Jid = "100000012345678:99@hosted.lid"
1046            .parse()
1047            .expect("test JID should be valid");
1048        assert!(
1049            hosted_lid_server.is_hosted(),
1050            "JID with @hosted.lid server should be detected as hosted"
1051        );
1052
1053        // Case 5: @hosted server with different device ID (edge case)
1054        // Even with device ID != 99, if server is @hosted, it's a hosted device
1055        let hosted_server_other_device: Jid = "5511999887766:0@hosted"
1056            .parse()
1057            .expect("test JID should be valid");
1058        assert!(
1059            hosted_server_other_device.is_hosted(),
1060            "JID with @hosted server should be hosted regardless of device ID"
1061        );
1062
1063        // === NON-HOSTED DEVICES (should return false) ===
1064
1065        // Case 6: Regular phone device (primary phone, device 0)
1066        let regular_phone: Jid = "5511999887766:0@s.whatsapp.net"
1067            .parse()
1068            .expect("test JID should be valid");
1069        assert!(
1070            !regular_phone.is_hosted(),
1071            "Regular phone device (ID 0) should NOT be hosted"
1072        );
1073
1074        // Case 7: Companion device (WhatsApp Web, device 33+)
1075        let companion_device: Jid = "5511999887766:33@s.whatsapp.net"
1076            .parse()
1077            .expect("test JID should be valid");
1078        assert!(
1079            !companion_device.is_hosted(),
1080            "Companion device (ID 33) should NOT be hosted"
1081        );
1082
1083        // Case 8: Regular LID device
1084        let regular_lid: Jid = "100000012345678:0@lid"
1085            .parse()
1086            .expect("test JID should be valid");
1087        assert!(
1088            !regular_lid.is_hosted(),
1089            "Regular LID device should NOT be hosted"
1090        );
1091
1092        // Case 9: LID companion device
1093        let lid_companion: Jid = "100000012345678:33@lid"
1094            .parse()
1095            .expect("test JID should be valid");
1096        assert!(
1097            !lid_companion.is_hosted(),
1098            "LID companion device (ID 33) should NOT be hosted"
1099        );
1100
1101        // Case 10: Group JID (not a device at all)
1102        let group_jid: Jid = "120363012345678@g.us"
1103            .parse()
1104            .expect("test JID should be valid");
1105        assert!(
1106            !group_jid.is_hosted(),
1107            "Group JID should NOT be detected as hosted"
1108        );
1109
1110        // Case 11: User JID without device
1111        let user_jid: Jid = "5511999887766@s.whatsapp.net"
1112            .parse()
1113            .expect("test JID should be valid");
1114        assert!(
1115            !user_jid.is_hosted(),
1116            "User JID without device should NOT be hosted"
1117        );
1118
1119        // Case 12: Bot device
1120        let bot_jid: Jid = "13136555001:0@s.whatsapp.net"
1121            .parse()
1122            .expect("test JID should be valid");
1123        assert!(
1124            !bot_jid.is_hosted(),
1125            "Bot JID should NOT be detected as hosted (different mechanism)"
1126        );
1127    }
1128
1129    /// Tests that document the filtering behavior for group messages.
1130    ///
1131    /// # Why this matters:
1132    /// When sending a group message, we distribute Sender Key Distribution Messages
1133    /// (SKDM) to all participant devices. However, HOSTED devices:
1134    /// 1. Don't use Signal protocol, so they can't process SKDM
1135    /// 2. WhatsApp Web explicitly excludes them from group fanout
1136    /// 3. Including them would cause unnecessary prekey fetch failures
1137    ///
1138    /// This test documents the expected behavior when filtering device lists.
1139    #[test]
1140    fn test_hosted_device_filtering_for_groups() {
1141        // Simulate a group with mixed device types
1142        let devices: Vec<Jid> = vec![
1143            // Regular devices that SHOULD receive SKDM
1144            "5511999887766:0@s.whatsapp.net"
1145                .parse()
1146                .expect("test JID should be valid"), // Phone
1147            "5511999887766:33@s.whatsapp.net"
1148                .parse()
1149                .expect("test JID should be valid"), // WhatsApp Web
1150            "5521988776655:0@s.whatsapp.net"
1151                .parse()
1152                .expect("test JID should be valid"), // Another user's phone
1153            "100000012345678:0@lid"
1154                .parse()
1155                .expect("test JID should be valid"), // LID device
1156            "100000012345678:33@lid"
1157                .parse()
1158                .expect("test JID should be valid"), // LID companion
1159            // HOSTED devices that should be EXCLUDED from group SKDM
1160            "5531977665544:99@s.whatsapp.net"
1161                .parse()
1162                .expect("test JID should be valid"), // Cloud API business
1163            "100000087654321:99@lid"
1164                .parse()
1165                .expect("test JID should be valid"), // Cloud API on LID
1166            "5541966554433:99@hosted"
1167                .parse()
1168                .expect("test JID should be valid"), // Explicit hosted
1169        ];
1170
1171        // Filter out hosted devices (this is what prepare_group_stanza does)
1172        let filtered: Vec<&Jid> = devices.iter().filter(|jid| !jid.is_hosted()).collect();
1173
1174        // Verify correct filtering
1175        assert_eq!(
1176            filtered.len(),
1177            5,
1178            "Should have 5 non-hosted devices after filtering"
1179        );
1180
1181        // All filtered devices should NOT be hosted
1182        for jid in &filtered {
1183            assert!(
1184                !jid.is_hosted(),
1185                "Filtered list should not contain hosted devices: {}",
1186                jid
1187            );
1188        }
1189
1190        // Count how many hosted devices were filtered out
1191        let hosted_count = devices.iter().filter(|jid| jid.is_hosted()).count();
1192        assert_eq!(hosted_count, 3, "Should have filtered out 3 hosted devices");
1193    }
1194
1195    #[test]
1196    fn test_jid_pn_factory() {
1197        let jid = Jid::pn("1234567890");
1198        assert_eq!(jid.user, "1234567890");
1199        assert_eq!(jid.server, DEFAULT_USER_SERVER);
1200        assert_eq!(jid.device, 0);
1201        assert!(jid.is_pn());
1202    }
1203
1204    #[test]
1205    fn test_jid_lid_factory() {
1206        let jid = Jid::lid("100000012345678");
1207        assert_eq!(jid.user, "100000012345678");
1208        assert_eq!(jid.server, HIDDEN_USER_SERVER);
1209        assert_eq!(jid.device, 0);
1210        assert!(jid.is_lid());
1211    }
1212
1213    #[test]
1214    fn test_jid_group_factory() {
1215        let jid = Jid::group("123456789-1234567890");
1216        assert_eq!(jid.user, "123456789-1234567890");
1217        assert_eq!(jid.server, GROUP_SERVER);
1218        assert!(jid.is_group());
1219    }
1220
1221    #[test]
1222    fn test_jid_pn_device_factory() {
1223        let jid = Jid::pn_device("1234567890", 5);
1224        assert_eq!(jid.user, "1234567890");
1225        assert_eq!(jid.server, DEFAULT_USER_SERVER);
1226        assert_eq!(jid.device, 5);
1227        assert!(jid.is_pn());
1228        assert!(jid.is_ad());
1229    }
1230
1231    #[test]
1232    fn test_jid_lid_device_factory() {
1233        let jid = Jid::lid_device("100000012345678", 33);
1234        assert_eq!(jid.user, "100000012345678");
1235        assert_eq!(jid.server, HIDDEN_USER_SERVER);
1236        assert_eq!(jid.device, 33);
1237        assert!(jid.is_lid());
1238        assert!(jid.is_ad());
1239    }
1240
1241    #[test]
1242    fn test_status_broadcast_jid() {
1243        let jid = Jid::status_broadcast();
1244        assert_eq!(jid.user, STATUS_BROADCAST_USER);
1245        assert_eq!(jid.server, BROADCAST_SERVER);
1246        assert_eq!(jid.device, 0);
1247        assert!(jid.is_status_broadcast());
1248        assert!(!jid.is_group());
1249        assert!(!jid.is_broadcast_list());
1250        assert_eq!(jid.to_string(), "status@broadcast");
1251
1252        // Parsing round-trip
1253        let parsed: Jid = "status@broadcast".parse().expect("should parse");
1254        assert!(parsed.is_status_broadcast());
1255        assert_eq!(parsed.user, "status");
1256        assert_eq!(parsed.server, "broadcast");
1257
1258        // Regular broadcast list should NOT be status broadcast
1259        let broadcast_list = Jid::new("12345", Server::Broadcast);
1260        assert!(broadcast_list.is_broadcast_list());
1261        assert!(!broadcast_list.is_status_broadcast());
1262    }
1263
1264    #[test]
1265    fn test_jid_to_non_ad_preserves_user_server() {
1266        // Verify to_non_ad strips device but keeps user/server
1267        let device_jid = Jid::pn_device("1234567890", 33);
1268        let non_ad = device_jid.to_non_ad();
1269        assert_eq!(non_ad.user, "1234567890");
1270        assert_eq!(non_ad.server, DEFAULT_USER_SERVER);
1271        assert_eq!(non_ad.device, 0);
1272        assert!(!non_ad.is_ad());
1273
1274        // LID variant
1275        let lid_device = Jid::lid_device("100000012345678", 25);
1276        let lid_non_ad = lid_device.to_non_ad();
1277        assert_eq!(lid_non_ad.user, "100000012345678");
1278        assert_eq!(lid_non_ad.server, HIDDEN_USER_SERVER);
1279        assert_eq!(lid_non_ad.device, 0);
1280
1281        // status@broadcast stays the same
1282        let status = Jid::status_broadcast();
1283        let status_non_ad = status.to_non_ad();
1284        assert_eq!(status_non_ad.to_string(), "status@broadcast");
1285    }
1286
1287    #[test]
1288    fn test_jid_factories_with_string_types() {
1289        // Test with &str
1290        let jid1 = Jid::pn("123");
1291        assert_eq!(jid1.user, "123");
1292
1293        // Test with String
1294        let jid2 = Jid::lid(String::from("456"));
1295        assert_eq!(jid2.user, "456");
1296
1297        // Test with owned String
1298        let user = "789".to_string();
1299        let jid3 = Jid::group(user);
1300        assert_eq!(jid3.user, "789");
1301    }
1302
1303    /// Verify that all JID formatting paths produce identical output:
1304    /// `Jid::Display`, `JidRef::Display`, `push_jid_to_string`, `push_jid_to_compact`,
1305    /// and `Jid::push_to`. Exercises the agent-elision rules across server variants.
1306    #[test]
1307    fn test_jid_format_parity() {
1308        struct Case {
1309            user: &'static str,
1310            server: Server,
1311            agent: u8,
1312            device: u16,
1313        }
1314
1315        let cases = [
1316            // Empty user (server-only JID)
1317            Case {
1318                user: "",
1319                server: Server::Pn,
1320                agent: 0,
1321                device: 0,
1322            },
1323            // Basic phone, no agent/device
1324            Case {
1325                user: "5511999887766",
1326                server: Server::Pn,
1327                agent: 0,
1328                device: 0,
1329            },
1330            // Phone with device
1331            Case {
1332                user: "5511999887766",
1333                server: Server::Pn,
1334                agent: 0,
1335                device: 2,
1336            },
1337            // Phone with agent (suppressed for Pn)
1338            Case {
1339                user: "5511999887766",
1340                server: Server::Pn,
1341                agent: 3,
1342                device: 15,
1343            },
1344            // LID with agent (suppressed for Lid)
1345            Case {
1346                user: "12345.6789",
1347                server: Server::Lid,
1348                agent: 1,
1349                device: 25,
1350            },
1351            // Hosted with agent (suppressed)
1352            Case {
1353                user: "100000012345678",
1354                server: Server::Hosted,
1355                agent: 2,
1356                device: 99,
1357            },
1358            // HostedLid with agent (suppressed)
1359            Case {
1360                user: "100000012345678",
1361                server: Server::HostedLid,
1362                agent: 1,
1363                device: 99,
1364            },
1365            // Group (no agent, no device)
1366            Case {
1367                user: "120363012345678901",
1368                server: Server::Group,
1369                agent: 0,
1370                device: 0,
1371            },
1372            // Bot with agent (shown)
1373            Case {
1374                user: "user",
1375                server: Server::Bot,
1376                agent: 5,
1377                device: 10,
1378            },
1379            // Interop with agent (shown)
1380            Case {
1381                user: "447911123456",
1382                server: Server::Interop,
1383                agent: 3,
1384                device: 0,
1385            },
1386            // Messenger with device, no agent
1387            Case {
1388                user: "messenger_user",
1389                server: Server::Messenger,
1390                agent: 0,
1391                device: 50,
1392            },
1393            // Broadcast
1394            Case {
1395                user: "status",
1396                server: Server::Broadcast,
1397                agent: 0,
1398                device: 0,
1399            },
1400            // Newsletter
1401            Case {
1402                user: "newsletter_id",
1403                server: Server::Newsletter,
1404                agent: 0,
1405                device: 0,
1406            },
1407            // Max values
1408            Case {
1409                user: "447911123456789",
1410                server: Server::Pn,
1411                agent: 255,
1412                device: 65535,
1413            },
1414            // Short user
1415            Case {
1416                user: "1",
1417                server: Server::Legacy,
1418                agent: 0,
1419                device: 1,
1420            },
1421        ];
1422
1423        for (i, c) in cases.iter().enumerate() {
1424            let jid = Jid {
1425                user: c.user.into(),
1426                server: c.server,
1427                agent: c.agent,
1428                device: c.device,
1429                integrator: 0,
1430            };
1431
1432            // Reference: Display impl (via write_jid! fallible)
1433            let display = jid.to_string();
1434
1435            // JidRef Display
1436            let jid_ref = JidRef {
1437                user: NodeStr::Borrowed(c.user),
1438                server: c.server,
1439                agent: c.agent,
1440                device: c.device,
1441                integrator: 0,
1442            };
1443            let ref_display = jid_ref.to_string();
1444
1445            // push_jid_to_string
1446            let mut string_buf = String::new();
1447            push_jid_to_string(c.user, c.server, c.agent, c.device, &mut string_buf);
1448
1449            // push_jid_to_compact
1450            let mut compact_buf = CompactString::default();
1451            push_jid_to_compact(c.user, c.server, c.agent, c.device, &mut compact_buf);
1452
1453            // Jid::push_to
1454            let mut push_buf = String::new();
1455            jid.push_to(&mut push_buf);
1456
1457            assert_eq!(display, ref_display, "case {i}: Display vs JidRef::Display");
1458            assert_eq!(
1459                display, string_buf,
1460                "case {i}: Display vs push_jid_to_string"
1461            );
1462            assert_eq!(
1463                display,
1464                compact_buf.as_str(),
1465                "case {i}: Display vs push_jid_to_compact"
1466            );
1467            assert_eq!(display, push_buf, "case {i}: Display vs Jid::push_to");
1468        }
1469    }
1470}