use crate::WireEnum;
use crate::iq::node::{required_attr, required_child};
use crate::protocol::ProtocolNode;
use anyhow::{Result, anyhow};
use serde::Serialize;
use wacore_binary::Jid;
use wacore_binary::builder::NodeBuilder;
use wacore_binary::{Node, NodeRef};
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum DeviceNotificationType {
#[wire = "add"]
Add,
#[wire = "remove"]
Remove,
#[wire = "update"]
Update,
}
#[derive(Debug, Clone, Serialize)]
pub struct KeyIndexInfo {
pub timestamp: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub signed_bytes: Option<Vec<u8>>,
}
impl ProtocolNode for KeyIndexInfo {
fn tag(&self) -> &'static str {
"key-index-list"
}
fn into_node(self) -> Node {
let mut builder = NodeBuilder::new("key-index-list").attr("ts", self.timestamp);
if let Some(bytes) = self.signed_bytes {
builder = builder.bytes(bytes);
}
builder.build()
}
fn try_from_node_ref(node: &NodeRef<'_>) -> Result<Self> {
use wacore_binary::NodeContentRef;
if node.tag != "key-index-list" {
return Err(anyhow!("expected <key-index-list>, got <{}>", node.tag));
}
let ts_u64 = node
.attrs()
.optional_u64("ts")
.ok_or_else(|| anyhow!("key-index-list missing required 'ts' attribute"))?;
let timestamp = i64::try_from(ts_u64)
.map_err(|_| anyhow!("key-index-list 'ts' value {} exceeds i64::MAX", ts_u64))?;
let signed_bytes = match node.content.as_ref() {
Some(NodeContentRef::Bytes(b)) if !b.is_empty() => Some(b.to_vec()),
_ => None,
};
Ok(Self {
timestamp,
signed_bytes,
})
}
}
#[derive(Debug, Clone, Serialize)]
pub struct DeviceElement {
pub jid: Jid,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_index: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lid: Option<Jid>,
}
impl DeviceElement {
#[inline]
pub fn device_id(&self) -> u32 {
self.jid.device as u32
}
}
impl ProtocolNode for DeviceElement {
fn tag(&self) -> &'static str {
"device"
}
fn into_node(self) -> Node {
let mut builder = NodeBuilder::new("device").attr("jid", self.jid);
if let Some(ki) = self.key_index {
builder = builder.attr("key-index", ki);
}
if let Some(lid) = self.lid {
builder = builder.attr("lid", lid);
}
builder.build()
}
fn try_from_node_ref(node: &NodeRef<'_>) -> Result<Self> {
if node.tag != "device" {
return Err(anyhow!("expected <device>, got <{}>", node.tag));
}
let mut attrs = node.attrs();
let jid = attrs
.optional_jid("jid")
.ok_or_else(|| anyhow!("device missing required 'jid' attribute"))?;
let key_index = match attrs.optional_u64("key-index") {
Some(v) => Some(
u32::try_from(v)
.map_err(|_| anyhow!("device 'key-index' value {} exceeds u32::MAX", v))?,
),
None => None,
};
let lid = attrs.optional_jid("lid");
if let Some(ref lid_jid) = lid {
let jid_device_id = jid.device;
let lid_device_id = lid_jid.device;
if jid_device_id != lid_device_id {
return Err(anyhow!(
"device id mismatch between jid ({}) and lid ({}) attributes",
jid_device_id,
lid_device_id
));
}
}
Ok(Self {
jid,
key_index,
lid,
})
}
}
#[derive(Debug, Clone, Serialize)]
pub struct DeviceOperation {
pub operation_type: DeviceNotificationType,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_hash: Option<String>,
pub devices: Vec<DeviceElement>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_index: Option<KeyIndexInfo>,
}
impl DeviceOperation {
pub fn try_from_child(node: &NodeRef<'_>) -> Result<Self> {
let operation_type = DeviceNotificationType::try_from(node.tag.as_ref())
.map_err(|_| anyhow!("unknown device operation: {}", node.tag))?;
match operation_type {
DeviceNotificationType::Add | DeviceNotificationType::Remove => {
let key_index_node = required_child(node, "key-index-list")?;
let key_index = KeyIndexInfo::try_from_node_ref(key_index_node)?;
if operation_type == DeviceNotificationType::Remove && key_index.timestamp == 0 {
return Err(anyhow!(
"timestamp is required to handle device remove notification"
));
}
let device_node = required_child(node, "device")?;
let device = DeviceElement::try_from_node_ref(device_node)?;
Ok(Self {
operation_type,
contact_hash: None,
devices: vec![device],
key_index: Some(key_index),
})
}
DeviceNotificationType::Update => {
let contact_hash = required_attr(node, "hash")?;
Ok(Self {
operation_type,
contact_hash: Some(contact_hash),
devices: Vec::new(),
key_index: None,
})
}
}
}
pub fn device_ids(&self) -> Vec<u32> {
self.devices.iter().map(|d| d.device_id()).collect()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct DeviceNotification {
pub from: Jid,
#[serde(skip_serializing_if = "Option::is_none")]
pub lid_user: Option<Jid>,
pub stanza_id: String,
pub timestamp: i64,
pub operation: DeviceOperation,
}
impl DeviceNotification {
pub fn try_parse(node: &NodeRef<'_>) -> Result<Self> {
if node.tag != "notification" {
return Err(anyhow!("expected <notification>, got <{}>", node.tag));
}
if node
.get_attr("type")
.is_none_or(|v| v.as_str() != "devices")
{
return Err(anyhow!("expected type='devices'"));
}
let mut parser = node.attrs();
let from = parser
.optional_jid("from")
.ok_or_else(|| anyhow!("notification missing required 'from' attribute"))?;
let lid_user = parser.optional_jid("lid");
let stanza_id = node
.get_attr("id")
.map(|v| v.as_str())
.unwrap_or_default()
.into_owned();
let timestamp = match parser.optional_u64("t") {
Some(t) => i64::try_from(t)
.map_err(|_| anyhow!("notification timestamp {} exceeds i64::MAX", t))?,
None => 0,
};
let operation = if let Some(remove_node) = node.get_optional_child("remove") {
DeviceOperation::try_from_child(remove_node)?
} else if let Some(add_node) = node.get_optional_child("add") {
DeviceOperation::try_from_child(add_node)?
} else if let Some(update_node) = node.get_optional_child("update") {
DeviceOperation::try_from_child(update_node)?
} else {
return Err(anyhow!(
"device notification missing required operation (add/remove/update)"
));
};
Ok(Self {
from,
lid_user,
stanza_id,
timestamp,
operation,
})
}
#[inline]
pub fn user(&self) -> &str {
&self.from.user
}
pub fn lid_pn_mapping(&self) -> Option<(&str, &str)> {
let lid = self.lid_user.as_ref()?;
if !self.from.is_lid() && lid.is_lid() {
Some((&lid.user, &self.from.user))
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use wacore_binary::builder::NodeBuilder;
#[test]
fn test_device_notification_type_as_str() {
assert_eq!(DeviceNotificationType::Add.as_str(), "add");
assert_eq!(DeviceNotificationType::Remove.as_str(), "remove");
assert_eq!(DeviceNotificationType::Update.as_str(), "update");
}
#[test]
fn test_device_notification_type_try_from() {
assert_eq!(
DeviceNotificationType::try_from("add").unwrap(),
DeviceNotificationType::Add
);
assert_eq!(
DeviceNotificationType::try_from("remove").unwrap(),
DeviceNotificationType::Remove
);
assert!(DeviceNotificationType::try_from("invalid").is_err());
}
#[test]
fn test_parse_remove_notification() {
let node = NodeBuilder::new("notification")
.attr("type", "devices")
.attr("from", "185169143189667@lid")
.attr("id", "511477682")
.attr("t", "1769296817")
.children([NodeBuilder::new("remove")
.children([
NodeBuilder::new("device")
.attr("jid", "185169143189667:75@lid")
.build(),
NodeBuilder::new("key-index-list")
.attr("ts", "1769296600")
.build(),
])
.build()])
.build();
let parsed = DeviceNotification::try_parse(&node.as_node_ref()).unwrap();
assert_eq!(parsed.from.user, "185169143189667");
assert_eq!(parsed.stanza_id, "511477682");
assert_eq!(parsed.timestamp, 1769296817);
let op = &parsed.operation;
assert_eq!(op.operation_type, DeviceNotificationType::Remove);
assert_eq!(op.devices.len(), 1);
assert_eq!(op.devices[0].device_id(), 75);
assert_eq!(op.key_index.as_ref().unwrap().timestamp, 1769296600);
assert!(op.key_index.as_ref().unwrap().signed_bytes.is_none());
}
#[test]
fn test_parse_add_notification_with_key_bytes() {
let node = NodeBuilder::new("notification")
.attr("type", "devices")
.attr("from", "15551234567@s.whatsapp.net")
.attr("lid", "100000000000001@lid")
.attr("id", "123")
.attr("t", "1000")
.children([NodeBuilder::new("add")
.children([
NodeBuilder::new("device")
.attr("jid", "15551234567:64@s.whatsapp.net")
.attr("key-index", "5")
.build(),
NodeBuilder::new("key-index-list")
.attr("ts", "999")
.bytes(vec![0x01, 0x02, 0x03])
.build(),
])
.build()])
.build();
let parsed = DeviceNotification::try_parse(&node.as_node_ref()).unwrap();
let (lid, pn) = parsed.lid_pn_mapping().unwrap();
assert_eq!(lid, "100000000000001");
assert_eq!(pn, "15551234567");
let op = &parsed.operation;
assert_eq!(op.operation_type, DeviceNotificationType::Add);
assert_eq!(op.devices[0].device_id(), 64);
assert_eq!(op.devices[0].key_index, Some(5));
assert_eq!(
op.key_index.as_ref().unwrap().signed_bytes,
Some(vec![0x01, 0x02, 0x03])
);
}
#[test]
fn test_parse_update_notification() {
let node = NodeBuilder::new("notification")
.attr("type", "devices")
.attr("from", "15551234567@s.whatsapp.net")
.attr("id", "456")
.attr("t", "2000")
.children([NodeBuilder::new("update")
.attr("hash", "contact_hash_value")
.build()])
.build();
let parsed = DeviceNotification::try_parse(&node.as_node_ref()).unwrap();
let op = &parsed.operation;
assert_eq!(op.operation_type, DeviceNotificationType::Update);
assert_eq!(op.contact_hash, Some("contact_hash_value".to_string()));
assert!(op.devices.is_empty());
}
#[test]
fn test_lid_pn_mapping_not_detected_when_from_is_lid() {
let node = NodeBuilder::new("notification")
.attr("type", "devices")
.attr("from", "185169143189667@lid")
.attr("lid", "185169143189667@lid")
.attr("id", "123")
.attr("t", "1000")
.children([NodeBuilder::new("update").attr("hash", "test_hash").build()])
.build();
let parsed = DeviceNotification::try_parse(&node.as_node_ref()).unwrap();
assert!(parsed.lid_pn_mapping().is_none());
}
#[test]
fn test_missing_key_index_list_fails() {
let node = NodeBuilder::new("notification")
.attr("type", "devices")
.attr("from", "15551234567@s.whatsapp.net")
.attr("id", "123")
.attr("t", "1000")
.children([NodeBuilder::new("add")
.children([NodeBuilder::new("device")
.attr("jid", "15551234567:64@s.whatsapp.net")
.build()])
.build()])
.build();
let result = DeviceNotification::try_parse(&node.as_node_ref());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("key-index-list"));
}
#[test]
fn test_remove_without_timestamp_fails() {
let node = NodeBuilder::new("notification")
.attr("type", "devices")
.attr("from", "15551234567@s.whatsapp.net")
.attr("id", "123")
.attr("t", "1000")
.children([NodeBuilder::new("remove")
.children([
NodeBuilder::new("device")
.attr("jid", "15551234567:64@s.whatsapp.net")
.build(),
NodeBuilder::new("key-index-list")
.attr("ts", "0") .build(),
])
.build()])
.build();
let result = DeviceNotification::try_parse(&node.as_node_ref());
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("timestamp is required")
);
}
#[test]
fn test_device_id_mismatch_fails() {
let node = NodeBuilder::new("notification")
.attr("type", "devices")
.attr("from", "15551234567@s.whatsapp.net")
.attr("id", "123")
.attr("t", "1000")
.children([NodeBuilder::new("add")
.children([
NodeBuilder::new("device")
.attr("jid", "15551234567:64@s.whatsapp.net")
.attr("lid", "100000000000001:99@lid") .build(),
NodeBuilder::new("key-index-list").attr("ts", "999").build(),
])
.build()])
.build();
let result = DeviceNotification::try_parse(&node.as_node_ref());
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("device id mismatch")
);
}
#[test]
fn test_device_with_matching_lid() {
let node = NodeBuilder::new("notification")
.attr("type", "devices")
.attr("from", "15551234567@s.whatsapp.net")
.attr("id", "123")
.attr("t", "1000")
.children([NodeBuilder::new("add")
.children([
NodeBuilder::new("device")
.attr("jid", "15551234567:64@s.whatsapp.net")
.attr("lid", "100000000000001:64@lid") .build(),
NodeBuilder::new("key-index-list").attr("ts", "999").build(),
])
.build()])
.build();
let parsed = DeviceNotification::try_parse(&node.as_node_ref()).unwrap();
assert_eq!(parsed.operation.devices[0].device_id(), 64);
assert!(parsed.operation.devices[0].lid.is_some());
}
#[test]
fn test_no_operation_fails() {
let node = NodeBuilder::new("notification")
.attr("type", "devices")
.attr("from", "15551234567@s.whatsapp.net")
.attr("id", "123")
.attr("t", "1000")
.build();
let result = DeviceNotification::try_parse(&node.as_node_ref());
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("missing required operation")
);
}
#[test]
fn test_remove_priority_over_add() {
let node = NodeBuilder::new("notification")
.attr("type", "devices")
.attr("from", "15551234567@s.whatsapp.net")
.attr("id", "123")
.attr("t", "1000")
.children([
NodeBuilder::new("add")
.children([
NodeBuilder::new("device")
.attr("jid", "15551234567:64@s.whatsapp.net")
.build(),
NodeBuilder::new("key-index-list").attr("ts", "999").build(),
])
.build(),
NodeBuilder::new("remove")
.children([
NodeBuilder::new("device")
.attr("jid", "15551234567:75@s.whatsapp.net")
.build(),
NodeBuilder::new("key-index-list").attr("ts", "888").build(),
])
.build(),
])
.build();
let parsed = DeviceNotification::try_parse(&node.as_node_ref()).unwrap();
assert_eq!(
parsed.operation.operation_type,
DeviceNotificationType::Remove
);
assert_eq!(parsed.operation.devices[0].device_id(), 75);
}
#[test]
fn test_update_without_hash_fails() {
let node = NodeBuilder::new("notification")
.attr("type", "devices")
.attr("from", "15551234567@s.whatsapp.net")
.attr("id", "123")
.attr("t", "1000")
.children([NodeBuilder::new("update").build()]) .build();
let result = DeviceNotification::try_parse(&node.as_node_ref());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("hash"));
}
}