1use super::traits::StanzaHandler;
2use crate::client::Client;
3use crate::lid_pn_cache::LearningSource;
4use crate::types::events::Event;
5use async_trait::async_trait;
6use log::{debug, info, warn};
7use std::sync::Arc;
8use wacore::stanza::business::BusinessNotification;
9use wacore::stanza::devices::DeviceNotification;
10use wacore::stanza::groups::{GroupNotification, GroupNotificationAction};
11use wacore::store::traits::{DeviceInfo, DeviceListRecord};
12use wacore::types::events::{
13 BusinessStatusUpdate, BusinessUpdateType, ContactNumberChanged, ContactSyncRequested,
14 ContactUpdated, DeviceListUpdate, DeviceNotificationInfo, GroupUpdate, PictureUpdate,
15 UserAboutUpdate,
16};
17use wacore_binary::jid::{Jid, JidExt};
18use wacore_binary::{jid::SERVER_JID, node::Node};
19
20#[derive(Default)]
28pub struct NotificationHandler;
29
30#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
31#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
32impl StanzaHandler for NotificationHandler {
33 fn tag(&self) -> &'static str {
34 "notification"
35 }
36
37 async fn handle(&self, client: Arc<Client>, node: Arc<Node>, _cancelled: &mut bool) -> bool {
38 handle_notification_impl(&client, &node).await;
39 true
40 }
41}
42
43async fn handle_notification_impl(client: &Arc<Client>, node: &Node) {
44 let notification_type = node.attrs().optional_string("type");
45 let notification_type = notification_type.as_deref().unwrap_or_default();
46
47 match notification_type {
48 "encrypt" => {
49 if node.attrs.get("from").is_some_and(|v| v == SERVER_JID) {
50 let first_child_tag = node
53 .children()
54 .and_then(|c| c.first().map(|n| n.tag.clone()));
55
56 match first_child_tag.as_deref() {
57 Some("count") => {
58 handle_prekey_low(client).await;
59 }
60 Some("digest") => {
61 handle_digest_key(client);
62 }
63 other => {
64 warn!("Unhandled encrypt notification child: {:?}", other);
65 }
66 }
67 }
68 }
69 "server_sync" => {
70 use std::str::FromStr;
74 use wacore::appstate::patch_decode::WAPatchName;
75
76 let mut collections = Vec::new();
77 if let Some(children) = node.children() {
78 for collection_node in children.iter().filter(|c| c.tag == "collection") {
79 let name_cow = collection_node.attrs().optional_string("name");
80 let name_str = name_cow.as_deref().unwrap_or("<unknown>");
81 let server_version =
82 collection_node.attrs().optional_u64("version").unwrap_or(0);
83 debug!(
84 target: "Client/AppState",
85 "Received server_sync for collection '{}' version {}",
86 name_str, server_version
87 );
88 if let Ok(patch_name) = WAPatchName::from_str(name_str)
89 && !matches!(patch_name, WAPatchName::Unknown)
90 {
91 collections.push((patch_name, server_version));
92 }
93 }
94 }
95
96 if !collections.is_empty() {
97 let client_clone = client.clone();
98 let generation = client
99 .connection_generation
100 .load(std::sync::atomic::Ordering::SeqCst);
101 client.runtime.spawn(Box::pin(async move {
102 if client_clone
104 .connection_generation
105 .load(std::sync::atomic::Ordering::SeqCst)
106 != generation
107 {
108 log::debug!(target: "Client/AppState", "server_sync task cancelled: connection generation changed");
109 return;
110 }
111
112 let backend = client_clone.persistence_manager.backend();
115 let mut to_sync = Vec::new();
116 for (name, server_version) in collections {
117 if server_version > 0 {
118 match backend.get_version(name.as_str()).await {
119 Ok(state) if state.version >= server_version => {
120 debug!(
121 target: "Client/AppState",
122 "Skipping server_sync for {:?}: local version {} >= server version {}",
123 name, state.version, server_version
124 );
125 continue;
126 }
127 Ok(_) => {}
128 Err(e) => {
129 warn!(
130 target: "Client/AppState",
131 "Failed to get local version for {:?}: {e}, syncing anyway", name
132 );
133 }
134 }
135 }
136 to_sync.push(name);
137 }
138
139 if !to_sync.is_empty()
140 && let Err(e) = client_clone.sync_collections_batched(to_sync).await
141 {
142 warn!(
143 target: "Client/AppState",
144 "Failed to batch sync app state from server_sync: {e}"
145 );
146 }
147 })).detach();
148 }
149 }
150 "account_sync" => {
151 if let Some(new_push_name) = node.attrs().optional_string("pushname") {
153 client
154 .clone()
155 .update_push_name_and_notify(new_push_name.to_string())
156 .await;
157 }
158
159 if let Some(devices_node) = node.get_optional_child_by_tag(&["devices"]) {
162 handle_account_sync_devices(client, node, devices_node).await;
163 }
164 }
165 "devices" => {
166 handle_devices_notification(client, node).await;
169 }
170 "link_code_companion_reg" => {
171 crate::pair_code::handle_pair_code_notification(client, node).await;
174 }
175 "business" => {
176 handle_business_notification(client, node).await;
179 }
180 "picture" => {
181 handle_picture_notification(client, node);
183 }
184 "privacy_token" => {
185 handle_privacy_token_notification(client, node).await;
188 }
189 "status" => {
190 handle_status_notification(client, node);
192 }
193 "contacts" => {
194 handle_contacts_notification(client, node).await;
195 }
196 "w:gp2" => {
197 handle_group_notification(client, node).await;
198 }
199 "disappearing_mode" => {
200 handle_disappearing_mode_notification(client, node);
205 }
206 "newsletter" => {
207 handle_newsletter_notification(client, node);
208 }
209 "mediaretry" => {
210 debug!(
213 "Received mediaretry notification for msg {}",
214 node.attrs().optional_string("id").unwrap_or_default()
215 );
216 }
217 _ => {
218 debug!("Unhandled notification type '{notification_type}', dispatching raw event");
219 client
220 .core
221 .event_bus
222 .dispatch(&Event::Notification(node.clone()));
223 }
224 }
225}
226
227async fn handle_prekey_low(client: &Arc<Client>) {
235 client
237 .server_has_prekeys
238 .store(false, std::sync::atomic::Ordering::Relaxed);
239
240 let client_clone = client.clone();
241 client
242 .runtime
243 .spawn(Box::pin(async move {
244 client_clone.wait_for_offline_delivery_end().await;
248
249 if !client_clone
251 .is_logged_in
252 .load(std::sync::atomic::Ordering::Relaxed)
253 {
254 debug!("Pre-key upload skipped: disconnected during offline delivery wait");
255 return;
256 }
257
258 let _guard = client_clone.prekey_upload_lock.lock().await;
260
261 if client_clone
263 .server_has_prekeys
264 .load(std::sync::atomic::Ordering::Relaxed)
265 {
266 debug!("Pre-key upload already completed by another task, skipping");
267 return;
268 }
269
270 if let Err(e) = client_clone.upload_pre_keys_with_retry(false).await {
271 warn!(
272 "Failed to upload pre-keys after prekey_low notification: {:?}",
273 e
274 );
275 }
276 }))
277 .detach();
278}
279
280fn handle_digest_key(client: &Arc<Client>) {
289 let client_clone = client.clone();
290 client
291 .runtime
292 .spawn(Box::pin(async move {
293 let _guard = client_clone.prekey_upload_lock.lock().await;
294 if let Err(e) = client_clone.validate_digest_key().await {
295 warn!("Digest key validation failed: {:?}", e);
296 }
297 }))
298 .detach();
299}
300
301async fn handle_devices_notification(client: &Arc<Client>, node: &Node) {
314 let notification = match DeviceNotification::try_parse(node) {
316 Ok(n) => n,
317 Err(e) => {
318 warn!("Failed to parse device notification: {e}");
319 return;
320 }
321 };
322
323 if let Some((lid, pn)) = notification.lid_pn_mapping()
325 && let Err(e) = client
326 .add_lid_pn_mapping(lid, pn, LearningSource::DeviceNotification)
327 .await
328 {
329 warn!("Failed to add LID-PN mapping from device notification: {e}");
330 }
331
332 let op = ¬ification.operation;
336 debug!(
337 "Device notification: user={}, type={:?}, devices={:?}",
338 notification.user(),
339 op.operation_type,
340 op.device_ids()
341 );
342
343 match op.operation_type {
344 wacore::stanza::devices::DeviceNotificationType::Add => {
345 for device in &op.devices {
346 client
347 .patch_device_add(notification.user(), ¬ification.from, device)
348 .await;
349 }
350 }
351 wacore::stanza::devices::DeviceNotificationType::Remove => {
352 for device in &op.devices {
353 client
354 .patch_device_remove(
355 notification.user(),
356 ¬ification.from,
357 device.device_id(),
358 )
359 .await;
360 }
361 }
362 wacore::stanza::devices::DeviceNotificationType::Update => {
363 if op.devices.is_empty() {
364 client.invalidate_device_cache(notification.user()).await;
367 } else {
368 for device in &op.devices {
369 client
370 .patch_device_update(notification.user(), device)
371 .await;
372 }
373 }
374 }
375 }
376
377 let event = Event::DeviceListUpdate(DeviceListUpdate {
379 user: notification.from.clone(),
380 lid_user: notification.lid_user.clone(),
381 update_type: op.operation_type.into(),
382 devices: op
383 .devices
384 .iter()
385 .map(|d| DeviceNotificationInfo {
386 device_id: d.device_id(),
387 key_index: d.key_index,
388 })
389 .collect(),
390 key_index: op.key_index.clone(),
391 contact_hash: op.contact_hash.clone(),
392 });
393 client.core.event_bus.dispatch(&event);
394}
395
396struct AccountSyncDevice {
398 jid: Jid,
399 key_index: Option<u32>,
400}
401
402fn parse_account_sync_device_list(devices_node: &Node) -> Vec<AccountSyncDevice> {
413 let Some(children) = devices_node.children() else {
414 return Vec::new();
415 };
416
417 children
418 .iter()
419 .filter(|n| n.tag == "device")
420 .filter_map(|n| {
421 let jid = n.attrs().optional_jid("jid")?;
422 let key_index = n.attrs().optional_u64("key-index").map(|v| v as u32);
423 Some(AccountSyncDevice { jid, key_index })
424 })
425 .collect()
426}
427
428async fn handle_account_sync_devices(client: &Arc<Client>, node: &Node, devices_node: &Node) {
439 let from_jid = match node.attrs().optional_jid("from") {
441 Some(jid) => jid,
442 None => {
443 warn!(target: "Client/AccountSync", "account_sync devices missing 'from' attribute");
444 return;
445 }
446 };
447
448 let device_snapshot = client.persistence_manager.get_device_snapshot().await;
450 let own_pn = device_snapshot.pn.as_ref();
451 let own_lid = device_snapshot.lid.as_ref();
452
453 let is_own_account = own_pn.is_some_and(|pn| pn.is_same_user_as(&from_jid))
456 || own_lid.is_some_and(|lid| lid.is_same_user_as(&from_jid));
457
458 if !is_own_account {
459 warn!(
461 target: "Client/AccountSync",
462 "Received account_sync devices for non-self user: {} (our PN: {:?}, LID: {:?})",
463 from_jid,
464 own_pn.map(|j| j.user.as_str()),
465 own_lid.map(|j| j.user.as_str())
466 );
467 return;
468 }
469
470 let devices = parse_account_sync_device_list(devices_node);
472 if devices.is_empty() {
473 debug!(target: "Client/AccountSync", "account_sync devices list is empty");
474 return;
475 }
476
477 let dhash = devices_node
479 .attrs()
480 .optional_string("dhash")
481 .map(|s| s.into_owned());
482
483 let timestamp = node
485 .attrs()
486 .optional_u64("t")
487 .map(|v| v as i64)
488 .unwrap_or_else(wacore::time::now_secs);
489
490 let device_list = DeviceListRecord {
493 user: from_jid.user.clone(),
494 devices: devices
495 .iter()
496 .map(|d| DeviceInfo {
497 device_id: d.jid.device as u32,
498 key_index: d.key_index,
499 })
500 .collect(),
501 timestamp,
502 phash: dhash,
503 };
504
505 if let Err(e) = client.update_device_list(device_list).await {
506 warn!(
507 target: "Client/AccountSync",
508 "Failed to update device list from account_sync: {}",
509 e
510 );
511 return;
512 }
513
514 info!(
515 target: "Client/AccountSync",
516 "Updated own device list from account_sync: {} devices (user: {})",
517 devices.len(),
518 from_jid.user
519 );
520
521 for device in &devices {
523 debug!(
524 target: "Client/AccountSync",
525 " Device: {} (key-index: {:?})",
526 device.jid,
527 device.key_index
528 );
529 }
530}
531
532async fn handle_privacy_token_notification(client: &Arc<Client>, node: &Node) {
546 use wacore::iq::tctoken::parse_privacy_token_notification;
547 use wacore::store::traits::TcTokenEntry;
548
549 let sender_lid = node
552 .attrs()
553 .optional_jid("sender_lid")
554 .map(|j| j.user.clone());
555
556 let sender_lid = match sender_lid {
557 Some(lid) if !lid.is_empty() => lid,
558 _ => {
559 let from_jid = match node.attrs().optional_jid("from") {
561 Some(jid) => jid,
562 None => {
563 warn!(target: "Client/TcToken", "privacy_token notification missing 'from' attribute");
564 return;
565 }
566 };
567
568 if from_jid.is_lid() {
569 from_jid.user.clone()
570 } else {
571 match client.lid_pn_cache.get_current_lid(&from_jid.user).await {
573 Some(lid) => lid,
574 None => {
575 debug!(
576 target: "Client/TcToken",
577 "Cannot resolve LID for privacy_token sender {}, storing under PN",
578 from_jid
579 );
580 from_jid.user.clone()
581 }
582 }
583 }
584 }
585 };
586
587 let received_tokens = match parse_privacy_token_notification(node) {
589 Ok(tokens) => tokens,
590 Err(e) => {
591 warn!(target: "Client/TcToken", "Failed to parse privacy_token notification: {e}");
592 return;
593 }
594 };
595
596 if received_tokens.is_empty() {
597 debug!(target: "Client/TcToken", "privacy_token notification had no trusted_contact tokens");
598 return;
599 }
600
601 let backend = client.persistence_manager.backend();
602
603 for received in &received_tokens {
604 match backend.get_tc_token(&sender_lid).await {
605 Ok(Some(existing)) => {
606 if received.timestamp < existing.token_timestamp {
608 debug!(
609 target: "Client/TcToken",
610 "Skipping older token for {} (incoming={}, existing={})",
611 sender_lid, received.timestamp, existing.token_timestamp
612 );
613 continue;
614 }
615
616 let entry = TcTokenEntry {
618 token: received.token.clone(),
619 token_timestamp: received.timestamp,
620 sender_timestamp: existing.sender_timestamp,
621 };
622
623 if let Err(e) = backend.put_tc_token(&sender_lid, &entry).await {
624 warn!(target: "Client/TcToken", "Failed to update tc_token for {}: {e}", sender_lid);
625 } else {
626 debug!(target: "Client/TcToken", "Updated tc_token for {} (t={})", sender_lid, received.timestamp);
627 }
628 }
629 Ok(None) => {
630 let entry = TcTokenEntry {
632 token: received.token.clone(),
633 token_timestamp: received.timestamp,
634 sender_timestamp: None,
635 };
636
637 if let Err(e) = backend.put_tc_token(&sender_lid, &entry).await {
638 warn!(target: "Client/TcToken", "Failed to store tc_token for {}: {e}", sender_lid);
639 } else {
640 debug!(target: "Client/TcToken", "Stored new tc_token for {} (t={})", sender_lid, received.timestamp);
641 }
642 }
643 Err(e) => {
644 warn!(target: "Client/TcToken", "Failed to read tc_token for {}: {e}, skipping", sender_lid);
645 }
646 }
647 }
648}
649
650async fn handle_business_notification(client: &Arc<Client>, node: &Node) {
652 let notification = match BusinessNotification::try_parse(node) {
653 Ok(n) => n,
654 Err(e) => {
655 warn!(target: "Client/Business", "Failed to parse business notification: {e}");
656 return;
657 }
658 };
659
660 debug!(
661 target: "Client/Business",
662 "Business notification: from={}, type={}, jid={:?}",
663 notification.from,
664 notification.notification_type,
665 notification.jid
666 );
667
668 let update_type = BusinessUpdateType::from(notification.notification_type.clone());
669 let verified_name = notification
670 .verified_name
671 .as_ref()
672 .and_then(|vn| vn.name.clone());
673
674 let event = Event::BusinessStatusUpdate(BusinessStatusUpdate {
675 jid: notification.from.clone(),
676 update_type,
677 timestamp: notification.timestamp,
678 target_jid: notification.jid.clone(),
679 hash: notification.hash.clone(),
680 verified_name,
681 product_ids: notification.product_ids.clone(),
682 collection_ids: notification.collection_ids.clone(),
683 subscriptions: notification.subscriptions.clone(),
684 });
685
686 match notification.notification_type {
687 wacore::stanza::business::BusinessNotificationType::RemoveJid
688 | wacore::stanza::business::BusinessNotificationType::RemoveHash => {
689 info!(
690 target: "Client/Business",
691 "Contact {} is no longer a business account",
692 notification.from
693 );
694 }
695 wacore::stanza::business::BusinessNotificationType::VerifiedNameJid
696 | wacore::stanza::business::BusinessNotificationType::VerifiedNameHash => {
697 if let Some(name) = ¬ification
698 .verified_name
699 .as_ref()
700 .and_then(|vn| vn.name.as_ref())
701 {
702 info!(
703 target: "Client/Business",
704 "Contact {} verified business name: {}",
705 notification.from,
706 name
707 );
708 }
709 }
710 wacore::stanza::business::BusinessNotificationType::Profile
711 | wacore::stanza::business::BusinessNotificationType::ProfileHash => {
712 debug!(
713 target: "Client/Business",
714 "Contact {} business profile updated (hash: {:?})",
715 notification.from,
716 notification.hash
717 );
718 }
719 _ => {}
720 }
721
722 client.core.event_bus.dispatch(&event);
723}
724
725fn handle_picture_notification(client: &Arc<Client>, node: &Node) {
743 let from = match node.attrs().optional_jid("from") {
744 Some(jid) => jid,
745 None => {
746 warn!(target: "Client/Picture", "picture notification missing 'from' attribute");
747 return;
748 }
749 };
750
751 let timestamp = node
752 .attrs()
753 .optional_u64("t")
754 .map(|t| chrono::DateTime::from_timestamp(t as i64, 0).unwrap_or_else(chrono::Utc::now))
755 .unwrap_or_else(chrono::Utc::now);
756
757 let (jid, author, removed, picture_id) = if let Some(set_node) = node.get_optional_child("set")
762 {
763 let jid = set_node.attrs().optional_jid("jid").unwrap_or_else(|| {
764 if set_node.attrs().optional_string("hash").is_some() {
765 debug!(
766 target: "Client/Picture",
767 "Hash-based picture notification (no jid), using from={}", from
768 );
769 }
770 from.clone()
771 });
772 let author = set_node.attrs().optional_jid("author");
773 let pic_id = set_node
774 .attrs()
775 .optional_string("id")
776 .map(|s| s.to_string());
777 (jid, author, false, pic_id)
778 } else if let Some(delete_node) = node.get_optional_child("delete") {
779 let jid = delete_node
780 .attrs()
781 .optional_jid("jid")
782 .unwrap_or_else(|| from.clone());
783 let author = delete_node.attrs().optional_jid("author");
784 (jid, author, true, None)
785 } else {
786 let children = node.children().map(|c| c.len()).unwrap_or(0);
789 if children == 0 {
790 let jid = node
791 .attrs()
792 .optional_jid("jid")
793 .unwrap_or_else(|| from.clone());
794 let author = node.attrs().optional_jid("author");
795 (jid, author, true, None)
796 } else {
797 let child_tag = node
799 .children()
800 .and_then(|c| c.first().map(|n| n.tag.as_ref()));
801 debug!(
802 target: "Client/Picture",
803 "Ignoring picture notification with child {:?} from {}", child_tag, from
804 );
805 return;
806 }
807 };
808
809 debug!(
810 target: "Client/Picture",
811 "Picture {}: jid={}, author={:?}, pic_id={:?}",
812 if removed { "removed" } else { "updated" },
813 jid, author, picture_id
814 );
815
816 let event = Event::PictureUpdate(PictureUpdate {
817 jid,
818 author,
819 timestamp,
820 removed,
821 picture_id,
822 });
823 client.core.event_bus.dispatch(&event);
824}
825
826fn handle_status_notification(client: &Arc<Client>, node: &Node) {
837 let from = match node.attrs().optional_jid("from") {
838 Some(jid) => jid,
839 None => {
840 warn!(target: "Client/Status", "status notification missing 'from' attribute");
841 return;
842 }
843 };
844
845 let timestamp = node
846 .attrs()
847 .optional_u64("t")
848 .map(|t| chrono::DateTime::from_timestamp(t as i64, 0).unwrap_or_else(chrono::Utc::now))
849 .unwrap_or_else(chrono::Utc::now);
850
851 if let Some(set_node) = node.get_optional_child("set") {
852 let status_text = match &set_node.content {
853 Some(wacore_binary::node::NodeContent::String(s)) => s.clone(),
854 Some(wacore_binary::node::NodeContent::Bytes(b)) => {
855 String::from_utf8_lossy(b).into_owned()
856 }
857 _ => String::new(),
858 };
859
860 debug!(
861 target: "Client/Status",
862 "Status update from {} (length={})", from, status_text.len()
863 );
864
865 let event = Event::UserAboutUpdate(UserAboutUpdate {
866 jid: from,
867 status: status_text,
868 timestamp,
869 });
870 client.core.event_bus.dispatch(&event);
871 } else {
872 debug!(
873 target: "Client/Status",
874 "Status notification from {} without <set> child, ignoring", from
875 );
876 }
877}
878
879fn notification_timestamp(node: &Node) -> chrono::DateTime<chrono::Utc> {
880 node.attrs()
881 .optional_u64("t")
882 .map(|t| chrono::DateTime::from_timestamp(t as i64, 0).unwrap_or_else(chrono::Utc::now))
883 .unwrap_or_else(chrono::Utc::now)
884}
885
886async fn learn_contact_modify_mappings(
896 client: &Arc<Client>,
897 old_pn: &Jid,
898 new_pn: &Jid,
899 old_lid: Option<&Jid>,
900 new_lid: Option<&Jid>,
901) {
902 if let (Some(old_lid), Some(new_lid)) = (old_lid, new_lid) {
904 for (lid, pn) in [(old_lid, old_pn), (new_lid, new_pn)] {
905 if let Err(e) = client
906 .add_lid_pn_mapping(&lid.user, &pn.user, LearningSource::DeviceNotification)
907 .await
908 {
909 warn!(
910 target: "Client/Contacts",
911 "Failed to add LID-PN mapping lid={} pn={}: {e}",
912 lid, pn
913 );
914 }
915 }
916 } else {
917 debug!(
918 target: "Client/Contacts",
919 "Contacts modify without old_lid/new_lid, skipping LID-PN mapping (old={}, new={})",
920 old_pn, new_pn
921 );
922 }
923}
924
925async fn handle_contacts_notification(client: &Arc<Client>, node: &Node) {
938 let timestamp = notification_timestamp(node);
939
940 let Some(child) = node.children().and_then(|children| children.first()) else {
941 debug!(
942 target: "Client/Contacts",
943 "Ignoring contacts notification without child action"
944 );
945 return;
946 };
947
948 match child.tag.as_ref() {
949 "update" => {
950 let Some(jid) = child.attrs().optional_jid("jid") else {
951 debug!(target: "Client/Contacts", "contacts update with hash but no jid, ignoring (hash={:?})",
955 child.attrs().optional_string("hash"));
956 return;
957 };
958
959 debug!(target: "Client/Contacts", "Contact updated for {}", jid);
960 client
961 .core
962 .event_bus
963 .dispatch(&Event::ContactUpdated(ContactUpdated { jid, timestamp }));
964 }
965 "modify" => {
966 let mut child_attrs = child.attrs();
968 let Some(old_jid) = child_attrs.optional_jid("old") else {
969 warn!(target: "Client/Contacts", "contacts modify missing 'old' attribute");
970 return;
971 };
972 let Some(new_jid) = child_attrs.optional_jid("new") else {
973 warn!(target: "Client/Contacts", "contacts modify missing 'new' attribute");
974 return;
975 };
976 let old_lid = child_attrs.optional_jid("old_lid");
977 let new_lid = child_attrs.optional_jid("new_lid");
978
979 learn_contact_modify_mappings(
980 client,
981 &old_jid,
982 &new_jid,
983 old_lid.as_ref(),
984 new_lid.as_ref(),
985 )
986 .await;
987
988 debug!(
989 target: "Client/Contacts",
990 "Contact number changed: {} -> {} (old_lid={:?}, new_lid={:?})",
991 old_jid, new_jid, old_lid, new_lid
992 );
993 client
994 .core
995 .event_bus
996 .dispatch(&Event::ContactNumberChanged(ContactNumberChanged {
997 old_jid,
998 new_jid,
999 old_lid,
1000 new_lid,
1001 timestamp,
1002 }));
1003 }
1004 "sync" => {
1005 let after = child
1006 .attrs()
1007 .optional_u64("after")
1008 .and_then(|after| chrono::DateTime::from_timestamp(after as i64, 0));
1009
1010 debug!(
1011 target: "Client/Contacts",
1012 "Contact sync requested after {:?}",
1013 after
1014 );
1015 client
1016 .core
1017 .event_bus
1018 .dispatch(&Event::ContactSyncRequested(ContactSyncRequested {
1019 after,
1020 timestamp,
1021 }));
1022 }
1023 "add" | "remove" => {
1024 debug!(
1025 target: "Client/Contacts",
1026 "Contact {} notification handled without extra work",
1027 child.tag
1028 );
1029 }
1030 other => {
1031 debug!(
1032 target: "Client/Contacts",
1033 "Ignoring unknown contacts notification child {:?}",
1034 other
1035 );
1036 }
1037 }
1038}
1039
1040async fn handle_group_notification(client: &Arc<Client>, node: &Node) {
1047 let notification = match GroupNotification::try_from_node(node) {
1048 Some(n) => n,
1049 None => {
1050 warn!(target: "Client/Group", "w:gp2 notification missing 'from' attribute");
1051 return;
1052 }
1053 };
1054
1055 let timestamp = i64::try_from(notification.timestamp)
1056 .ok()
1057 .and_then(|t| chrono::DateTime::from_timestamp(t, 0))
1058 .unwrap_or_else(chrono::Utc::now);
1059
1060 for action in notification.actions {
1061 match &action {
1065 GroupNotificationAction::Add { participants, .. } => {
1066 let group_cache = client.get_group_cache().await;
1067 if let Some(mut info) = group_cache.get(¬ification.group_jid).await {
1068 let new: Vec<_> = participants
1069 .iter()
1070 .map(|p| (p.jid.clone(), p.phone_number.clone()))
1071 .collect();
1072 info.add_participants(&new);
1073 group_cache
1074 .insert(notification.group_jid.clone(), info)
1075 .await;
1076 debug!(
1077 target: "Client/Group",
1078 "Patched group cache for {}: added {} participants",
1079 notification.group_jid, participants.len()
1080 );
1081 }
1082 }
1083 GroupNotificationAction::Remove { participants, .. } => {
1084 let group_cache = client.get_group_cache().await;
1085 if let Some(mut info) = group_cache.get(¬ification.group_jid).await {
1086 let users: Vec<&str> =
1087 participants.iter().map(|p| p.jid.user.as_str()).collect();
1088 info.remove_participants(&users);
1089 group_cache
1090 .insert(notification.group_jid.clone(), info)
1091 .await;
1092 debug!(
1093 target: "Client/Group",
1094 "Patched group cache for {}: removed {} participants",
1095 notification.group_jid, participants.len()
1096 );
1097 }
1098 }
1099 _ => {}
1100 }
1101
1102 debug!(
1103 target: "Client/Group",
1104 "Group notification: group={}, action={}",
1105 notification.group_jid, action.tag_name()
1106 );
1107
1108 client
1109 .core
1110 .event_bus
1111 .dispatch(&Event::GroupUpdate(GroupUpdate {
1112 group_jid: notification.group_jid.clone(),
1113 participant: notification.participant.clone(),
1114 participant_pn: notification.participant_pn.clone(),
1115 timestamp,
1116 is_lid_addressing_mode: notification.is_lid_addressing_mode,
1117 action,
1118 }));
1119 }
1120
1121 client
1123 .core
1124 .event_bus
1125 .dispatch(&Event::Notification(node.clone()));
1126}
1127
1128fn handle_newsletter_notification(client: &Arc<Client>, node: &Node) {
1143 use crate::features::newsletter::parse_reaction_counts;
1144 use wacore::types::events::{
1145 NewsletterLiveUpdate, NewsletterLiveUpdateMessage, NewsletterLiveUpdateReaction,
1146 };
1147
1148 let Some(newsletter_jid) = node.attrs().optional_jid("from") else {
1149 return;
1150 };
1151
1152 if let Some(live_updates) = node.get_optional_child("live_updates")
1153 && let Some(messages_node) = live_updates.get_optional_child("messages")
1154 && let Some(children) = messages_node.children()
1155 {
1156 let messages: Vec<_> = children
1157 .iter()
1158 .filter(|n| n.tag.as_ref() == "message")
1159 .filter_map(|msg_node| {
1160 let server_id = msg_node
1161 .attrs
1162 .get("server_id")
1163 .map(|v| v.as_str())
1164 .and_then(|s| s.parse::<u64>().ok())?;
1165
1166 let reactions = parse_reaction_counts(msg_node)
1167 .into_iter()
1168 .map(|r| NewsletterLiveUpdateReaction {
1169 code: r.code,
1170 count: r.count,
1171 })
1172 .collect();
1173
1174 Some(NewsletterLiveUpdateMessage {
1175 server_id,
1176 reactions,
1177 })
1178 })
1179 .collect();
1180
1181 if !messages.is_empty() {
1182 client
1183 .core
1184 .event_bus
1185 .dispatch(&Event::NewsletterLiveUpdate(NewsletterLiveUpdate {
1186 newsletter_jid,
1187 messages,
1188 }));
1189 }
1190 }
1191
1192 client
1194 .core
1195 .event_bus
1196 .dispatch(&Event::Notification(node.clone()));
1197}
1198
1199fn handle_disappearing_mode_notification(client: &Arc<Client>, node: &Node) {
1210 let mut attrs = node.attrs();
1211 let from = attrs.jid("from").to_non_ad();
1212
1213 let Some(dm_node) = node.get_optional_child("disappearing_mode") else {
1214 warn!(
1215 "disappearing_mode notification missing <disappearing_mode> child: {}",
1216 wacore::xml::DisplayableNode(node)
1217 );
1218 return;
1219 };
1220
1221 let mut dm_attrs = dm_node.attrs();
1222
1223 let duration = dm_attrs
1225 .optional_string("duration")
1226 .and_then(|s| s.parse::<u32>().ok())
1227 .unwrap_or(0);
1228
1229 let Some(setting_timestamp) = dm_attrs
1231 .optional_string("t")
1232 .and_then(|s| s.parse::<u64>().ok())
1233 else {
1234 warn!(
1235 "disappearing_mode notification missing or invalid 't' attribute: {}",
1236 wacore::xml::DisplayableNode(node)
1237 );
1238 return;
1239 };
1240
1241 debug!(
1242 "Disappearing mode changed for {}: duration={}s, t={}",
1243 from, duration, setting_timestamp
1244 );
1245
1246 client
1247 .core
1248 .event_bus
1249 .dispatch(&Event::DisappearingModeChanged(
1250 wacore::types::events::DisappearingModeChanged {
1251 from,
1252 duration,
1253 setting_timestamp,
1254 },
1255 ));
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260 use super::*;
1261 use crate::test_utils::create_test_client;
1262 use std::sync::{Arc, Mutex};
1263 use wacore::stanza::devices::DeviceNotificationType;
1264 use wacore::types::events::{DeviceListUpdateType, EventHandler};
1265 use wacore_binary::builder::NodeBuilder;
1266
1267 #[derive(Default)]
1268 struct TestEventCollector {
1269 events: Mutex<Vec<Event>>,
1270 }
1271
1272 impl EventHandler for TestEventCollector {
1273 fn handle_event(&self, event: &Event) {
1274 self.events
1275 .lock()
1276 .expect("collector mutex should not be poisoned")
1277 .push(event.clone());
1278 }
1279 }
1280
1281 impl TestEventCollector {
1282 fn events(&self) -> Vec<Event> {
1283 self.events
1284 .lock()
1285 .expect("collector mutex should not be poisoned")
1286 .clone()
1287 }
1288 }
1289
1290 #[test]
1291 fn test_parse_device_add_notification() {
1292 let node = NodeBuilder::new("notification")
1294 .attr("type", "devices")
1295 .attr("from", "1234567890@s.whatsapp.net")
1296 .children([NodeBuilder::new("add")
1297 .children([
1298 NodeBuilder::new("device")
1299 .attr("jid", "1234567890:1@s.whatsapp.net")
1300 .build(),
1301 NodeBuilder::new("key-index-list")
1302 .attr("ts", "1000")
1303 .bytes(vec![0x01, 0x02, 0x03])
1304 .build(),
1305 ])
1306 .build()])
1307 .build();
1308
1309 let parsed = DeviceNotification::try_parse(&node).unwrap();
1310 assert_eq!(parsed.operation.operation_type, DeviceNotificationType::Add);
1311 assert_eq!(parsed.operation.device_ids(), vec![1]);
1312 assert!(parsed.operation.key_index.is_some());
1314 assert_eq!(parsed.operation.key_index.as_ref().unwrap().timestamp, 1000);
1315 }
1316
1317 #[test]
1318 fn test_parse_device_remove_notification() {
1319 let node = NodeBuilder::new("notification")
1320 .attr("type", "devices")
1321 .attr("from", "1234567890@s.whatsapp.net")
1322 .children([NodeBuilder::new("remove")
1323 .children([
1324 NodeBuilder::new("device")
1325 .attr("jid", "1234567890:3@s.whatsapp.net")
1326 .build(),
1327 NodeBuilder::new("key-index-list")
1328 .attr("ts", "2000")
1329 .build(),
1330 ])
1331 .build()])
1332 .build();
1333
1334 let parsed = DeviceNotification::try_parse(&node).unwrap();
1335 assert_eq!(
1336 parsed.operation.operation_type,
1337 DeviceNotificationType::Remove
1338 );
1339 assert_eq!(parsed.operation.device_ids(), vec![3]);
1340 }
1341
1342 #[test]
1343 fn test_parse_device_update_notification_with_hash() {
1344 let node = NodeBuilder::new("notification")
1345 .attr("type", "devices")
1346 .attr("from", "1234567890@s.whatsapp.net")
1347 .children([NodeBuilder::new("update")
1348 .attr("hash", "2:abcdef123456")
1349 .build()])
1350 .build();
1351
1352 let parsed = DeviceNotification::try_parse(&node).unwrap();
1353 assert_eq!(
1354 parsed.operation.operation_type,
1355 DeviceNotificationType::Update
1356 );
1357 assert_eq!(
1358 parsed.operation.contact_hash,
1359 Some("2:abcdef123456".to_string())
1360 );
1361 assert!(parsed.operation.devices.is_empty());
1363 }
1364
1365 #[test]
1366 fn test_parse_empty_device_notification_fails() {
1367 let node = NodeBuilder::new("notification")
1369 .attr("type", "devices")
1370 .attr("from", "1234567890@s.whatsapp.net")
1371 .build();
1372
1373 let result = DeviceNotification::try_parse(&node);
1374 assert!(result.is_err());
1375 assert!(
1376 result
1377 .unwrap_err()
1378 .to_string()
1379 .contains("missing required operation")
1380 );
1381 }
1382
1383 #[test]
1384 fn test_parse_multiple_operations_uses_priority() {
1385 let node = NodeBuilder::new("notification")
1388 .attr("type", "devices")
1389 .attr("from", "1234567890@s.whatsapp.net")
1390 .children([
1391 NodeBuilder::new("add")
1392 .children([
1393 NodeBuilder::new("device")
1394 .attr("jid", "1234567890:5@s.whatsapp.net")
1395 .build(),
1396 NodeBuilder::new("key-index-list")
1397 .attr("ts", "3000")
1398 .build(),
1399 ])
1400 .build(),
1401 NodeBuilder::new("remove")
1402 .children([
1403 NodeBuilder::new("device")
1404 .attr("jid", "1234567890:2@s.whatsapp.net")
1405 .build(),
1406 NodeBuilder::new("key-index-list")
1407 .attr("ts", "3001")
1408 .build(),
1409 ])
1410 .build(),
1411 ])
1412 .build();
1413
1414 let parsed = DeviceNotification::try_parse(&node).unwrap();
1415 assert_eq!(
1417 parsed.operation.operation_type,
1418 DeviceNotificationType::Remove
1419 );
1420 assert_eq!(parsed.operation.device_ids(), vec![2]);
1421 }
1422
1423 #[test]
1424 fn test_device_list_update_type_from_notification_type() {
1425 assert_eq!(
1426 DeviceListUpdateType::from(DeviceNotificationType::Add),
1427 DeviceListUpdateType::Add
1428 );
1429 assert_eq!(
1430 DeviceListUpdateType::from(DeviceNotificationType::Remove),
1431 DeviceListUpdateType::Remove
1432 );
1433 assert_eq!(
1434 DeviceListUpdateType::from(DeviceNotificationType::Update),
1435 DeviceListUpdateType::Update
1436 );
1437 }
1438
1439 #[test]
1442 fn test_parse_account_sync_device_list_basic() {
1443 let devices_node = NodeBuilder::new("devices")
1444 .attr("dhash", "2:FnEWjS13")
1445 .children([
1446 NodeBuilder::new("device")
1447 .attr("jid", "15551234567@s.whatsapp.net")
1448 .build(),
1449 NodeBuilder::new("device")
1450 .attr("jid", "15551234567:64@s.whatsapp.net")
1451 .attr("key-index", "2")
1452 .build(),
1453 ])
1454 .build();
1455
1456 let devices = parse_account_sync_device_list(&devices_node);
1457 assert_eq!(devices.len(), 2);
1458
1459 assert_eq!(devices[0].jid.user, "15551234567");
1461 assert_eq!(devices[0].jid.device, 0);
1462 assert_eq!(devices[0].key_index, None);
1463
1464 assert_eq!(devices[1].jid.user, "15551234567");
1466 assert_eq!(devices[1].jid.device, 64);
1467 assert_eq!(devices[1].key_index, Some(2));
1468 }
1469
1470 #[test]
1471 fn test_parse_account_sync_device_list_with_key_index_list() {
1472 let devices_node = NodeBuilder::new("devices")
1474 .attr("dhash", "2:FnEWjS13")
1475 .children([
1476 NodeBuilder::new("device")
1477 .attr("jid", "15551234567@s.whatsapp.net")
1478 .build(),
1479 NodeBuilder::new("device")
1480 .attr("jid", "15551234567:77@s.whatsapp.net")
1481 .attr("key-index", "15")
1482 .build(),
1483 NodeBuilder::new("key-index-list")
1484 .attr("ts", "1766612162")
1485 .bytes(vec![0x01, 0x02, 0x03]) .build(),
1487 ])
1488 .build();
1489
1490 let devices = parse_account_sync_device_list(&devices_node);
1491 assert_eq!(devices.len(), 2);
1493 assert_eq!(devices[0].jid.device, 0);
1494 assert_eq!(devices[1].jid.device, 77);
1495 assert_eq!(devices[1].key_index, Some(15));
1496 }
1497
1498 #[test]
1499 fn test_parse_account_sync_device_list_empty() {
1500 let devices_node = NodeBuilder::new("devices")
1501 .attr("dhash", "2:FnEWjS13")
1502 .build();
1503
1504 let devices = parse_account_sync_device_list(&devices_node);
1505 assert!(devices.is_empty());
1506 }
1507
1508 #[test]
1509 fn test_parse_account_sync_device_list_multiple_devices() {
1510 let devices_node = NodeBuilder::new("devices")
1511 .attr("dhash", "2:XYZ123")
1512 .children([
1513 NodeBuilder::new("device")
1514 .attr("jid", "1234567890@s.whatsapp.net")
1515 .build(),
1516 NodeBuilder::new("device")
1517 .attr("jid", "1234567890:1@s.whatsapp.net")
1518 .attr("key-index", "1")
1519 .build(),
1520 NodeBuilder::new("device")
1521 .attr("jid", "1234567890:2@s.whatsapp.net")
1522 .attr("key-index", "5")
1523 .build(),
1524 NodeBuilder::new("device")
1525 .attr("jid", "1234567890:3@s.whatsapp.net")
1526 .attr("key-index", "10")
1527 .build(),
1528 ])
1529 .build();
1530
1531 let devices = parse_account_sync_device_list(&devices_node);
1532 assert_eq!(devices.len(), 4);
1533
1534 assert_eq!(devices[0].jid.device, 0);
1536 assert_eq!(devices[1].jid.device, 1);
1537 assert_eq!(devices[2].jid.device, 2);
1538 assert_eq!(devices[3].jid.device, 3);
1539
1540 assert_eq!(devices[0].key_index, None);
1542 assert_eq!(devices[1].key_index, Some(1));
1543 assert_eq!(devices[2].key_index, Some(5));
1544 assert_eq!(devices[3].key_index, Some(10));
1545 }
1546
1547 fn parse_disappearing_mode(node: &Node) -> Option<(u32, u64)> {
1553 let dm_node = node.get_optional_child("disappearing_mode")?;
1554 let mut dm_attrs = dm_node.attrs();
1555 let duration = dm_attrs
1556 .optional_string("duration")
1557 .and_then(|s| s.parse::<u32>().ok())
1558 .unwrap_or(0);
1559 let setting_timestamp = dm_attrs
1560 .optional_string("t")
1561 .and_then(|s| s.parse::<u64>().ok())?;
1562 Some((duration, setting_timestamp))
1563 }
1564
1565 #[test]
1566 fn test_parse_disappearing_mode_valid() {
1567 let node = NodeBuilder::new("notification")
1568 .attr("from", "5511999999999@s.whatsapp.net")
1569 .attr("type", "disappearing_mode")
1570 .children([NodeBuilder::new("disappearing_mode")
1571 .attr("duration", "86400")
1572 .attr("t", "1773519041")
1573 .build()])
1574 .build();
1575
1576 let (duration, ts) = parse_disappearing_mode(&node).expect("should parse");
1577 assert_eq!(duration, 86400);
1578 assert_eq!(ts, 1773519041);
1579 }
1580
1581 #[test]
1582 fn test_parse_disappearing_mode_disabled() {
1583 let node = NodeBuilder::new("notification")
1585 .attr("from", "5511999999999@s.whatsapp.net")
1586 .children([NodeBuilder::new("disappearing_mode")
1587 .attr("duration", "0")
1588 .attr("t", "1773519041")
1589 .build()])
1590 .build();
1591
1592 let (duration, ts) = parse_disappearing_mode(&node).expect("should parse");
1593 assert_eq!(duration, 0, "duration=0 means disabled");
1594 assert_eq!(ts, 1773519041);
1595 }
1596
1597 #[test]
1598 fn test_parse_disappearing_mode_missing_child() {
1599 let node = NodeBuilder::new("notification")
1601 .attr("from", "5511999999999@s.whatsapp.net")
1602 .attr("type", "disappearing_mode")
1603 .build();
1604
1605 assert!(
1606 parse_disappearing_mode(&node).is_none(),
1607 "should return None when child element is missing"
1608 );
1609 }
1610
1611 #[test]
1612 fn test_parse_disappearing_mode_missing_timestamp() {
1613 let node = NodeBuilder::new("notification")
1615 .attr("from", "5511999999999@s.whatsapp.net")
1616 .children([NodeBuilder::new("disappearing_mode")
1617 .attr("duration", "86400")
1618 .build()])
1619 .build();
1620
1621 assert!(
1622 parse_disappearing_mode(&node).is_none(),
1623 "should return None when 't' attribute is missing"
1624 );
1625 }
1626
1627 #[test]
1628 fn test_parse_disappearing_mode_missing_duration_defaults_to_zero() {
1629 let node = NodeBuilder::new("notification")
1631 .attr("from", "5511999999999@s.whatsapp.net")
1632 .children([NodeBuilder::new("disappearing_mode")
1633 .attr("t", "1773519041")
1634 .build()])
1635 .build();
1636
1637 let (duration, _) = parse_disappearing_mode(&node).expect("should parse");
1638 assert_eq!(duration, 0, "missing duration should default to 0");
1639 }
1640
1641 #[tokio::test]
1642 async fn test_contacts_update_dispatches_contact_updated_event() {
1643 let client = create_test_client().await;
1644 let collector = Arc::new(TestEventCollector::default());
1645 client.register_handler(collector.clone());
1646
1647 let node = NodeBuilder::new("notification")
1648 .attr("type", "contacts")
1649 .attr("from", "s.whatsapp.net")
1650 .attr("id", "contacts-update-1")
1651 .attr("t", "1773519041")
1652 .children([NodeBuilder::new("update")
1653 .attr("jid", "5511999999999@s.whatsapp.net")
1654 .build()])
1655 .build();
1656
1657 handle_notification_impl(&client, &node).await;
1658
1659 let events = collector.events();
1660 assert!(matches!(
1661 events.as_slice(),
1662 [Event::ContactUpdated(ContactUpdated { jid, .. })]
1663 if jid == &Jid::pn("5511999999999")
1664 ));
1665 }
1666
1667 #[tokio::test]
1668 async fn test_contacts_modify_with_lid_creates_mappings() {
1669 let client = create_test_client().await;
1672 let collector = Arc::new(TestEventCollector::default());
1673 client.register_handler(collector.clone());
1674
1675 let node = NodeBuilder::new("notification")
1676 .attr("type", "contacts")
1677 .attr("from", "s.whatsapp.net")
1678 .attr("id", "contacts-modify-1")
1679 .children([NodeBuilder::new("modify")
1680 .attr("old", "5511999999999@s.whatsapp.net")
1681 .attr("new", "5511888888888@s.whatsapp.net")
1682 .attr("old_lid", "100000011111111@lid")
1683 .attr("new_lid", "100000022222222@lid")
1684 .build()])
1685 .build();
1686
1687 handle_notification_impl(&client, &node).await;
1688
1689 assert_eq!(
1691 client
1692 .lid_pn_cache
1693 .get_phone_number("100000011111111")
1694 .await,
1695 Some("5511999999999".to_string()),
1696 "old_lid should map to old PN"
1697 );
1698 assert_eq!(
1699 client
1700 .lid_pn_cache
1701 .get_phone_number("100000022222222")
1702 .await,
1703 Some("5511888888888".to_string()),
1704 "new_lid should map to new PN"
1705 );
1706
1707 let events = collector.events();
1708 assert!(matches!(
1709 events.as_slice(),
1710 [Event::ContactNumberChanged(ContactNumberChanged {
1711 old_jid, new_jid, old_lid, new_lid, ..
1712 })]
1713 if old_jid == &Jid::pn("5511999999999")
1714 && new_jid == &Jid::pn("5511888888888")
1715 && old_lid.is_some()
1716 && new_lid.is_some()
1717 ));
1718 }
1719
1720 #[tokio::test]
1721 async fn test_contacts_modify_without_lid_skips_mapping() {
1722 let client = create_test_client().await;
1723 let collector = Arc::new(TestEventCollector::default());
1724 client.register_handler(collector.clone());
1725
1726 let node = NodeBuilder::new("notification")
1727 .attr("type", "contacts")
1728 .attr("from", "s.whatsapp.net")
1729 .attr("id", "contacts-modify-2")
1730 .children([NodeBuilder::new("modify")
1731 .attr("old", "5511999999999@s.whatsapp.net")
1732 .attr("new", "5511888888888@s.whatsapp.net")
1733 .build()])
1734 .build();
1735
1736 handle_notification_impl(&client, &node).await;
1737
1738 assert_eq!(collector.events().len(), 1);
1740 }
1741
1742 #[tokio::test]
1743 async fn test_contacts_sync_dispatches_contact_sync_requested_event() {
1744 let client = create_test_client().await;
1745 let collector = Arc::new(TestEventCollector::default());
1746 client.register_handler(collector.clone());
1747
1748 let node = NodeBuilder::new("notification")
1749 .attr("type", "contacts")
1750 .attr("from", "s.whatsapp.net")
1751 .attr("id", "contacts-sync-1")
1752 .children([NodeBuilder::new("sync").attr("after", "1773519041").build()])
1753 .build();
1754
1755 handle_notification_impl(&client, &node).await;
1756
1757 let events = collector.events();
1758 assert!(matches!(
1759 events.as_slice(),
1760 [Event::ContactSyncRequested(ContactSyncRequested { after, .. })]
1761 if after.is_some()
1762 ));
1763 }
1764
1765 #[tokio::test]
1766 async fn test_contacts_add_remove_do_not_dispatch_events() {
1767 let client = create_test_client().await;
1768 let collector = Arc::new(TestEventCollector::default());
1769 client.register_handler(collector.clone());
1770
1771 for tag in ["add", "remove"] {
1772 let node = NodeBuilder::new("notification")
1773 .attr("type", "contacts")
1774 .attr("from", "s.whatsapp.net")
1775 .attr("id", format!("contacts-{tag}-1"))
1776 .children([NodeBuilder::new(tag).build()])
1777 .build();
1778 handle_notification_impl(&client, &node).await;
1779 }
1780
1781 assert!(
1782 collector.events().is_empty(),
1783 "add/remove should not dispatch events"
1784 );
1785 }
1786
1787 #[tokio::test]
1788 async fn test_contacts_empty_notification_ignored() {
1789 let client = create_test_client().await;
1790 let collector = Arc::new(TestEventCollector::default());
1791 client.register_handler(collector.clone());
1792
1793 let node = NodeBuilder::new("notification")
1795 .attr("type", "contacts")
1796 .attr("from", "s.whatsapp.net")
1797 .attr("id", "contacts-empty-1")
1798 .build();
1799 handle_notification_impl(&client, &node).await;
1800
1801 assert!(
1802 collector.events().is_empty(),
1803 "empty contacts notification should not dispatch events"
1804 );
1805 }
1806
1807 #[tokio::test]
1808 async fn test_contacts_update_hash_only_ignored() {
1809 let client = create_test_client().await;
1812 let collector = Arc::new(TestEventCollector::default());
1813 client.register_handler(collector.clone());
1814
1815 let node = NodeBuilder::new("notification")
1816 .attr("type", "contacts")
1817 .attr("from", "551199887766@s.whatsapp.net")
1818 .attr("id", "3251801952")
1819 .attr("t", "1773668072")
1820 .children([NodeBuilder::new("update").attr("hash", "Quvc").build()])
1821 .build();
1822 handle_notification_impl(&client, &node).await;
1823
1824 assert!(
1825 collector.events().is_empty(),
1826 "hash-only update without jid should not dispatch events"
1827 );
1828 }
1829}