Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 1 addition & 12 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1475,7 +1475,7 @@ impl Client {
match (code, conflict_type.as_str()) {
("515", _) => {
// 515 is expected during registration/pairing phase - server closes stream after pairing
info!(target: "Client", "Got 515 stream error, server is closing stream. Will auto-reconnect.");
info!(target: "Client", "Got 515 stream error, server is closing stream (expected after pairing). Will auto-reconnect.");
self.expect_disconnect().await;
// Proactively disconnect transport since server may not close the connection
// Clone the transport Arc before spawning to avoid holding the lock
Expand Down Expand Up @@ -2765,17 +2765,6 @@ mod tests {
/// - BUG (before fix): Called process_prekey_bundle() unconditionally,
/// replacing the existing session with a new one
/// - RESULT: Remote device still uses old session state, causing MAC failures
///
/// WhatsApp Web Reference (MpTzv7av1aW.js, lines 32828-32834):
/// ```javascript
/// S.forEach(function (e, t) {
/// if (k[t]) { // If session exists
/// h.delete(e); // Just remove from pending - NO fetch
/// } else {
/// I.push(e); // Only fetch prekeys for devices WITHOUT sessions
/// }
/// });
/// ```
#[tokio::test]
async fn test_establish_session_skips_when_exists() {
use wacore::libsignal::protocol::SessionRecord;
Expand Down
4 changes: 0 additions & 4 deletions src/client/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,6 @@ mod tests {
use super::*;
use wacore_binary::jid::{DEFAULT_USER_SERVER, HIDDEN_USER_SERVER, JidExt};

// Tests verify session management matches WhatsApp Web's behavior:
// - hasSignalSessions() via containSessions() (GysEGRAXCvh.js:48394)
// - ensureE2ESessions() (MpTzv7av1aW.js:32760)

#[test]
fn test_primary_phone_jid_creation_from_pn() {
let own_pn = Jid::pn("559999999999");
Expand Down
258 changes: 141 additions & 117 deletions src/handlers/notification.rs
Original file line number Diff line number Diff line change
@@ -1,27 +1,16 @@
use super::traits::StanzaHandler;
use crate::client::Client;
use crate::lid_pn_cache::LearningSource;
use crate::types::events::Event;
use async_trait::async_trait;
use log::{debug, info, warn};
use std::sync::Arc;
use wacore::stanza::devices::DeviceNotification;
use wacore::store::traits::{DeviceInfo, DeviceListRecord};
use wacore::types::events::{DeviceListUpdate, DeviceListUpdateType};
use wacore::types::events::{DeviceListUpdate, DeviceNotificationInfo};
use wacore_binary::jid::{Jid, JidExt};
use wacore_binary::{jid::SERVER_JID, node::Node};

/// Extract device IDs from child `<device>` elements of a node.
fn extract_device_ids(node: &Node) -> Vec<u32> {
node.children()
.map(|device_nodes| {
device_nodes
.iter()
.filter(|n| n.tag == "device")
.filter_map(|n| n.attrs().optional_u64("id").map(|id| id as u32))
.collect()
})
.unwrap_or_default()
}

/// Handler for `<notification>` stanzas.
///
/// Processes various notification types including:
Expand Down Expand Up @@ -118,62 +107,62 @@ async fn handle_notification_impl(client: &Arc<Client>, node: &Node) {
/// Device notifications have the structure:
/// ```xml
/// <notification type="devices" from="user@s.whatsapp.net">
/// <add> or <remove> or <update hash="...">
/// <device id="1" />
/// <device id="2" />
/// <add device_hash="..."> or <remove device_hash="..."> or <update hash="...">
/// <device jid="user:device@server"/>
/// <key-index-list ts="..."/>
/// </add/remove/update>
/// </notification>
/// ```
async fn handle_devices_notification(client: &Arc<Client>, node: &Node) {
// Extract user JID from the "from" attribute
let from_jid = match node.attrs().optional_jid("from") {
Some(jid) => jid,
None => {
warn!(target: "Client", "Device notification missing 'from' attribute");
// Parse using type-safe struct
let notification = match DeviceNotification::try_parse(node) {
Ok(n) => n,
Err(e) => {
warn!(target: "Client", "Failed to parse device notification: {e}");
return;
}
};

let user = from_jid.user.clone();

// Determine update type and extract device list
let Some(children) = node.children() else {
warn!(target: "Client", "Device notification has no children");
return;
};

for child in children.iter() {
let (update_type, hash) = match child.tag.as_str() {
"add" => (DeviceListUpdateType::Add, None),
"remove" => (DeviceListUpdateType::Remove, None),
"update" => {
let hash = child.attrs().optional_string("hash").map(|s| s.to_string());
(DeviceListUpdateType::Update, hash)
}
_ => continue,
};

let devices = extract_device_ids(child);
// Learn LID-PN mapping if present
if let Some((lid, pn)) = notification.lid_pn_mapping()
&& let Err(e) = client
.add_lid_pn_mapping(lid, pn, LearningSource::DeviceNotification)
.await
{
warn!(target: "Client", "Failed to add LID-PN mapping from device notification: {e}");
}

debug!(
target: "Client",
"Device notification: user={}, type={:?}, devices={:?}, hash={:?}",
user, update_type, devices, hash
);
// Process the single operation (per WhatsApp Web: one operation per notification)
let op = &notification.operation;
debug!(
target: "Client",
"Device notification: user={}, type={:?}, devices={:?}",
notification.user(),
op.operation_type,
op.device_ids()
);

// Invalidate the device cache for this user
// This ensures the next lookup fetches fresh data
client.invalidate_device_cache(&user).await;

// Dispatch event to notify application layer
let event = Event::DeviceListUpdate(DeviceListUpdate {
user: from_jid.clone(),
update_type,
devices,
hash,
});
client.core.event_bus.dispatch(&event);
}
// Invalidate the device cache for this user
// This ensures the next lookup fetches fresh data
client.invalidate_device_cache(notification.user()).await;

// Dispatch event to notify application layer
let event = Event::DeviceListUpdate(DeviceListUpdate {
user: notification.from.clone(),
lid_user: notification.lid_user.clone(),
update_type: op.operation_type.into(),
devices: op
.devices
.iter()
.map(|d| DeviceNotificationInfo {
device_id: d.device_id(),
key_index: d.key_index,
})
.collect(),
key_index: op.key_index.clone(),
contact_hash: op.contact_hash.clone(),
});
client.core.event_bus.dispatch(&event);
}

/// Parsed device info from account_sync notification
Expand Down Expand Up @@ -316,54 +305,35 @@ async fn handle_account_sync_devices(client: &Arc<Client>, node: &Node, devices_
#[cfg(test)]
mod tests {
use super::*;
use wacore::stanza::devices::DeviceNotificationType;
use wacore::types::events::DeviceListUpdateType;
use wacore_binary::builder::NodeBuilder;

/// Helper to parse device notification and extract update info
fn parse_device_notification_info(
node: &wacore_binary::node::Node,
) -> Vec<(DeviceListUpdateType, Vec<u32>, Option<String>)> {
let Some(children) = node.children() else {
return vec![];
};

let mut results = vec![];
for child in children.iter() {
let (update_type, hash) = match child.tag.as_str() {
"add" => (DeviceListUpdateType::Add, None),
"remove" => (DeviceListUpdateType::Remove, None),
"update" => {
let hash = child.attrs().optional_string("hash").map(|s| s.to_string());
(DeviceListUpdateType::Update, hash)
}
_ => continue,
};

let devices = extract_device_ids(child);

results.push((update_type, devices, hash));
}
results
}

#[test]
fn test_parse_device_add_notification() {
// Per WhatsApp Web: add operation has single device + key-index-list
let node = NodeBuilder::new("notification")
.attr("type", "devices")
.attr("from", "1234567890@s.whatsapp.net")
.children([NodeBuilder::new("add")
.children([
NodeBuilder::new("device").attr("id", "1").build(),
NodeBuilder::new("device").attr("id", "2").build(),
NodeBuilder::new("device")
.attr("jid", "1234567890:1@s.whatsapp.net")
.build(),
NodeBuilder::new("key-index-list")
.attr("ts", "1000")
.bytes(vec![0x01, 0x02, 0x03])
.build(),
])
.build()])
.build();

let results = parse_device_notification_info(&node);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, DeviceListUpdateType::Add);
assert_eq!(results[0].1, vec![1, 2]);
assert_eq!(results[0].2, None);
let parsed = DeviceNotification::try_parse(&node).unwrap();
assert_eq!(parsed.operation.operation_type, DeviceNotificationType::Add);
assert_eq!(parsed.operation.device_ids(), vec![1]);
// Verify key index info
assert!(parsed.operation.key_index.is_some());
assert_eq!(parsed.operation.key_index.as_ref().unwrap().timestamp, 1000);
}

#[test]
Expand All @@ -372,14 +342,23 @@ mod tests {
.attr("type", "devices")
.attr("from", "1234567890@s.whatsapp.net")
.children([NodeBuilder::new("remove")
.children([NodeBuilder::new("device").attr("id", "3").build()])
.children([
NodeBuilder::new("device")
.attr("jid", "1234567890:3@s.whatsapp.net")
.build(),
NodeBuilder::new("key-index-list")
.attr("ts", "2000")
.build(),
])
.build()])
.build();

let results = parse_device_notification_info(&node);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, DeviceListUpdateType::Remove);
assert_eq!(results[0].1, vec![3]);
let parsed = DeviceNotification::try_parse(&node).unwrap();
assert_eq!(
parsed.operation.operation_type,
DeviceNotificationType::Remove
);
assert_eq!(parsed.operation.device_ids(), vec![3]);
}

#[test]
Expand All @@ -389,49 +368,94 @@ mod tests {
.attr("from", "1234567890@s.whatsapp.net")
.children([NodeBuilder::new("update")
.attr("hash", "2:abcdef123456")
.children([NodeBuilder::new("device").attr("id", "0").build()])
.build()])
.build();

let results = parse_device_notification_info(&node);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, DeviceListUpdateType::Update);
assert_eq!(results[0].1, vec![0]);
assert_eq!(results[0].2, Some("2:abcdef123456".to_string()));
let parsed = DeviceNotification::try_parse(&node).unwrap();
assert_eq!(
parsed.operation.operation_type,
DeviceNotificationType::Update
);
assert_eq!(
parsed.operation.contact_hash,
Some("2:abcdef123456".to_string())
);
// Update operations don't have devices (just hash for lookup)
assert!(parsed.operation.devices.is_empty());
}

#[test]
fn test_parse_empty_device_notification() {
fn test_parse_empty_device_notification_fails() {
// Per WhatsApp Web: at least one operation (add/remove/update) is required
let node = NodeBuilder::new("notification")
.attr("type", "devices")
.attr("from", "1234567890@s.whatsapp.net")
.build();

let results = parse_device_notification_info(&node);
assert!(results.is_empty());
let result = DeviceNotification::try_parse(&node);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("missing required operation")
);
}

#[test]
fn test_parse_multiple_device_operations() {
fn test_parse_multiple_operations_uses_priority() {
// Per WhatsApp Web: only ONE operation is processed with priority remove > add > update
// If both remove and add are present, remove should be processed
let node = NodeBuilder::new("notification")
.attr("type", "devices")
.attr("from", "1234567890@s.whatsapp.net")
.children([
NodeBuilder::new("add")
.children([NodeBuilder::new("device").attr("id", "5").build()])
.children([
NodeBuilder::new("device")
.attr("jid", "1234567890:5@s.whatsapp.net")
.build(),
NodeBuilder::new("key-index-list")
.attr("ts", "3000")
.build(),
])
.build(),
NodeBuilder::new("remove")
.children([NodeBuilder::new("device").attr("id", "2").build()])
.children([
NodeBuilder::new("device")
.attr("jid", "1234567890:2@s.whatsapp.net")
.build(),
NodeBuilder::new("key-index-list")
.attr("ts", "3001")
.build(),
])
.build(),
])
.build();

let results = parse_device_notification_info(&node);
assert_eq!(results.len(), 2);
assert_eq!(results[0].0, DeviceListUpdateType::Add);
assert_eq!(results[0].1, vec![5]);
assert_eq!(results[1].0, DeviceListUpdateType::Remove);
assert_eq!(results[1].1, vec![2]);
let parsed = DeviceNotification::try_parse(&node).unwrap();
// Should process remove, not add (priority: remove > add > update)
assert_eq!(
parsed.operation.operation_type,
DeviceNotificationType::Remove
);
assert_eq!(parsed.operation.device_ids(), vec![2]);
}

#[test]
fn test_device_list_update_type_from_notification_type() {
assert_eq!(
DeviceListUpdateType::from(DeviceNotificationType::Add),
DeviceListUpdateType::Add
);
assert_eq!(
DeviceListUpdateType::from(DeviceNotificationType::Remove),
DeviceListUpdateType::Remove
);
assert_eq!(
DeviceListUpdateType::from(DeviceNotificationType::Update),
DeviceListUpdateType::Update
);
}

// Tests for account_sync device parsing
Expand Down
1 change: 1 addition & 0 deletions src/lid_pn_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ mod tests {
(LearningSource::BlocklistActive, "blocklist_active"),
(LearningSource::BlocklistInactive, "blocklist_inactive"),
(LearningSource::Pairing, "pairing"),
(LearningSource::DeviceNotification, "device_notification"),
(LearningSource::Other, "other"),
];

Expand Down
Loading