use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedJid {
pub user: String,
pub server: String,
pub device: Option<u16>,
}
impl ParsedJid {
pub fn is_user(&self) -> bool {
matches!(self.server.as_str(), "s.whatsapp.net" | "lid")
}
pub fn canonical(&self) -> String {
format!("{}@{}", self.user, self.server)
}
pub fn user_only(&self) -> &str {
&self.user
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum JidParseError {
#[error("jid empty")]
Empty,
#[error("jid missing '@' separator: {0:?}")]
NoSeparator(String),
#[error("jid has empty user portion: {0:?}")]
EmptyUser(String),
#[error("jid server unknown: {0:?}")]
UnknownServer(String),
#[error("jid device suffix invalid: {0:?}")]
InvalidDevice(String),
}
const KNOWN_USER_SERVERS: &[&str] = &["s.whatsapp.net", "lid"];
const KNOWN_LEGACY_SERVERS: &[&str] = &["c.us"];
const KNOWN_NON_USER_SERVERS: &[&str] =
&["g.us", "broadcast", "status@broadcast", "bot", "newsletter"];
pub fn parse_jid(input: &str) -> Result<ParsedJid, JidParseError> {
let raw = input.trim();
if raw.is_empty() {
return Err(JidParseError::Empty);
}
let (user_part, server_part) = raw
.split_once('@')
.ok_or_else(|| JidParseError::NoSeparator(raw.to_string()))?;
if user_part.is_empty() {
return Err(JidParseError::EmptyUser(raw.to_string()));
}
let (user_no_device, device) = if let Some((before, dev)) = user_part.rsplit_once(':') {
let dev_num = dev
.parse::<u16>()
.map_err(|_| JidParseError::InvalidDevice(raw.to_string()))?;
(before, Some(dev_num))
} else {
(user_part, None)
};
let user_no_agent = user_no_device
.split_once('_')
.map(|(u, _)| u)
.unwrap_or(user_no_device);
let user_no_agent = user_no_agent
.split_once('.')
.map(|(u, _)| u)
.unwrap_or(user_no_agent);
let user = user_no_agent.to_lowercase();
let canonical_server = if KNOWN_LEGACY_SERVERS.contains(&server_part) {
"s.whatsapp.net".to_string()
} else if KNOWN_USER_SERVERS.contains(&server_part)
|| KNOWN_NON_USER_SERVERS.contains(&server_part)
{
server_part.to_lowercase()
} else {
return Err(JidParseError::UnknownServer(server_part.to_string()));
};
Ok(ParsedJid {
user,
server: canonical_server,
device,
})
}
pub fn normalize_jid(input: &str) -> Result<String, JidParseError> {
Ok(parse_jid(input)?.canonical())
}
pub fn same_user(a: &ParsedJid, b: &ParsedJid) -> bool {
a.is_user() && b.is_user() && a.user == b.user && a.server == b.server
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_canonical_pn_jid() {
let p = parse_jid("573001234567@s.whatsapp.net").unwrap();
assert_eq!(p.user, "573001234567");
assert_eq!(p.server, "s.whatsapp.net");
assert_eq!(p.device, None);
assert!(p.is_user());
}
#[test]
fn parse_strips_device_suffix() {
let p = parse_jid("573001234567:5@s.whatsapp.net").unwrap();
assert_eq!(p.user, "573001234567");
assert_eq!(p.device, Some(5));
assert_eq!(p.canonical(), "573001234567@s.whatsapp.net");
}
#[test]
fn parse_canonicalises_legacy_c_us() {
let p = parse_jid("573001234567@c.us").unwrap();
assert_eq!(p.server, "s.whatsapp.net");
}
#[test]
fn parse_keeps_lid_distinct_from_pn() {
let pn = parse_jid("573001234567@s.whatsapp.net").unwrap();
let lid = parse_jid("123456789@lid").unwrap();
assert!(pn.is_user());
assert!(lid.is_user());
assert_ne!(pn.canonical(), lid.canonical());
}
#[test]
fn parse_strips_baileys_agent_underscore() {
let p = parse_jid("573001234567_15:1@s.whatsapp.net").unwrap();
assert_eq!(p.user, "573001234567");
assert_eq!(p.device, Some(1));
}
#[test]
fn parse_strips_whatsmeow_agent_dot() {
let p = parse_jid("573001234567.15:1@s.whatsapp.net").unwrap();
assert_eq!(p.user, "573001234567");
assert_eq!(p.device, Some(1));
}
#[test]
fn parse_lower_cases_user() {
let p = parse_jid("ABCDEF@lid").unwrap();
assert_eq!(p.user, "abcdef");
}
#[test]
fn parse_group_jid_accepted_but_not_user() {
let p = parse_jid("123456-987654@g.us").unwrap();
assert_eq!(p.server, "g.us");
assert!(!p.is_user());
}
#[test]
fn parse_status_broadcast_not_user() {
let p = parse_jid("status@broadcast").unwrap();
assert!(!p.is_user());
}
#[test]
fn parse_unknown_server_rejected() {
let r = parse_jid("123@example.com");
assert!(matches!(r, Err(JidParseError::UnknownServer(_))));
}
#[test]
fn parse_empty_input_rejected() {
assert_eq!(parse_jid("").unwrap_err(), JidParseError::Empty);
assert_eq!(parse_jid(" ").unwrap_err(), JidParseError::Empty);
}
#[test]
fn parse_no_separator_rejected() {
let r = parse_jid("573001234567");
assert!(matches!(r, Err(JidParseError::NoSeparator(_))));
}
#[test]
fn parse_empty_user_rejected() {
let r = parse_jid("@s.whatsapp.net");
assert!(matches!(r, Err(JidParseError::EmptyUser(_))));
}
#[test]
fn parse_invalid_device_rejected() {
let r = parse_jid("573001234567:abc@s.whatsapp.net");
assert!(matches!(r, Err(JidParseError::InvalidDevice(_))));
}
#[test]
fn normalize_jid_returns_canonical_string() {
let s = normalize_jid("573001234567:1@c.us").unwrap();
assert_eq!(s, "573001234567@s.whatsapp.net");
}
#[test]
fn same_user_ignores_device_and_agent() {
let a = parse_jid("573001234567:1@s.whatsapp.net").unwrap();
let b = parse_jid("573001234567:5@s.whatsapp.net").unwrap();
assert!(same_user(&a, &b));
}
#[test]
fn same_user_rejects_cross_namespace() {
let pn = parse_jid("573001234567@s.whatsapp.net").unwrap();
let lid = parse_jid("573001234567@lid").unwrap();
assert!(!same_user(&pn, &lid));
}
#[test]
fn same_user_rejects_groups() {
let g1 = parse_jid("12-34@g.us").unwrap();
let g2 = parse_jid("12-34@g.us").unwrap();
assert!(!same_user(&g1, &g2));
}
}