1use 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
33fn 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 #[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 let resolved_jid = self.resolve_encryption_jid(&info.source.chat).await;
74 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 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 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 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 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 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 #[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 #[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 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 #[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 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 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 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 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 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 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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 assert_eq!(info.source.chat.to_string(), peer_lid);
778 assert!(info.source.is_from_me);
779 }
780
781 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 #[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 #[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 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 #[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}