Skip to main content

whatsapp_rust/
receipt.rs

1use crate::client::Client;
2use crate::types::events::{Event, Receipt};
3use crate::types::presence::ReceiptType;
4use log::debug;
5use std::sync::Arc;
6use wacore_binary::builder::NodeBuilder;
7use wacore_binary::jid::{Jid, JidExt as _};
8
9use wacore_binary::node::Node;
10
11impl Client {
12    fn should_send_delivery_receipt(info: &crate::types::message::MessageInfo) -> bool {
13        use wacore_binary::jid::STATUS_BROADCAST_USER;
14
15        if info.id.is_empty()
16            || info.source.chat.user == STATUS_BROADCAST_USER
17            || info.source.chat.is_newsletter()
18        {
19            return false;
20        }
21
22        // WA Web sends type="peer_msg" delivery receipts for self-synced
23        // messages (category="peer").  These tell the primary phone that
24        // this companion device received the message.
25        // For all other messages, skip receipts for our own messages.
26        info.category == "peer" || !info.source.is_from_me
27    }
28
29    pub(crate) async fn handle_receipt(self: &Arc<Self>, node: Arc<Node>) {
30        let mut attrs = node.attrs();
31        let from = attrs.jid("from");
32        let id = match attrs.optional_string("id") {
33            Some(id) => id.to_string(),
34            None => {
35                log::warn!("Receipt stanza missing required 'id' attribute");
36                return;
37            }
38        };
39        let receipt_type_cow = attrs.optional_string("type");
40        let receipt_type_str = receipt_type_cow.as_deref().unwrap_or("delivery");
41        let participant = attrs.optional_jid("participant");
42
43        let receipt_type = ReceiptType::from(receipt_type_str.to_string());
44
45        debug!("Received receipt type '{receipt_type:?}' for message {id} from {from}");
46
47        let from_clone = from.clone();
48        let sender = if from.is_group() {
49            if let Some(participant) = participant {
50                participant
51            } else {
52                from_clone
53            }
54        } else {
55            from.clone()
56        };
57
58        let receipt = Receipt {
59            message_ids: vec![id.clone()],
60            source: crate::types::message::MessageSource {
61                chat: from.clone(),
62                sender: sender.clone(),
63                ..Default::default()
64            },
65            timestamp: wacore::time::now_utc(),
66            r#type: receipt_type.clone(),
67            message_sender: sender.clone(),
68        };
69
70        if receipt_type == ReceiptType::Retry {
71            let client_clone = Arc::clone(self);
72            // Arc clone is cheap - just reference count increment
73            let node_clone = Arc::clone(&node);
74            self.runtime
75                .spawn(Box::pin(async move {
76                    if let Err(e) = client_clone
77                        .handle_retry_receipt(&receipt, &node_clone)
78                        .await
79                    {
80                        log::warn!(
81                            "Failed to handle retry receipt for {}: {:?}",
82                            receipt.message_ids[0],
83                            e
84                        );
85                    }
86                }))
87                .detach();
88        } else if receipt_type == ReceiptType::EncRekeyRetry {
89            // WA Web: both "retry" and "enc_rekey_retry" route through
90            // handleMessageRetryRequest, but enc_rekey_retry branches to the
91            // VoIP stack's resendEncRekeyRetry(peerJid, retryCount).
92            // Since we don't have a VoIP stack yet, log and dispatch as a
93            // Receipt event so consumers can observe it. When VoIP is
94            // implemented (#345), this will route to the VoIP re-key handler.
95            if let Some(child) = node.get_optional_child("enc_rekey") {
96                let mut attrs = child.attrs();
97                log::debug!(
98                    "Received enc_rekey_retry receipt for call-id={} from {} \
99                     (call-creator={}, count={}). VoIP not implemented, forwarding as event.",
100                    attrs
101                        .optional_string("call-id")
102                        .as_deref()
103                        .unwrap_or_default(),
104                    from,
105                    attrs
106                        .optional_string("call-creator")
107                        .as_deref()
108                        .unwrap_or_default(),
109                    attrs
110                        .optional_string("count")
111                        .and_then(|s| s.parse::<u8>().ok())
112                        .unwrap_or(1),
113                );
114            }
115            self.core.event_bus.dispatch(&Event::Receipt(receipt));
116        } else {
117            self.core.event_bus.dispatch(&Event::Receipt(receipt));
118        }
119    }
120
121    /// Sends a delivery receipt to the sender of a message.
122    ///
123    /// This function handles:
124    /// - Direct messages (DMs) - sends receipt to the sender's JID.
125    /// - Group messages - sends receipt to the group JID with the sender as a participant.
126    /// - Peer device messages (category="peer") - sends `type="peer_msg"` receipt to
127    ///   acknowledge self-synced messages from the primary phone.
128    /// - It correctly skips sending receipts for status broadcasts, newsletters,
129    ///   or messages without an ID.
130    pub(crate) async fn send_delivery_receipt(&self, info: &crate::types::message::MessageInfo) {
131        if !Self::should_send_delivery_receipt(info) {
132            return;
133        }
134
135        let mut builder = NodeBuilder::new("receipt")
136            .attr("id", &info.id)
137            .attr("to", info.source.chat.clone());
138
139        // WA Web: peer device messages (category="peer") use type="peer_msg".
140        // Normal delivery receipts omit the type attribute (DROP_ATTR).
141        if info.category == "peer" {
142            builder = builder.attr("type", "peer_msg");
143        }
144
145        // For group messages, the 'participant' attribute is required to identify the sender.
146        if info.source.is_group {
147            builder = builder.attr("participant", info.source.sender.clone());
148        }
149
150        let receipt_node = builder.build();
151
152        debug!(target: "Client/Receipt", "Sending {} receipt for message {} to {}",
153            if info.category == "peer" { "peer_msg" } else { "delivery" },
154            info.id, info.source.sender);
155
156        if let Err(e) = self.send_node(receipt_node).await {
157            log::warn!(target: "Client/Receipt", "Failed to send delivery receipt for message {}: {:?}", info.id, e);
158        }
159    }
160
161    /// Sends read receipts for one or more messages.
162    ///
163    /// For group messages, pass the message sender as `sender`.
164    pub async fn mark_as_read(
165        &self,
166        chat: &Jid,
167        sender: Option<&Jid>,
168        message_ids: Vec<String>,
169    ) -> Result<(), anyhow::Error> {
170        if message_ids.is_empty() {
171            return Ok(());
172        }
173
174        let timestamp = (wacore::time::now_secs() as u64).to_string();
175
176        let mut builder = NodeBuilder::new("receipt")
177            .attr("to", chat.clone())
178            .attr("type", "read")
179            .attr("id", &message_ids[0])
180            .attr("t", &timestamp);
181
182        if let Some(sender) = sender {
183            builder = builder.attr("participant", sender.clone());
184        }
185
186        // Additional message IDs go into <list><item id="..."/></list>
187        if message_ids.len() > 1 {
188            let items: Vec<wacore_binary::node::Node> = message_ids[1..]
189                .iter()
190                .map(|id| NodeBuilder::new("item").attr("id", id).build())
191                .collect();
192            builder = builder.children(vec![NodeBuilder::new("list").children(items).build()]);
193        }
194
195        let node = builder.build();
196
197        debug!(target: "Client/Receipt", "Sending read receipt for {} message(s) to {}", message_ids.len(), chat);
198
199        self.send_node(node)
200            .await
201            .map_err(|e| anyhow::anyhow!("Failed to send read receipt: {}", e))
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::store::persistence_manager::PersistenceManager;
209    use crate::test_utils::MockHttpClient;
210    use crate::types::message::{MessageInfo, MessageSource};
211    use std::sync::Mutex;
212    use wacore::types::events::EventHandler;
213
214    #[derive(Default)]
215    struct TestEventCollector {
216        events: Mutex<Vec<Event>>,
217    }
218
219    impl EventHandler for TestEventCollector {
220        fn handle_event(&self, event: &Event) {
221            self.events
222                .lock()
223                .expect("collector mutex should not be poisoned")
224                .push(event.clone());
225        }
226    }
227
228    impl TestEventCollector {
229        fn events(&self) -> Vec<Event> {
230            self.events
231                .lock()
232                .expect("collector mutex should not be poisoned")
233                .clone()
234        }
235    }
236
237    #[tokio::test]
238    async fn test_send_delivery_receipt_dm() {
239        let backend = crate::test_utils::create_test_backend().await;
240        let pm = Arc::new(
241            PersistenceManager::new(backend)
242                .await
243                .expect("persistence manager should initialize"),
244        );
245        let (client, _rx) = Client::new(
246            Arc::new(crate::runtime_impl::TokioRuntime),
247            pm,
248            Arc::new(crate::transport::mock::MockTransportFactory::new()),
249            Arc::new(MockHttpClient),
250            None,
251        )
252        .await;
253
254        let info = MessageInfo {
255            id: "TEST-ID-123".to_string(),
256            source: MessageSource {
257                chat: "12345@s.whatsapp.net"
258                    .parse()
259                    .expect("test JID should be valid"),
260                sender: "12345@s.whatsapp.net"
261                    .parse()
262                    .expect("test JID should be valid"),
263                is_from_me: false,
264                is_group: false,
265                ..Default::default()
266            },
267            ..Default::default()
268        };
269
270        // This should complete without panicking. The actual node sending
271        // would fail since we're not connected, but the function should
272        // handle that gracefully and log a warning.
273        client.send_delivery_receipt(&info).await;
274
275        // If we got here, the function executed successfully.
276        // In a real scenario, we'd need to mock the transport to verify
277        // the exact node sent, but basic functionality testing confirms
278        // the method doesn't panic and logs appropriately.
279    }
280
281    #[tokio::test]
282    async fn test_send_delivery_receipt_group() {
283        let backend = crate::test_utils::create_test_backend().await;
284        let pm = Arc::new(
285            PersistenceManager::new(backend)
286                .await
287                .expect("persistence manager should initialize"),
288        );
289        let (client, _rx) = Client::new(
290            Arc::new(crate::runtime_impl::TokioRuntime),
291            pm,
292            Arc::new(crate::transport::mock::MockTransportFactory::new()),
293            Arc::new(MockHttpClient),
294            None,
295        )
296        .await;
297
298        let info = MessageInfo {
299            id: "GROUP-MSG-ID".to_string(),
300            source: MessageSource {
301                chat: "120363021033254949@g.us"
302                    .parse()
303                    .expect("test JID should be valid"),
304                sender: "15551234567@s.whatsapp.net"
305                    .parse()
306                    .expect("test JID should be valid"),
307                is_from_me: false,
308                is_group: true,
309                ..Default::default()
310            },
311            ..Default::default()
312        };
313
314        // Should complete without panicking for group messages too.
315        client.send_delivery_receipt(&info).await;
316    }
317
318    #[tokio::test]
319    async fn test_skip_delivery_receipt_for_own_messages() {
320        let backend = crate::test_utils::create_test_backend().await;
321        let pm = Arc::new(
322            PersistenceManager::new(backend)
323                .await
324                .expect("persistence manager should initialize"),
325        );
326        let (client, _rx) = Client::new(
327            Arc::new(crate::runtime_impl::TokioRuntime),
328            pm,
329            Arc::new(crate::transport::mock::MockTransportFactory::new()),
330            Arc::new(MockHttpClient),
331            None,
332        )
333        .await;
334
335        let info = MessageInfo {
336            id: "OWN-MSG-ID".to_string(),
337            source: MessageSource {
338                chat: "12345@s.whatsapp.net"
339                    .parse()
340                    .expect("test JID should be valid"),
341                sender: "12345@s.whatsapp.net"
342                    .parse()
343                    .expect("test JID should be valid"),
344                is_from_me: true, // Own message
345                is_group: false,
346                ..Default::default()
347            },
348            ..Default::default()
349        };
350
351        // Should return early without attempting to send.
352        // We can't easily assert that send_node was not called without
353        // refactoring, but at least verify the function completes.
354        client.send_delivery_receipt(&info).await;
355    }
356
357    #[tokio::test]
358    async fn test_skip_delivery_receipt_for_empty_id() {
359        let backend = crate::test_utils::create_test_backend().await;
360        let pm = Arc::new(
361            PersistenceManager::new(backend)
362                .await
363                .expect("persistence manager should initialize"),
364        );
365        let (client, _rx) = Client::new(
366            Arc::new(crate::runtime_impl::TokioRuntime),
367            pm,
368            Arc::new(crate::transport::mock::MockTransportFactory::new()),
369            Arc::new(MockHttpClient),
370            None,
371        )
372        .await;
373
374        let info = MessageInfo {
375            id: "".to_string(), // Empty ID
376            source: MessageSource {
377                chat: "12345@s.whatsapp.net"
378                    .parse()
379                    .expect("test JID should be valid"),
380                sender: "12345@s.whatsapp.net"
381                    .parse()
382                    .expect("test JID should be valid"),
383                is_from_me: false,
384                is_group: false,
385                ..Default::default()
386            },
387            ..Default::default()
388        };
389
390        // Should return early without attempting to send.
391        client.send_delivery_receipt(&info).await;
392    }
393
394    #[tokio::test]
395    async fn test_skip_delivery_receipt_for_status_broadcast() {
396        let backend = crate::test_utils::create_test_backend().await;
397        let pm = Arc::new(
398            PersistenceManager::new(backend)
399                .await
400                .expect("persistence manager should initialize"),
401        );
402        let (client, _rx) = Client::new(
403            Arc::new(crate::runtime_impl::TokioRuntime),
404            pm,
405            Arc::new(crate::transport::mock::MockTransportFactory::new()),
406            Arc::new(MockHttpClient),
407            None,
408        )
409        .await;
410
411        let info = MessageInfo {
412            id: "STATUS-MSG-ID".to_string(),
413            source: MessageSource {
414                chat: "status@broadcast"
415                    .parse()
416                    .expect("test JID should be valid"), // Status broadcast
417                sender: "12345@s.whatsapp.net"
418                    .parse()
419                    .expect("test JID should be valid"),
420                is_from_me: false,
421                is_group: true,
422                ..Default::default()
423            },
424            ..Default::default()
425        };
426
427        // Should return early without attempting to send for status broadcasts.
428        client.send_delivery_receipt(&info).await;
429    }
430
431    #[test]
432    fn test_should_skip_delivery_receipt_for_newsletter() {
433        let info = MessageInfo {
434            id: "NEWSLETTER-MSG-ID".to_string(),
435            source: MessageSource {
436                chat: "120363173003902460@newsletter"
437                    .parse()
438                    .expect("newsletter JID should be valid"),
439                sender: "120363173003902460@newsletter"
440                    .parse()
441                    .expect("newsletter JID should be valid"),
442                is_from_me: false,
443                is_group: false,
444                ..Default::default()
445            },
446            ..Default::default()
447        };
448
449        assert!(
450            !Client::should_send_delivery_receipt(&info),
451            "generic delivery receipts must be skipped for newsletters"
452        );
453    }
454
455    #[test]
456    fn test_should_send_peer_msg_receipt_for_self_synced_messages() {
457        // Self-synced messages (category="peer") should get delivery receipts
458        // even though is_from_me is true.  WA Web sends type="peer_msg" for these.
459        let info = MessageInfo {
460            id: "PEER-MSG-ID".to_string(),
461            source: MessageSource {
462                chat: "155500012345@s.whatsapp.net"
463                    .parse()
464                    .expect("own PN JID should be valid"),
465                sender: "155500012345@s.whatsapp.net"
466                    .parse()
467                    .expect("own PN JID should be valid"),
468                is_from_me: true,
469                is_group: false,
470                ..Default::default()
471            },
472            category: "peer".to_string(),
473            ..Default::default()
474        };
475
476        assert!(
477            Client::should_send_delivery_receipt(&info),
478            "peer device messages must get delivery receipts even when is_from_me"
479        );
480    }
481
482    /// Create a test client with an event collector registered.
483    async fn setup_client_with_collector() -> (Arc<Client>, Arc<TestEventCollector>) {
484        let backend = crate::test_utils::create_test_backend().await;
485        let pm = Arc::new(
486            PersistenceManager::new(backend)
487                .await
488                .expect("persistence manager should initialize"),
489        );
490        let (client, _rx) = Client::new(
491            Arc::new(crate::runtime_impl::TokioRuntime),
492            pm,
493            Arc::new(crate::transport::mock::MockTransportFactory::new()),
494            Arc::new(MockHttpClient),
495            None,
496        )
497        .await;
498
499        let collector = Arc::new(TestEventCollector::default());
500        client.register_handler(collector.clone());
501        (client, collector)
502    }
503
504    /// Verify that enc_rekey_retry receipt is dispatched as a Receipt event
505    /// with EncRekeyRetry type so consumers can observe it.
506    #[tokio::test]
507    async fn test_enc_rekey_retry_receipt_dispatches_event() {
508        let (client, collector) = setup_client_with_collector().await;
509
510        // Build an enc_rekey_retry receipt node matching WA Web structure
511        let node = Arc::new(
512            NodeBuilder::new("receipt")
513                .attr("from", "5511999999999@s.whatsapp.net")
514                .attr("id", "3EB0AABBCCDD")
515                .attr("type", "enc_rekey_retry")
516                .children([
517                    NodeBuilder::new("enc_rekey")
518                        .attr("call-creator", "5511888888888@s.whatsapp.net")
519                        .attr("call-id", "CALL-123")
520                        .attr("count", "1")
521                        .build(),
522                    NodeBuilder::new("registration")
523                        .bytes(12345u32.to_be_bytes().to_vec())
524                        .build(),
525                ])
526                .build(),
527        );
528
529        client.handle_receipt(node).await;
530
531        // Must dispatch exactly one Receipt event with EncRekeyRetry type
532        let events = collector.events();
533        let receipt_events: Vec<_> = events
534            .iter()
535            .filter_map(|e| match e {
536                Event::Receipt(r) => Some(r),
537                _ => None,
538            })
539            .collect();
540        assert_eq!(
541            receipt_events.len(),
542            1,
543            "enc_rekey_retry must dispatch exactly one Receipt event"
544        );
545        assert_eq!(
546            receipt_events[0].r#type,
547            ReceiptType::EncRekeyRetry,
548            "dispatched receipt must have EncRekeyRetry type"
549        );
550        assert_eq!(receipt_events[0].message_ids, vec!["3EB0AABBCCDD"]);
551    }
552
553    /// Verify that enc_rekey_retry without <enc_rekey> child still dispatches
554    /// the Receipt event (graceful degradation, no crash).
555    #[tokio::test]
556    async fn test_enc_rekey_retry_receipt_without_child_still_dispatches() {
557        let (client, collector) = setup_client_with_collector().await;
558
559        // Malformed: no <enc_rekey> child
560        let node = Arc::new(
561            NodeBuilder::new("receipt")
562                .attr("from", "5511999999999@s.whatsapp.net")
563                .attr("id", "3EB0AABBCCDD")
564                .attr("type", "enc_rekey_retry")
565                .build(),
566        );
567
568        client.handle_receipt(node).await;
569
570        // Should still dispatch the Receipt event even without <enc_rekey> child
571        let events = collector.events();
572        let receipt_events: Vec<_> = events
573            .iter()
574            .filter_map(|e| match e {
575                Event::Receipt(r) => Some(r),
576                _ => None,
577            })
578            .collect();
579        assert_eq!(
580            receipt_events.len(),
581            1,
582            "malformed enc_rekey_retry must still dispatch Receipt event"
583        );
584        assert_eq!(receipt_events[0].r#type, ReceiptType::EncRekeyRetry);
585    }
586
587    #[test]
588    fn test_should_skip_non_peer_self_messages() {
589        // Normal self messages (no category) should still be skipped.
590        let info = MessageInfo {
591            id: "SELF-MSG-ID".to_string(),
592            source: MessageSource {
593                chat: "155500012345@s.whatsapp.net"
594                    .parse()
595                    .expect("own PN JID should be valid"),
596                sender: "155500012345@s.whatsapp.net"
597                    .parse()
598                    .expect("own PN JID should be valid"),
599                is_from_me: true,
600                is_group: false,
601                ..Default::default()
602            },
603            ..Default::default()
604        };
605
606        assert!(
607            !Client::should_send_delivery_receipt(&info),
608            "non-peer self messages must not get delivery receipts"
609        );
610    }
611
612    /// Verify that receipt nodes use JID-typed attrs for `to` and `participant`,
613    /// ensuring the NodeValue::Jid optimization is not accidentally regressed to to_string.
614    #[test]
615    fn test_receipt_node_uses_jid_attrs() {
616        use wacore_binary::node::NodeValue;
617
618        let chat_jid: Jid = "120363021033254949@g.us"
619            .parse()
620            .expect("test JID should be valid");
621        let sender_jid: Jid = "15551234567@s.whatsapp.net"
622            .parse()
623            .expect("test JID should be valid");
624
625        // Build a group receipt node using the same pattern as send_delivery_receipt
626        let node = NodeBuilder::new("receipt")
627            .attr("id", "MSG-123")
628            .attr("to", chat_jid.clone())
629            .attr("participant", sender_jid.clone())
630            .build();
631
632        // "to" must be stored as NodeValue::Jid, not NodeValue::String
633        let to_attr = node.attrs.get("to").expect("receipt must have 'to' attr");
634        assert!(
635            matches!(to_attr, NodeValue::Jid(_)),
636            "'to' attr should be JID-typed, got: {:?}",
637            to_attr
638        );
639        assert_eq!(to_attr.to_jid().unwrap(), chat_jid);
640
641        // "participant" must also be JID-typed
642        let participant_attr = node
643            .attrs
644            .get("participant")
645            .expect("group receipt must have 'participant' attr");
646        assert!(
647            matches!(participant_attr, NodeValue::Jid(_)),
648            "'participant' attr should be JID-typed, got: {:?}",
649            participant_attr
650        );
651        assert_eq!(participant_attr.to_jid().unwrap(), sender_jid);
652    }
653}