Skip to main content

whatsapp_rust/
pdo.rs

1//! PDO (Peer Data Operation) support for requesting message content from the primary device.
2//!
3//! When message decryption fails (e.g., due to session mismatch), instead of only sending
4//! a retry receipt to the sender, we can also request the message content from our own
5//! primary phone device. This is useful because:
6//!
7//! 1. The primary phone has already decrypted the message successfully
8//! 2. It can share the decrypted content with linked devices via PDO
9//! 3. This bypasses session issues entirely since we're asking our own trusted device
10//!
11//! The flow is:
12//! 1. Decryption fails for a message
13//! 2. We send a PeerDataOperationRequestMessage with type PLACEHOLDER_MESSAGE_RESEND
14//! 3. The phone responds with PeerDataOperationRequestResponseMessage containing the decoded message
15//! 4. We emit the message as if we had decrypted it ourselves
16
17use crate::client::Client;
18use crate::types::message::MessageInfo;
19use log::{debug, info, warn};
20use std::sync::Arc;
21use wacore::types::message::{
22    ChatMessageId, EditAttribute, MessageCategory, MessageSource, MsgMetaInfo,
23};
24use wacore_binary::{Jid, JidExt};
25use waproto::whatsapp as wa;
26
27#[derive(Clone, Debug)]
28pub struct PendingPdoRequest {
29    pub message_info: Arc<MessageInfo>,
30    pub requested_at: wacore::time::Instant,
31}
32
33/// Peer-message destination keyed by the namespace the phone's Signal
34/// store actually uses — LID after migration, PN before. Mirrors
35/// whatsmeow's `SendPeerMessage` → `cli.getOwnID().ToNonAD()`. WA Web's
36/// PN-only target leaves the LID slot stranded post-migration.
37fn self_peer_target(device: &wacore::store::Device) -> Result<Jid, crate::client::ClientError> {
38    if let Some(lid) = device.lid.as_ref() {
39        return Ok(Jid::lid(lid.user.clone()));
40    }
41    let pn = device
42        .pn
43        .as_ref()
44        .ok_or(crate::client::ClientError::NotLoggedIn)?;
45    Ok(Jid::pn(pn.user.clone()))
46}
47
48impl Client {
49    /// Sends a PDO (Peer Data Operation) request to our own primary phone to get the
50    /// decrypted content of a message that we failed to decrypt.
51    ///
52    /// This is called when decryption fails and we want to ask our phone for the message.
53    /// The phone will respond with a PeerDataOperationRequestResponseMessage containing
54    /// the full WebMessageInfo which we can then dispatch as a normal message event.
55    ///
56    /// # Arguments
57    /// * `info` - The MessageInfo for the message that failed to decrypt
58    ///
59    /// # Returns
60    /// * `Ok(())` if the request was sent successfully
61    /// * `Err` if we couldn't send the request (e.g., not logged in)
62    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.pdo.placeholder_resend", level = "debug", skip_all, fields(chat = %info.source.chat.observe(), sender = %info.source.sender.observe(), msg_id = %info.id), err(Debug)))]
63    pub async fn send_pdo_placeholder_resend_request(
64        self: &Arc<Self>,
65        info: &Arc<MessageInfo>,
66    ) -> Result<(), anyhow::Error> {
67        let device_snapshot = self.persistence_manager.get_device_snapshot();
68        let peer_target = self_peer_target(&device_snapshot)?;
69
70        // Resolve to LID for the MessageKey when LID-migrated, matching WA Web's
71        // NonMessageDataRequest.js:412-421 (toUserLid when isLidMigrated).
72        // The phone stores messages by LID after migration.
73        let resolved_jid = self.resolve_encryption_jid(&info.source.chat).await;
74        // WAWebE2EProtoUtils.msgKeyToProtobuf omits participant when fromMe or
75        // when the MsgKey has no participant (i.e. a DM, where the chat JID is
76        // the sender). Groups and broadcast chats need it so the phone can
77        // locate the stored message.
78        let participant = if !info.source.is_from_me
79            && (info.source.is_group || info.source.chat.server == wacore_binary::Server::Broadcast)
80        {
81            Some(self.resolve_encryption_jid(&info.source.sender).await)
82        } else {
83            None
84        };
85
86        // Cache key must use PN JID because the phone's response always contains
87        // PN JIDs in WebMessageInfo.key. For LID-migrated DMs, info.source.chat
88        // can be LID while sender_alt holds the PN — prefer the PN form.
89        let cache_chat = if !info.source.is_group && info.source.chat.is_lid() {
90            info.source
91                .sender_alt
92                .as_ref()
93                .map(|jid| jid.to_non_ad())
94                .unwrap_or_else(|| info.source.chat.clone())
95        } else {
96            info.source.chat.clone()
97        };
98        let cache_key = ChatMessageId::new(cache_chat, info.id.clone());
99
100        // One request per message, like WA Web's session-lifetime set in
101        // WAWebNonMessageDataRequestPlaceholderMessageResendUtils. The
102        // pending cache below only covers in-flight requests; once the phone
103        // answers (even without content) it empties, and a sender that keeps
104        // redelivering the same undecryptable message would otherwise trigger
105        // a fresh request per copy. Claimed via the single-flight `get_with`
106        // (same arm as `dispatch_undecryptable_event`): decrypt-failure tasks
107        // are detached per copy, so a get-then-insert would let two
108        // concurrent copies both pass the gate, and only the claim winner may
109        // release the slot on send failure below.
110        let claimed = Arc::new(std::sync::atomic::AtomicBool::new(false));
111        let claimed_clone = claimed.clone();
112        self.pdo_requested
113            .get_with(cache_key.clone(), async move {
114                claimed_clone.store(true, std::sync::atomic::Ordering::Release);
115            })
116            .await;
117        if !claimed.load(std::sync::atomic::Ordering::Acquire) {
118            debug!(
119                "PDO request already sent for message {} from {}; not re-requesting",
120                info.id,
121                info.source.sender.observe()
122            );
123            return Ok(());
124        }
125
126        if self.pdo_pending_requests.get(&cache_key).await.is_some() {
127            debug!(
128                "PDO request already pending for message {} from {}",
129                info.id,
130                info.source.sender.observe()
131            );
132            return Ok(());
133        }
134
135        let pending = PendingPdoRequest {
136            message_info: Arc::clone(info),
137            requested_at: wacore::time::Instant::now(),
138        };
139        self.pdo_pending_requests
140            .insert(cache_key.clone(), pending)
141            .await;
142
143        let message_key = wa::MessageKey {
144            remote_jid: Some(resolved_jid.to_string()),
145            from_me: Some(info.source.is_from_me),
146            id: Some(info.id.clone()),
147            participant: participant.map(|p| p.to_string()),
148        };
149
150        // Build the PDO request message
151        let pdo_request = wa::message::PeerDataOperationRequestMessage {
152            peer_data_operation_request_type: Some(
153                wa::message::PeerDataOperationRequestType::PLACEHOLDER_MESSAGE_RESEND,
154            ),
155            placeholder_message_resend_request: vec![
156                wa::message::peer_data_operation_request_message::PlaceholderMessageResendRequest {
157                    message_key: buffa::MessageField::some(message_key),
158                },
159            ],
160            ..Default::default()
161        };
162
163        // Wrap it in a protocol message
164        let protocol_message = wa::message::ProtocolMessage {
165            r#type: Some(wa::message::protocol_message::Type::PEER_DATA_OPERATION_REQUEST_MESSAGE),
166            peer_data_operation_request_message: buffa::MessageField::some(pdo_request),
167            ..Default::default()
168        };
169
170        let msg = wa::Message {
171            protocol_message: buffa::MessageField::some(protocol_message),
172            ..Default::default()
173        };
174
175        info!(
176            "Sending PDO placeholder resend request for message {} from {} in {} to {}",
177            info.id,
178            info.source.sender.observe(),
179            info.source.chat.observe(),
180            peer_target.observe()
181        );
182
183        // A failed send must not consume the once-per-message slot, or a
184        // transient error would permanently block recovery for this message.
185        if let Err(e) = self
186            .ensure_e2e_sessions(std::slice::from_ref(&peer_target))
187            .await
188        {
189            self.pdo_pending_requests.remove(&cache_key).await;
190            self.pdo_requested.remove(&cache_key).await;
191            return Err(e);
192        }
193
194        if let Err(e) = self.send_peer_message(peer_target, &msg).await {
195            self.pdo_pending_requests.remove(&cache_key).await;
196            self.pdo_requested.remove(&cache_key).await;
197            warn!(
198                "Failed to send PDO request for message {}: {:?}",
199                info.id, e
200            );
201            return Err(e);
202        }
203
204        debug!("PDO request sent successfully for message {}", info.id);
205        Ok(())
206    }
207
208    /// Request on-demand message history from the primary phone via PDO.
209    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.pdo.fetch_history", level = "debug", skip_all, fields(chat = %chat_jid.observe(), count), err(Debug)))]
210    pub async fn fetch_message_history(
211        self: &Arc<Self>,
212        chat_jid: &Jid,
213        oldest_msg_id: &str,
214        oldest_msg_from_me: bool,
215        oldest_msg_timestamp_ms: i64,
216        count: i32,
217    ) -> Result<String, anyhow::Error> {
218        let device_snapshot = self.persistence_manager.get_device_snapshot();
219        let peer_target = self_peer_target(&device_snapshot)?;
220
221        let pdo_request = wa::message::PeerDataOperationRequestMessage {
222            peer_data_operation_request_type: Some(
223                wa::message::PeerDataOperationRequestType::HISTORY_SYNC_ON_DEMAND,
224            ),
225            history_sync_on_demand_request: buffa::MessageField::some(
226                wa::message::peer_data_operation_request_message::HistorySyncOnDemandRequest {
227                    chat_jid: Some(chat_jid.to_string()),
228                    oldest_msg_id: Some(oldest_msg_id.to_string()),
229                    oldest_msg_from_me: Some(oldest_msg_from_me),
230                    oldest_msg_timestamp_ms: Some(oldest_msg_timestamp_ms),
231                    on_demand_msg_count: Some(count),
232                    ..Default::default()
233                },
234            ),
235            ..Default::default()
236        };
237
238        let protocol_message = wa::message::ProtocolMessage {
239            r#type: Some(wa::message::protocol_message::Type::PEER_DATA_OPERATION_REQUEST_MESSAGE),
240            peer_data_operation_request_message: buffa::MessageField::some(pdo_request),
241            ..Default::default()
242        };
243
244        let msg = wa::Message {
245            protocol_message: buffa::MessageField::some(protocol_message),
246            ..Default::default()
247        };
248
249        info!(
250            "Sending PDO history sync on-demand request for chat {} (count={}) to {}",
251            chat_jid.observe(),
252            count,
253            peer_target.observe()
254        );
255
256        self.ensure_e2e_sessions(std::slice::from_ref(&peer_target))
257            .await?;
258        self.send_peer_message(peer_target, &msg).await
259    }
260
261    /// Sends a peer message (message to our own devices).
262    /// This is used for PDO requests and similar device-to-device communication.
263    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.pdo.send_peer_message", level = "debug", skip_all, fields(to = %to.observe()), err(Debug)))]
264    async fn send_peer_message(
265        self: &Arc<Self>,
266        to: Jid,
267        msg: &wa::Message,
268    ) -> Result<String, anyhow::Error> {
269        let msg_id = self.generate_message_id();
270
271        // Send with peer category and high priority
272        self.send_message_impl(
273            to,
274            msg,
275            crate::send::SendPipelineOptions {
276                request_id: Some(&msg_id),
277                peer: true,
278                ..Default::default()
279            },
280        )
281        .await?;
282
283        Ok(msg_id)
284    }
285
286    /// Handles a PDO response message from our primary phone.
287    /// This is called when we receive a PeerDataOperationRequestResponseMessage.
288    ///
289    /// # Arguments
290    /// * `response` - The PDO response message
291    /// * `info` - The MessageInfo for the PDO response message itself
292    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.pdo.handle_response", level = "debug", skip_all, fields(sender = %pdo_msg_info.source.sender.observe())))]
293    pub async fn handle_pdo_response(
294        self: &Arc<Self>,
295        response: &wa::message::PeerDataOperationRequestResponseMessage,
296        pdo_msg_info: &MessageInfo,
297    ) {
298        // Only process PDO responses from device 0 (the primary phone)
299        if pdo_msg_info.source.sender.device != 0 {
300            debug!(
301                "Ignoring PDO response from non-primary device {}",
302                pdo_msg_info.source.sender.observe()
303            );
304            return;
305        }
306
307        let request_id = response.stanza_id.as_deref().unwrap_or("");
308        debug!(
309            "Received PDO response (request_id={}) with {} results",
310            request_id,
311            response.peer_data_operation_result.len()
312        );
313
314        for result in &response.peer_data_operation_result {
315            if let Some(placeholder_response) =
316                result.placeholder_message_resend_response.as_option()
317            {
318                self.handle_placeholder_resend_response(placeholder_response, request_id)
319                    .await;
320            }
321        }
322    }
323
324    async fn handle_placeholder_resend_response(
325        self: &Arc<Self>,
326        response: &wa::message::peer_data_operation_request_response_message::peer_data_operation_result::PlaceholderMessageResendResponse,
327        request_id: &str,
328    ) {
329        let Some(web_message_info_bytes) = &response.web_message_info_bytes else {
330            warn!("PDO placeholder response missing webMessageInfoBytes");
331            return;
332        };
333
334        // Owned decode (not a view): WebMessageInfo carries a nested `message`
335        // (a full Message), so an eager view would pull the entire MessageView
336        // tree into the binary and parse the message once into a view only to
337        // copy it again into the owned form. Owned decode reads it in one pass.
338        let mut web_msg_info = match waproto::codec::web_message_info_decode(web_message_info_bytes)
339        {
340            Ok(info) => info,
341            Err(e) => {
342                warn!("Failed to decode WebMessageInfo from PDO response: {:?}", e);
343                return;
344            }
345        };
346
347        let Some(key) = web_msg_info.key.as_option() else {
348            warn!("PDO response WebMessageInfo missing key");
349            return;
350        };
351        let remote_jid_str = key.remote_jid.as_deref().unwrap_or("");
352        let msg_id = key.id.as_deref().unwrap_or("");
353
354        let cache_key = match remote_jid_str.parse::<Jid>() {
355            Ok(jid) => ChatMessageId::new(jid, msg_id.to_owned()),
356            Err(_) => {
357                warn!(
358                    "PDO response has unparseable remote_jid: {}",
359                    remote_jid_str
360                );
361                return;
362            }
363        };
364
365        let pending = self.pdo_pending_requests.remove(&cache_key).await;
366
367        let elapsed = pending
368            .as_ref()
369            .map(|p| p.requested_at.elapsed().as_millis())
370            .unwrap_or(0);
371
372        info!(
373            "Received PDO placeholder response for message {} (took {}ms)",
374            msg_id, elapsed
375        );
376
377        let mut message_info = if let Some(pending) = pending {
378            pending.message_info
379        } else {
380            match self.message_info_from_web_message_info(&web_msg_info).await {
381                Ok(info) => Arc::new(info),
382                Err(e) => {
383                    warn!(
384                        "Failed to reconstruct MessageInfo from PDO response: {:?}",
385                        e
386                    );
387                    return;
388                }
389            }
390        };
391
392        let Some(message) = web_msg_info.message.take() else {
393            // Expected when the phone could not decrypt the message either;
394            // WA Web only counts this outcome in telemetry, with no warning.
395            info!("PDO response WebMessageInfo missing message content");
396            return;
397        };
398
399        {
400            use wacore::proto_helpers::MessageExt;
401            let mi = Arc::make_mut(&mut message_info);
402            if mi.ephemeral_expiration.is_none() {
403                mi.ephemeral_expiration = message.get_base_message().get_ephemeral_expiration();
404            }
405            mi.unavailable_request_id = if request_id.is_empty() {
406                None
407            } else {
408                Some(request_id.to_owned())
409            };
410        }
411
412        info!(
413            "Dispatching PDO-recovered message {} from {} via phone (request_id={})",
414            message_info.id,
415            message_info.source.sender.observe(),
416            request_id
417        );
418
419        // PDO recovery is event-only (its ack runs on the PDO path, not the
420        // message pipeline), so this bypasses the commit batcher on purpose.
421        self.core
422            .event_bus
423            .dispatch(wacore::types::events::Event::Messages(
424                wacore::types::events::MessageBatch::builder()
425                    .messages(Arc::from([wacore::types::events::InboundMessage::builder(
426                    )
427                    .message(Arc::from(message))
428                    .info(message_info)
429                    .build()]))
430                    .origin(wacore::types::events::BatchOrigin::Live)
431                    .build(),
432            ));
433    }
434
435    /// Reconstructs a MessageInfo from a WebMessageInfo.
436    /// This is used when we receive a PDO response but don't have the original pending request cached.
437    async fn message_info_from_web_message_info(
438        &self,
439        web_msg: &wa::WebMessageInfo,
440    ) -> Result<MessageInfo, anyhow::Error> {
441        let Some(key) = web_msg.key.as_option() else {
442            anyhow::bail!("WebMessageInfo missing key");
443        };
444
445        self.message_info_from_web_message_parts(
446            key.remote_jid.as_deref(),
447            key.from_me,
448            key.id.as_deref(),
449            key.participant.as_deref(),
450            web_msg.message_timestamp,
451            web_msg.push_name.as_deref(),
452        )
453        .await
454    }
455
456    #[allow(clippy::too_many_arguments)]
457    async fn message_info_from_web_message_parts(
458        &self,
459        remote_jid: Option<&str>,
460        from_me: Option<bool>,
461        id: Option<&str>,
462        participant: Option<&str>,
463        message_timestamp: Option<u64>,
464        push_name: Option<&str>,
465    ) -> Result<MessageInfo, anyhow::Error> {
466        let remote_jid: Jid = remote_jid
467            .ok_or_else(|| anyhow::anyhow!("MessageKey missing remoteJid"))?
468            .parse()?;
469        let is_group = remote_jid.is_group();
470        let is_from_me = from_me.unwrap_or(false);
471
472        // `key.participant` is the real author for any chat where the sender
473        // differs from the remote_jid — groups AND broadcasts (including
474        // status). Falling back to remote_jid for broadcasts would surface
475        // `status@broadcast` as the sender and erase the author. Matches the
476        // response-handler construction in WAWebNonMessageDataRequestHandlerPlaceholderResend
477        // which maps participant to `author` for both broadcast branches.
478        let sender = if let Some(p) = participant {
479            p.parse()?
480        } else if is_from_me {
481            self.persistence_manager
482                .get_device_snapshot()
483                .pn
484                .clone()
485                .unwrap_or_else(|| remote_jid.clone())
486        } else {
487            remote_jid.clone()
488        };
489
490        let timestamp = message_timestamp
491            .map(|ts| wacore::time::from_secs_or_now(ts as i64))
492            .unwrap_or_else(wacore::time::now_utc);
493
494        Ok(MessageInfo {
495            id: id.unwrap_or_default().to_owned(),
496            server_id: 0,
497            r#type: String::new(),
498            source: MessageSource {
499                chat: remote_jid,
500                sender,
501                sender_alt: None,
502                recipient_alt: None,
503                is_from_me,
504                is_group,
505                addressing_mode: None,
506                broadcast_list_owner: None,
507                recipient: None,
508            },
509            timestamp,
510            push_name: push_name.unwrap_or_default().to_owned(),
511            category: MessageCategory::default(),
512            multicast: false,
513            media_type: String::new(),
514            edit: EditAttribute::default(),
515            bot_info: None,
516            meta_info: MsgMetaInfo::default(),
517            verified_name: None,
518            device_sent_meta: None,
519            ephemeral_expiration: None,
520            is_offline: false,
521            unavailable_request_id: None,
522            server_timestamp_us: None,
523            verified_level: None,
524            verified_name_serial: None,
525            peer_recipient_pn: None,
526            comment_target: None,
527            bcl_participants: Vec::new(),
528        })
529    }
530
531    /// Age-gated PDO send, awaitable so it can run before a transport ack inside
532    /// one flush task (when PDO is the sole recovery, e.g. `<unavailable>`).
533    /// `fromMe` is NOT excluded: own-device fan-out that fails to decrypt has PDO
534    /// as its only recovery (WAWebNonMessageDataRequestPlaceholderMessageResendUtils).
535    ///
536    /// Returns `false` only on a transient send failure: the caller must then
537    /// NOT ack, so the stanza stays in the offline queue for another attempt.
538    /// Age-skip counts as a deliberate give-up (`true`), so ancient stanzas are
539    /// still cleared.
540    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.pdo.run_request", level = "debug", skip_all, fields(chat = %info.source.chat.observe(), sender = %info.source.sender.observe(), msg_id = %info.id)))]
541    pub(crate) async fn run_pdo_request(self: &Arc<Self>, info: &Arc<MessageInfo>) -> bool {
542        // Skip ancient messages (14d, matching the AB prop), compared in seconds
543        // like WA Web's `age_s > i`. Uses the wacore time primitive (mockable).
544        const PDO_MAX_AGE_SECS: i64 = 14 * 24 * 60 * 60;
545        let age_secs = wacore::time::now_secs() - info.timestamp.timestamp();
546        if age_secs > PDO_MAX_AGE_SECS {
547            debug!(
548                "PDO request skipped for message {} (age {age_secs}s exceeds {PDO_MAX_AGE_SECS}s limit)",
549                info.id,
550            );
551            return true;
552        }
553        match self.send_pdo_placeholder_resend_request(info).await {
554            Ok(()) => true,
555            Err(e) => {
556                warn!(
557                    "Failed to send PDO request for message {} from {}: {:?}",
558                    info.id,
559                    info.source.sender.observe(),
560                    e
561                );
562                false
563            }
564        }
565    }
566}
567
568#[cfg(test)]
569#[allow(clippy::disallowed_methods)]
570mod tests {
571    use super::self_peer_target;
572    use wacore::store::Device;
573    use wacore_binary::{Jid, JidExt, Server};
574
575    fn empty_device() -> Device {
576        Device {
577            pn: None,
578            lid: None,
579            ..Device::default()
580        }
581    }
582
583    /// LID-migrated bots must address peer messages over LID so the
584    /// pkmsg emitted alongside the PDO refreshes the phone's LID-keyed
585    /// Signal slot — sending the same pkmsg to PN leaves the LID slot
586    /// on a diverged ratchet and the inbound side never recovers.
587    /// Whatsmeow's `SendPeerMessage` picks the same way via
588    /// `cli.getOwnID().ToNonAD()` (`Store.GetJID()` returns LID
589    /// post-migration).
590    #[test]
591    fn self_peer_target_prefers_lid_when_present() {
592        let mut device = empty_device();
593        device.pn = Some(Jid::pn_device("559999999999", 33));
594        device.lid = Some(Jid::lid_device("111111111111111", 33));
595
596        let target = self_peer_target(&device).expect("LID present");
597
598        assert_eq!(target.user, "111111111111111");
599        assert_eq!(target.server, Server::Lid);
600        assert_eq!(target.device, 0);
601        assert!(!target.is_ad());
602    }
603
604    /// Pre-LID-migration accounts only have a PN. Fall back so peer
605    /// messages still route to the primary phone via the PN slot.
606    #[test]
607    fn self_peer_target_falls_back_to_pn_without_lid() {
608        let mut device = empty_device();
609        device.pn = Some(Jid::pn_device("559999999999", 33));
610
611        let target = self_peer_target(&device).expect("PN present");
612
613        assert_eq!(target.user, "559999999999");
614        assert_eq!(target.server, Server::Pn);
615        assert_eq!(target.device, 0);
616    }
617
618    /// Pre-login (no PN/LID yet) must surface as a typed error rather
619    /// than addressing a bogus JID.
620    #[test]
621    fn self_peer_target_errors_when_no_identity_known() {
622        let device = empty_device();
623        assert!(
624            matches!(
625                self_peer_target(&device),
626                Err(crate::client::ClientError::NotLoggedIn)
627            ),
628            "must require either PN or LID"
629        );
630    }
631
632    // Reconstruction-path tests share a bare Client wired to mock transport
633    // and an in-memory SQLite backend. The only thing they vary is the
634    // WebMessageInfo they hand to `message_info_from_web_message_info`.
635
636    async fn setup_reconstruct_client() -> std::sync::Arc<crate::client::Client> {
637        use crate::test_utils::{MockHttpClient, create_test_backend};
638        use crate::{
639            client::Client, runtime_impl::TokioRuntime,
640            store::persistence_manager::PersistenceManager, transport::mock::MockTransportFactory,
641        };
642        use std::sync::Arc;
643
644        let backend = create_test_backend().await;
645        let pm = Arc::new(PersistenceManager::new(backend).await.unwrap());
646        let (client, _rx) = Client::new(
647            Arc::new(TokioRuntime),
648            pm,
649            Arc::new(MockTransportFactory::new()),
650            Arc::new(MockHttpClient),
651            None,
652        )
653        .await;
654        client
655    }
656
657    fn make_web_msg(
658        remote_jid: &str,
659        from_me: bool,
660        id: &str,
661        participant: Option<&str>,
662    ) -> waproto::whatsapp::WebMessageInfo {
663        use waproto::whatsapp as wa;
664        wa::WebMessageInfo {
665            key: buffa::MessageField::some(wa::MessageKey {
666                remote_jid: Some(remote_jid.into()),
667                from_me: Some(from_me),
668                id: Some(id.into()),
669                participant: participant.map(|p| p.into()),
670            }),
671            ..Default::default()
672        }
673    }
674
675    /// The reconstruction path preserves the real author for status
676    /// broadcasts via `key.participant`. Using `remote_jid` as sender
677    /// would surface `status@broadcast` and erase the author.
678    #[tokio::test]
679    async fn test_reconstruct_prefers_participant_for_status_broadcast() {
680        let client = setup_reconstruct_client().await;
681        let author_jid = "203040904720543@lid";
682        let web_msg = make_web_msg("status@broadcast", false, "STATUS_PDO_1", Some(author_jid));
683
684        let info = client
685            .message_info_from_web_message_info(&web_msg)
686            .await
687            .unwrap();
688
689        assert_eq!(info.source.chat.to_string(), "status@broadcast");
690        assert_eq!(info.source.sender.to_string(), author_jid);
691    }
692
693    /// DM without participant falls back to remote_jid as the sender,
694    /// preserving the pre-fix behaviour for the DM case.
695    #[tokio::test]
696    async fn test_reconstruct_dm_falls_back_to_remote_jid() {
697        let client = setup_reconstruct_client().await;
698        let peer = "5511999998888@s.whatsapp.net";
699        let web_msg = make_web_msg(peer, false, "DM_PDO_1", None);
700
701        let info = client
702            .message_info_from_web_message_info(&web_msg)
703            .await
704            .unwrap();
705
706        assert_eq!(info.source.chat.to_string(), peer);
707        assert_eq!(info.source.sender.to_string(), peer);
708    }
709
710    #[tokio::test]
711    async fn test_reconstruct_from_web_message_info_view() {
712        use buffa::Message as _;
713        use waproto::whatsapp as wa;
714
715        let client = setup_reconstruct_client().await;
716        let author_jid = "203040904720543@lid";
717        let mut web_msg = make_web_msg(
718            "status@broadcast",
719            false,
720            "STATUS_PDO_VIEW_1",
721            Some(author_jid),
722        );
723        web_msg.push_name = Some("Recovered Sender".to_string());
724        web_msg.message_timestamp = Some(1_700_000_000);
725        let encoded = web_msg.encode_to_vec();
726        let decoded = wa::WebMessageInfo::decode_from_slice(&encoded).expect("should decode");
727
728        let info = client
729            .message_info_from_web_message_info(&decoded)
730            .await
731            .unwrap();
732
733        assert_eq!(info.id, "STATUS_PDO_VIEW_1");
734        assert_eq!(info.source.chat.to_string(), "status@broadcast");
735        assert_eq!(info.source.sender.to_string(), author_jid);
736        assert_eq!(info.push_name, "Recovered Sender");
737    }
738
739    /// LID-migrated 1-on-1 responses carry `remote_jid` in LID form and no
740    /// `participant` (WA Web's request side strips it when building the new
741    /// MsgKey, and `msgKeyToProtobuf` then omits it). Reconstruction must
742    /// still resolve the sender to that LID remote, not to something else.
743    #[tokio::test]
744    async fn test_reconstruct_lid_migrated_dm_uses_lid_remote() {
745        let client = setup_reconstruct_client().await;
746        let peer_lid = "236395184570386@lid";
747        let web_msg = make_web_msg(peer_lid, false, "LID_DM_PDO_1", None);
748
749        let info = client
750            .message_info_from_web_message_info(&web_msg)
751            .await
752            .unwrap();
753
754        assert_eq!(info.source.chat.to_string(), peer_lid);
755        assert_eq!(info.source.sender.to_string(), peer_lid);
756        assert!(!info.source.is_group);
757        assert!(!info.source.is_from_me);
758    }
759
760    /// fromMe LID DM: the response has no participant (WA Web omits it when
761    /// fromMe), so the reconstructed sender must come from the device's own
762    /// PN, not from the LID remote_jid.
763    #[tokio::test]
764    async fn test_reconstruct_lid_migrated_dm_from_me_uses_own_pn() {
765        let client = setup_reconstruct_client().await;
766        let peer_lid = "236395184570386@lid";
767        let web_msg = make_web_msg(peer_lid, true, "LID_DM_FROM_ME_1", None);
768
769        let info = client
770            .message_info_from_web_message_info(&web_msg)
771            .await
772            .unwrap();
773
774        // No own PN configured on a fresh test client, so sender falls back
775        // to `remote_jid`. The point is that the participant-less fromMe
776        // path reconstructs without panic.
777        assert_eq!(info.source.chat.to_string(), peer_lid);
778        assert!(info.source.is_from_me);
779    }
780
781    // Once-per-message memo tests: WA Web sends at most one placeholder
782    // resend request per message per session
783    // (WAWebNonMessageDataRequestPlaceholderMessageResendUtils); these pin
784    // the same contract onto `pdo_requested`.
785
786    fn make_group_message_info(
787        chat: &str,
788        sender: &str,
789        id: &str,
790    ) -> std::sync::Arc<wacore::types::message::MessageInfo> {
791        use wacore::types::message::{MessageInfo, MessageSource};
792        std::sync::Arc::new(MessageInfo {
793            id: id.to_owned(),
794            source: MessageSource {
795                chat: chat.parse().expect("chat jid"),
796                sender: sender.parse().expect("sender jid"),
797                is_group: true,
798                ..Default::default()
799            },
800            timestamp: wacore::time::now_utc(),
801            ..Default::default()
802        })
803    }
804
805    async fn set_own_pn(client: &std::sync::Arc<crate::client::Client>) {
806        client
807            .persistence_manager
808            .process_command(crate::store::commands::DeviceCommand::SetId(Some(
809                "5511777776666:2@s.whatsapp.net".parse().expect("own jid"),
810            )))
811            .await;
812    }
813
814    /// A message that already went through one placeholder resend must not
815    /// trigger another request, no matter how many times the server
816    /// redelivers the undecryptable original.
817    #[tokio::test]
818    async fn pdo_request_skipped_when_already_requested() {
819        use wacore::types::message::ChatMessageId;
820
821        let client = setup_reconstruct_client().await;
822        set_own_pn(&client).await;
823
824        let info = make_group_message_info(
825            "120363000000000001@g.us",
826            "203040904720543@lid",
827            "PDO_ONCE_1",
828        );
829        let key = ChatMessageId::new(info.source.chat.clone(), info.id.clone());
830        client.pdo_requested.insert(key.clone(), ()).await;
831
832        let res = client.send_pdo_placeholder_resend_request(&info).await;
833
834        assert!(res.is_ok(), "gated path reports success: {res:?}");
835        assert!(
836            client.pdo_pending_requests.get(&key).await.is_none(),
837            "gated request must not register a pending entry"
838        );
839    }
840
841    /// A transient send failure must release the once-per-message slot, or
842    /// one bad send would permanently block recovery for that message.
843    #[tokio::test]
844    async fn pdo_request_failure_releases_once_per_message_slot() {
845        use wacore::types::message::ChatMessageId;
846
847        let client = setup_reconstruct_client().await;
848        set_own_pn(&client).await;
849        // A live client has finished offline sync long before any PDO; skip
850        // the offline-delivery wait so the send failure surfaces immediately.
851        client
852            .offline_sync_completed
853            .store(true, std::sync::atomic::Ordering::Relaxed);
854
855        let info = make_group_message_info(
856            "120363000000000001@g.us",
857            "203040904720543@lid",
858            "PDO_ONCE_2",
859        );
860        let key = ChatMessageId::new(info.source.chat.clone(), info.id.clone());
861
862        let res = tokio::time::timeout(
863            std::time::Duration::from_secs(15),
864            client.send_pdo_placeholder_resend_request(&info),
865        )
866        .await
867        .expect("send attempt must resolve fast without a live transport");
868
869        assert!(res.is_err(), "no live transport, the send must fail");
870        assert!(
871            client.pdo_requested.get(&key).await.is_none(),
872            "failed send must release the once-per-message slot"
873        );
874        assert!(
875            client.pdo_pending_requests.get(&key).await.is_none(),
876            "failed send must clear the pending entry"
877        );
878    }
879
880    /// A phone response without content consumes the pending slot but keeps
881    /// the memo: the phone has nothing to share for this message, so
882    /// re-asking on the next redelivery cannot produce content either.
883    #[tokio::test]
884    async fn pdo_missing_content_response_clears_pending_but_keeps_memo() {
885        use buffa::Message as _;
886        use wacore::types::message::ChatMessageId;
887
888        let client = setup_reconstruct_client().await;
889        let chat = "5511999998888@s.whatsapp.net";
890        let msg_id = "PDO_ONCE_3";
891        let key = ChatMessageId::new(chat.parse().expect("chat jid"), msg_id.to_owned());
892
893        client.pdo_requested.insert(key.clone(), ()).await;
894        client
895            .pdo_pending_requests
896            .insert(
897                key.clone(),
898                super::PendingPdoRequest {
899                    message_info: make_group_message_info(chat, chat, msg_id),
900                    requested_at: wacore::time::Instant::now(),
901                },
902            )
903            .await;
904
905        let web_msg = waproto::whatsapp::WebMessageInfo {
906            key: buffa::MessageField::some(waproto::whatsapp::MessageKey {
907                remote_jid: Some(chat.to_owned()),
908                from_me: Some(false),
909                id: Some(msg_id.to_owned()),
910                participant: None,
911            }),
912            ..Default::default()
913        };
914        let response = waproto::whatsapp::message::peer_data_operation_request_response_message::peer_data_operation_result::PlaceholderMessageResendResponse {
915            web_message_info_bytes: Some(web_msg.encode_to_vec()),
916        };
917
918        client
919            .handle_placeholder_resend_response(&response, "req-1")
920            .await;
921
922        assert!(
923            client.pdo_pending_requests.get(&key).await.is_none(),
924            "response consumes the pending slot"
925        );
926        assert!(
927            client.pdo_requested.get(&key).await.is_some(),
928            "memo must survive a content-less response"
929        );
930    }
931}