Skip to main content

whatsapp_rust/handlers/
notification.rs

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/// Handler for `<notification>` stanzas.
21///
22/// Processes various notification types including:
23/// - Encrypt notifications (key upload requests)
24/// - Server sync notifications
25/// - Account sync notifications (push name updates)
26/// - Device notifications (device add/remove/update)
27#[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                // Dispatch based on first child tag, matching WA Web's handleEncryptNotification.
51                // "count" → handlePreKeyLow, "digest" → handleDigestKey
52                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            // Server sync notifications inform us of app state changes from other devices.
71            // Matches WhatsApp Web's handleServerSyncNotification which calls
72            // markCollectionsForSync() with the parsed collection names.
73            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                    // Check if connection was replaced before starting sync
103                    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                    // Filter by version comparison before syncing.
113                    // Matches WA Web's markCollectionsForSync version comparison filter.
114                    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            // Handle push name updates
152            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            // Handle device list updates (when a new device is paired)
160            // Matches WhatsApp Web's handleAccountSyncNotification for DEVICES type
161            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 device list change notifications (WhatsApp Web: handleDevicesNotification)
167            // These are sent when a user adds, removes, or updates a device
168            handle_devices_notification(client, node).await;
169        }
170        "link_code_companion_reg" => {
171            // Handle pair code notification (stage 2 of pair code authentication)
172            // This is sent when the user enters the code on their phone
173            crate::pair_code::handle_pair_code_notification(client, node).await;
174        }
175        "business" => {
176            // Handle business notification (WhatsApp Web: handleBusinessNotification)
177            // Notifies about business account status changes: verified name, profile, removal
178            handle_business_notification(client, node).await;
179        }
180        "picture" => {
181            // Handle profile picture change notifications (WhatsApp Web: WAWebHandleProfilePicNotification)
182            handle_picture_notification(client, node);
183        }
184        "privacy_token" => {
185            // Handle incoming trusted contact privacy token notifications.
186            // Matches WhatsApp Web's WAWebHandlePrivacyTokenNotification.
187            handle_privacy_token_notification(client, node).await;
188        }
189        "status" => {
190            // Handle status/about text change notifications (WhatsApp Web: WAWebHandleAboutNotification)
191            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            // WA Web: WAWebHandleDisappearingModeNotification →
201            // WAWebUpdateDisappearingModeForContact.
202            // Parses <disappearing_mode duration="..." t="..."/> child,
203            // updates the contact's default ephemeral setting.
204            handle_disappearing_mode_notification(client, node);
205        }
206        "newsletter" => {
207            handle_newsletter_notification(client, node);
208        }
209        "mediaretry" => {
210            // Handled by wait_for_node waiter in MediaReupload::request().
211            // Ack is sent automatically by the stanza dispatch loop.
212            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
227/// Handle encrypt/count notification (PreKey Low).
228///
229/// Matches WA Web's `WAWebHandlePreKeyLow`:
230/// 1. Mark `server_has_prekeys = false`
231/// 2. Wait for offline delivery to complete
232/// 3. Acquire dedup lock (prevents concurrent uploads)
233/// 4. Upload prekeys with Fibonacci retry
234async fn handle_prekey_low(client: &Arc<Client>) {
235    // Mark server as not having our prekeys
236    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            // Wait for offline delivery to complete first (matches WA Web's waitForOfflineDeliveryEnd).
245            // Done BEFORE acquiring the lock so the lock isn't held during an
246            // indefinite wait that could block digest-key or other upload paths.
247            client_clone.wait_for_offline_delivery_end().await;
248
249            // Bail if disconnected during offline delivery wait
250            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            // Serialize upload — prevents concurrent uploads from count + digest paths
259            let _guard = client_clone.prekey_upload_lock.lock().await;
260
261            // Dedup: if a previous upload already succeeded, skip
262            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
280/// Handle encrypt/digest notification (Digest Key validation).
281///
282/// Matches WA Web's `WAWebHandleDigestKey`:
283/// Queries server for key bundle digest, validates SHA-1 hash locally,
284/// re-uploads if mismatch or missing.
285///
286/// Acquires `prekey_upload_lock` to serialize with the count-based upload path,
287/// preventing concurrent uploads that could race on prekey ID allocation.
288fn 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
301/// Handle device list change notifications.
302/// Matches WhatsApp Web's WAWebHandleDeviceNotification.handleDevicesNotification().
303///
304/// Device notifications have the structure:
305/// ```xml
306/// <notification type="devices" from="user@s.whatsapp.net">
307///   <add device_hash="..."> or <remove device_hash="..."> or <update hash="...">
308///     <device jid="user:device@server"/>
309///     <key-index-list ts="..."/>
310///   </add/remove/update>
311/// </notification>
312/// ```
313async fn handle_devices_notification(client: &Arc<Client>, node: &Node) {
314    // Parse using type-safe struct
315    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    // Learn LID-PN mapping if present
324    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    // Process the single operation (per WhatsApp Web: one operation per notification).
333    // Granularly patch caches instead of invalidating — matches WA Web's
334    // bulkCreateOrReplace pattern and avoids a usync IQ round-trip.
335    let op = &notification.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(), &notification.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                        &notification.from,
357                        device.device_id(),
358                    )
359                    .await;
360            }
361        }
362        wacore::stanza::devices::DeviceNotificationType::Update => {
363            if op.devices.is_empty() {
364                // Hash-only update without device list — fall back to
365                // invalidation so the next read rehydrates from the server.
366                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    // Dispatch event to notify application layer
378    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
396/// Parsed device info from account_sync notification
397struct AccountSyncDevice {
398    jid: Jid,
399    key_index: Option<u32>,
400}
401
402/// Parse devices from account_sync notification's <devices> child.
403///
404/// Example structure:
405/// ```xml
406/// <devices dhash="2:FnEWjS13">
407///   <device jid="15551234567@s.whatsapp.net"/>
408///   <device jid="15551234567:64@s.whatsapp.net" key-index="2"/>
409///   <key-index-list ts="1766612162"><!-- bytes --></key-index-list>
410/// </devices>
411/// ```
412fn 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
428/// Handle account_sync notification with <devices> child.
429///
430/// This is sent when devices are added/removed from OUR account (e.g., pairing a new WhatsApp Web).
431/// Matches WhatsApp Web's `handleAccountSyncNotification` for `AccountSyncType.DEVICES`.
432///
433/// Key behaviors:
434/// 1. Check if notification is for our own account (isSameAccountAndAddressingMode)
435/// 2. Parse device list from notification
436/// 3. Update device registry with new device list
437/// 4. Does NOT trigger app state sync (that's handled by server_sync)
438async fn handle_account_sync_devices(client: &Arc<Client>, node: &Node, devices_node: &Node) {
439    // Extract the "from" JID - this is the account the notification is about
440    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    // Get our own JIDs (PN and LID) to verify this is about our account
449    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    // Check if notification is about our own account
454    // Matches WhatsApp Web's isSameAccountAndAddressingMode check
455    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        // WhatsApp Web logs "wid-is-not-self" error in this case
460        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    // Parse device list from notification
471    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    // Extract dhash (device hash) for cache validation
478    let dhash = devices_node
479        .attrs()
480        .optional_string("dhash")
481        .map(|s| s.into_owned());
482
483    // Get timestamp from notification
484    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    // Build DeviceListRecord for storage
491    // Note: update_device_list() will automatically store under LID if mapping is known
492    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    // Log individual devices at debug level
522    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
532/// Handle incoming privacy_token notification.
533///
534/// Stores trusted contact tokens from contacts. Matches WhatsApp Web's
535/// `WAWebHandlePrivacyTokenNotification`.
536///
537/// Structure:
538/// ```xml
539/// <notification type="privacy_token" from="user@s.whatsapp.net" sender_lid="user@lid">
540///   <tokens>
541///     <token type="trusted_contact" t="1707000000"><!-- bytes --></token>
542///   </tokens>
543/// </notification>
544/// ```
545async 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    // Resolve the sender to a LID JID for storage.
550    // WA Web uses `sender_lid` attr if present, otherwise resolves from `from`.
551    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            // Fall back to resolving from the `from` JID via LID-PN cache
560            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                // Try to resolve phone number to LID
572                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    // Parse the token data from the notification
588    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                // Timestamp monotonicity guard: only store if incoming >= existing
607                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                // Preserve existing sender_timestamp when updating token
617                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                // New token — no existing entry
631                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
650/// Handle business notification (WhatsApp Web: `WAWebHandleBusinessNotification`).
651async 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) = &notification
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
725/// Handle profile picture change notifications.
726///
727/// Matches WhatsApp Web's `WAWebHandleProfilePicNotification`.
728///
729/// Structure:
730/// ```xml
731/// <notification type="picture" from="user@s.whatsapp.net" t="1234567890" id="...">
732///   <set jid="user@s.whatsapp.net" id="pic_id" author="author@s.whatsapp.net"/>
733/// </notification>
734/// ```
735///
736/// Or for removal (no child or `<delete>` child):
737/// ```xml
738/// <notification type="picture" from="user@s.whatsapp.net" t="1234567890" id="...">
739///   <delete jid="user@s.whatsapp.net"/>
740/// </notification>
741/// ```
742fn 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    // Look for <set>, <delete>, or <request> child to determine the action.
758    // WhatsApp Web has two formats:
759    // - With `jid` attr: direct update for that JID
760    // - With `hash` attr (no `jid`): side contact, resolved via contact hash lookup
761    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        // No <set> or <delete> child. Check if notification has no children at all,
787        // which WhatsApp uses as a deletion signal (bare notification).
788        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            // Unknown child type (e.g., "request", "set_avatar") — log and skip
798            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
826/// Handle status/about text change notifications.
827///
828/// Matches WhatsApp Web's `WAWebHandleAboutNotification`.
829///
830/// Structure:
831/// ```xml
832/// <notification type="status" from="user@s.whatsapp.net" t="1234567890" notify="PushName">
833///   <set>new status text</set>
834/// </notification>
835/// ```
836fn 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
886/// Learn LID-PN mappings from a contacts modify notification.
887///
888/// WA Web (`WAWebHandleContactNotification` → `WAWebDBCreateLidPnMappings`):
889/// The `<modify>` child carries four attributes:
890/// - `old` / `new` — old and new PN (phone number) JIDs
891/// - `old_lid` / `new_lid` — old and new LID JIDs (optional)
892///
893/// When both `old_lid` and `new_lid` are present, WA Web creates two mappings:
894/// `{ lid: old_lid, pn: old }` and `{ lid: new_lid, pn: new }`.
895async 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    // WA Web: createLidPnMappings({mappings:[{lid:oldLid,pn:oldJid},{lid:newLid,pn:newJid}]})
903    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
925/// Handle contact change notifications.
926///
927/// WA Web: `WAWebHandleContactNotification`
928///
929/// These stanzas are sent as `<notification type="contacts">` with a single child action:
930/// - `<update jid="..."/>` — contact profile changed. Consumers should
931///   invalidate cached presence/profile picture (WA Web resets PresenceCollection
932///   and refreshes profile pic thumb).
933/// - `<modify old="..." new="..." old_lid="..." new_lid="..."/>` — contact
934///   changed phone number. Creates LID-PN mappings when LID attrs present.
935/// - `<sync after="..."/>` — server requests full contact re-sync.
936/// - `<add .../>` or `<remove .../>` — lightweight roster changes (ACK only).
937async 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                // WA Web: when no jid, tries hash-based lookup against local contacts
952                // (first 4 chars of contact userhash). If no match, it's a no-op.
953                // We don't maintain a userhash index, so just ack and move on.
954                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            // WA Web: old/new are PN JIDs, old_lid/new_lid are optional LID JIDs.
967            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
1040/// Handle w:gp2 group notifications.
1041///
1042/// Parses all child actions (participant changes, setting changes, metadata updates)
1043/// and dispatches typed `Event::GroupUpdate` events for each.
1044///
1045/// Reference: WhatsApp Web `WAWebHandleGroupNotification` (Ri7Gf1BxhsX.js:12556-12962)
1046async 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        // Granularly patch group cache instead of invalidating — matches WA Web's
1062        // addParticipantInfo / removeParticipantInfo pattern and avoids a
1063        // group metadata IQ round-trip.
1064        match &action {
1065            GroupNotificationAction::Add { participants, .. } => {
1066                let group_cache = client.get_group_cache().await;
1067                if let Some(mut info) = group_cache.get(&notification.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(&notification.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    // Also dispatch legacy generic notification for backward compatibility
1122    client
1123        .core
1124        .event_bus
1125        .dispatch(&Event::Notification(node.clone()));
1126}
1127
1128/// Handle `<notification type="newsletter">` — live updates with reaction counts.
1129///
1130/// Format:
1131/// ```xml
1132/// <notification from="NL_JID" type="newsletter" id="..." t="...">
1133///   <live_updates>
1134///     <messages jid="NL_JID" t="...">
1135///       <message server_id="123" ...>
1136///         <reactions><reaction code="👍" count="3"/></reactions>
1137///       </message>
1138///     </messages>
1139///   </live_updates>
1140/// </notification>
1141/// ```
1142fn 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    // Also dispatch raw notification for backward compatibility
1193    client
1194        .core
1195        .event_bus
1196        .dispatch(&Event::Notification(node.clone()));
1197}
1198
1199/// Handle `<notification type="disappearing_mode">` — a contact changed
1200/// their default disappearing messages setting.
1201///
1202/// WA Web: `WAWebHandleDisappearingModeNotification` parses the
1203/// `<disappearing_mode duration="..." t="..."/>` child and calls
1204/// `WAWebUpdateDisappearingModeForContact` which applies the update only
1205/// if the new timestamp is newer than the stored one.
1206///
1207/// We dispatch `Event::DisappearingModeChanged` and let consumers decide
1208/// how to persist/apply it.
1209fn 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    // WA Web: `t.attrInt("duration", 0)` — defaults to 0 (disabled).
1224    let duration = dm_attrs
1225        .optional_string("duration")
1226        .and_then(|s| s.parse::<u32>().ok())
1227        .unwrap_or(0);
1228
1229    // WA Web: `t.attrTime("t")` — required, no default.
1230    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        // Per WhatsApp Web: add operation has single device + key-index-list
1293        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        // Verify key index info
1313        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        // Update operations don't have devices (just hash for lookup)
1362        assert!(parsed.operation.devices.is_empty());
1363    }
1364
1365    #[test]
1366    fn test_parse_empty_device_notification_fails() {
1367        // Per WhatsApp Web: at least one operation (add/remove/update) is required
1368        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        // Per WhatsApp Web: only ONE operation is processed with priority remove > add > update
1386        // If both remove and add are present, remove should be processed
1387        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        // Should process remove, not add (priority: remove > add > update)
1416        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    // Tests for account_sync device parsing
1440
1441    #[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        // Primary device (device 0)
1460        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        // Companion device (device 64)
1465        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        // Real-world structure includes <key-index-list> which should be ignored
1473        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]) // Simulated signed bytes
1486                    .build(),
1487            ])
1488            .build();
1489
1490        let devices = parse_account_sync_device_list(&devices_node);
1491        // Should only parse <device> tags, not <key-index-list>
1492        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        // Verify device IDs are correctly parsed
1535        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        // Verify key indexes
1541        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    // ── disappearing_mode notification parsing tests ─────────────────────
1548
1549    /// Helper: parse a disappearing_mode notification node the same way
1550    /// the handler does, returning `(duration, setting_timestamp)` or `None`
1551    /// on validation failure.
1552    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        // duration=0 means disappearing messages disabled
1584        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        // No <disappearing_mode> child → returns None
1600        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        // Missing 't' attribute → returns None (required field)
1614        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        // Missing duration defaults to 0 (WA Web: attrInt("duration", 0))
1630        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        // WA Web: old/new are PN JIDs, old_lid/new_lid are LID JIDs.
1670        // Creates two mappings: old_lid→old_pn AND new_lid→new_pn.
1671        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        // Both LID-PN mappings should be created
1690        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        // Event should still be dispatched, just without LID info
1739        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        // No child element
1794        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        // WA Web sends <update hash="Quvc"/> without jid when using hash-based lookup.
1810        // We don't maintain a userhash index, so this should be a no-op.
1811        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}