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