1use crate::types::events::{Event, LazyHistorySync};
2use bytes::Bytes;
3use std::sync::Arc;
4use wacore::history_sync::{
5 HistoryMsgSecretRecordRef, HistoryMsgSecretRecordVisitor, TcTokenCandidate,
6 process_history_sync_bytes_with_record_sink,
7};
8use wacore::messages::DetachedHistorySyncNotification;
9use wacore::msg_secret::{MsgSecretPolicy, MsgSecretRetention, RetentionClass};
10use wacore::store::traits::MsgSecretEntry;
11use wacore_binary::{Jid, JidExt as _};
12
13use crate::client::Client;
14
15const HISTORY_MSG_SECRET_SIZE: usize = wacore::reporting_token::MESSAGE_SECRET_SIZE;
16
17#[derive(Clone, Copy)]
21struct HistorySecretSeedConfig {
22 enabled: bool,
23 policy: MsgSecretPolicy,
24 retention: MsgSecretRetention,
25 now: i64,
26}
27
28impl HistorySecretSeedConfig {
29 fn snapshot(config: &crate::cache_config::CacheConfig) -> Self {
30 Self {
31 enabled: config.seed_msg_secrets_from_history,
32 policy: config.msg_secret_policy,
33 retention: config.msg_secret_retention,
34 now: wacore::time::now_secs(),
35 }
36 }
37
38 #[inline]
39 fn accepts_secret(self, secret_len: usize) -> bool {
40 self.enabled && self.policy.persists() && secret_len == HISTORY_MSG_SECRET_SIZE
41 }
42
43 fn retained_class(
44 self,
45 secret_len: usize,
46 chat_is_bot: bool,
47 is_bot_invocation: bool,
48 is_poll_or_event: bool,
49 timestamp: Option<u64>,
50 ) -> Option<RetentionClass> {
51 if !self.accepts_secret(secret_len) {
52 return None;
53 }
54 let class = wacore::msg_secret::classify_from_flags(
55 chat_is_bot || is_bot_invocation,
56 is_poll_or_event,
57 );
58 if self.policy.bot_only() && class != RetentionClass::Bot {
59 return None;
60 }
61 if self.policy.prunes()
62 && !wacore::msg_secret::within_seed_horizon(&self.retention, class, timestamp, self.now)
63 {
64 return None;
65 }
66 Some(class)
67 }
68}
69
70struct HistorySecretSeedCollector {
74 config: HistorySecretSeedConfig,
75 own_pn: Option<Jid>,
76 own_lid: Option<Jid>,
77 last_chat_id: String,
78 last_chat_is_bot: Option<bool>,
79 last_chat: Option<Jid>,
80 last_chat_non_ad_id: Option<Arc<str>>,
81 entries: Vec<MsgSecretEntry>,
82}
83
84impl HistorySecretSeedCollector {
85 fn new(config: HistorySecretSeedConfig, own_pn: Option<Jid>, own_lid: Option<Jid>) -> Self {
86 Self {
87 config,
88 own_pn,
89 own_lid,
90 last_chat_id: String::new(),
91 last_chat_is_bot: None,
92 last_chat: None,
93 last_chat_non_ad_id: None,
94 entries: Vec::new(),
95 }
96 }
97
98 fn collect(&mut self, record: HistoryMsgSecretRecordRef<'_>) {
99 if !self.config.accepts_secret(record.secret.len()) {
100 return;
101 }
102
103 if self.last_chat_id != record.chat_id {
104 self.last_chat_id.clear();
105 self.last_chat_id.push_str(record.chat_id);
106 self.last_chat = None;
107 self.last_chat_non_ad_id = None;
108 self.last_chat_is_bot =
109 wacore_binary::jid::parse_jid_ref(record.chat_id).map(|chat| chat.is_bot());
110 if self.last_chat_is_bot.is_none()
111 && let Ok(chat) = record.chat_id.parse::<Jid>()
112 {
113 self.last_chat_is_bot = Some(chat.is_bot());
114 self.last_chat_non_ad_id = Some(Arc::from(chat.to_non_ad_string()));
115 self.last_chat = Some(chat);
116 }
117 }
118
119 let Some(class) = self.config.retained_class(
120 record.secret.len(),
121 self.last_chat_is_bot.unwrap_or(false),
122 record.is_bot_invocation,
123 record.is_poll_or_event,
124 record.timestamp,
125 ) else {
126 return;
127 };
128 let expires_at = wacore::msg_secret::expires_at(
129 self.config.policy,
130 &self.config.retention,
131 class,
132 record.timestamp,
133 self.config.now,
134 );
135 let message_ts = record
136 .timestamp
137 .and_then(|timestamp| i64::try_from(timestamp).ok())
138 .unwrap_or(0);
139
140 if self.last_chat.is_none() {
141 let Ok(chat) = record.chat_id.parse::<Jid>() else {
142 return;
143 };
144 self.last_chat_is_bot = Some(chat.is_bot());
145 self.last_chat_non_ad_id = Some(Arc::from(chat.to_non_ad_string()));
146 self.last_chat = Some(chat);
147 }
148 let (Some(chat), Some(chat_non_ad_id)) =
149 (self.last_chat.as_ref(), self.last_chat_non_ad_id.as_ref())
150 else {
151 return;
152 };
153
154 let senders =
155 history_msg_secret_senders(chat, record, self.own_pn.as_ref(), self.own_lid.as_ref());
156 if senders.iter().all(Option::is_none) {
157 return;
158 }
159
160 let chat_id = Arc::clone(chat_non_ad_id);
161 let msg_id: Arc<str> = Arc::from(record.msg_id);
162 let secret = match <&[u8; HISTORY_MSG_SECRET_SIZE]>::try_from(record.secret) {
163 Ok(secret) => *secret,
164 Err(_) => return,
165 };
166 for sender in senders.into_iter().flatten() {
167 let sender_id = MsgSecretEntry::sender_id_for(chat, &chat_id, &sender);
168 self.entries.push(MsgSecretEntry {
169 chat: Arc::clone(&chat_id),
170 sender: sender_id,
171 msg_id: Arc::clone(&msg_id),
172 secret,
173 expires_at,
174 message_ts,
175 });
176 }
177 }
178
179 fn into_entries(self) -> Vec<MsgSecretEntry> {
180 self.entries
181 }
182}
183
184impl HistoryMsgSecretRecordVisitor for &mut HistorySecretSeedCollector {
185 fn visit(&mut self, record: HistoryMsgSecretRecordRef<'_>) -> usize {
186 let previous_len = self.entries.len();
187 self.collect(record);
188 self.entries.len() - previous_len
189 }
190
191 fn reserve(&mut self, additional: usize) {
192 self.entries.reserve(additional);
193 }
194
195 fn retained_item_size(&self) -> Option<std::num::NonZeroUsize> {
196 std::num::NonZeroUsize::new(size_of::<MsgSecretEntry>())
197 }
198}
199
200impl Client {
201 #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.media.history_sync", level = "debug", skip_all, fields(msg_id = %message_id)))]
202 pub(crate) async fn handle_history_sync(
203 self: &Arc<Self>,
204 message_id: String,
205 notification: DetachedHistorySyncNotification,
206 ) {
207 if self.is_shutting_down() {
208 log::debug!(
209 "Dropping history sync {} during shutdown (Type: {:?})",
210 message_id,
211 notification.notification.sync_type
212 );
213 return;
214 }
215
216 if self.skip_history_sync_enabled() {
217 log::debug!(
218 "Skipping history sync for message {} (Type: {:?})",
219 message_id,
220 notification.notification.sync_type
221 );
222 self.send_protocol_receipt(
228 message_id,
229 crate::types::presence::ReceiptType::HistorySync,
230 )
231 .await;
232 return;
233 }
234
235 let payload_bytes = notification.inline_payload.as_ref().map_or(0, Bytes::len);
237 let tracker = self.begin_history_sync_task(payload_bytes);
238 let task = crate::sync_task::MajorSyncTask::HistorySync {
239 message_id,
240 notification: Box::new(notification),
241 tracker,
242 };
243 if let Err(e) = self.major_sync_task_sender.send(task).await {
244 if self.is_shutting_down() {
245 log::debug!("Dropping history sync task during shutdown: {e}");
246 } else {
247 log::error!("Failed to enqueue history sync task: {e}");
248 }
249 }
250 }
251
252 #[cfg(test)]
256 pub(crate) async fn process_history_sync_task(
257 self: &Arc<Self>,
258 message_id: String,
259 notification: DetachedHistorySyncNotification,
260 ) {
261 let payload_bytes = notification.inline_payload.as_ref().map_or(0, Bytes::len);
262 let mut tracker = self.begin_history_sync_task(payload_bytes);
263 self.process_history_sync_task_tracked(message_id, notification, &mut tracker)
264 .await;
265 }
266
267 #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.media.history_sync_task", level = "debug", skip_all, fields(msg_id = %message_id)))]
268 pub(crate) async fn process_history_sync_task_tracked(
269 self: &Arc<Self>,
270 message_id: String,
271 notification: DetachedHistorySyncNotification,
272 tracker: &mut crate::sync_task::HistorySyncTaskTracker,
273 ) {
274 if self.is_shutting_down() {
275 log::debug!("Aborting history sync {} before processing", message_id);
276 return;
277 }
278
279 let DetachedHistorySyncNotification {
280 mut notification,
281 inline_payload,
282 } = notification;
283
284 log::info!(
285 "Processing history sync for message {} (Size: {}, Type: {:?})",
286 message_id,
287 notification.file_length.unwrap_or(0),
288 notification.sync_type
289 );
290
291 self.send_protocol_receipt(
292 message_id.clone(),
293 crate::types::presence::ReceiptType::HistorySync,
294 )
295 .await;
296
297 if self.is_shutting_down() {
298 log::debug!(
299 "Aborting history sync {} after receipt during shutdown",
300 message_id
301 );
302 return;
303 }
304
305 let (compressed_data, payload_bytes) = if let Some(inline_payload) = inline_payload {
307 log::info!(
308 "Found inline history sync payload ({} bytes). Using directly.",
309 inline_payload.len()
310 );
311 let payload_bytes = inline_payload.len();
312 (inline_payload, payload_bytes)
313 } else {
314 log::info!("Downloading external history sync blob...");
315 if self.is_shutting_down() || !self.is_connected() {
316 log::debug!(
317 "Aborting history sync {} before blob download: client disconnected",
318 message_id
319 );
320 return;
321 }
322 match self.download(¬ification).await {
327 Ok(bytes) => {
328 log::info!("Successfully downloaded history sync blob.");
329 let payload_bytes = bytes.len();
333 (Bytes::from(bytes), payload_bytes)
334 }
335 Err(e) => {
336 if self.is_shutting_down() {
337 log::debug!(
338 "History sync blob download aborted during shutdown: {:?}",
339 e
340 );
341 } else {
342 log::error!("Failed to download history sync blob: {:?}", e);
343 }
344 return;
345 }
346 }
347 };
348 tracker.set_payload_bytes(payload_bytes);
349
350 let device_snapshot = self.persistence_manager.get_device_snapshot();
351 let own_pn = device_snapshot.pn.as_ref().map(|jid| jid.to_non_ad());
352 let own_lid = device_snapshot.lid.as_ref().map(|jid| jid.to_non_ad());
353 let own_user = own_pn.as_ref().map(|jid| jid.user.clone());
354 let secret_seed_config = HistorySecretSeedConfig::snapshot(&self.cache_config);
355 let mut secret_collector =
356 HistorySecretSeedCollector::new(secret_seed_config, own_pn, own_lid);
357
358 const INLINE_THRESHOLD: usize = 256 * 1024;
365 let parse_result = if compressed_data.len() < INLINE_THRESHOLD {
366 let result = process_history_sync_bytes_with_record_sink(
367 compressed_data,
368 own_user.as_deref(),
369 true,
370 &mut secret_collector,
371 );
372 Some((result, secret_collector.into_entries()))
373 } else {
374 let (result_tx, result_rx) = futures::channel::oneshot::channel();
375 let blocking_fut = self.runtime.spawn_blocking(Box::new(move || {
376 let result = process_history_sync_bytes_with_record_sink(
377 compressed_data,
378 own_user.as_deref(),
379 true,
380 &mut secret_collector,
381 );
382 let _ = result_tx.send((result, secret_collector.into_entries()));
383 }));
384 self.runtime
385 .spawn(Box::pin(async move {
386 blocking_fut.await;
387 }))
388 .detach();
389 result_rx.await.ok()
390 };
391
392 if self.is_shutting_down() {
393 log::debug!(
394 "Aborting history sync {} after parse during shutdown",
395 message_id
396 );
397 return;
398 }
399
400 match parse_result {
401 Some((Ok(sync_result), secret_entries)) => {
402 log::info!(
403 "Successfully processed HistorySync (message {message_id}); {} conversations",
404 sync_result.conversations_processed
405 );
406
407 if let Some(new_name) = sync_result.own_pushname {
409 log::info!("Updating own push name from history sync to '{new_name}'");
410 self.update_push_name_and_notify(new_name).await;
411 }
412
413 if let Some(salt) = sync_result.nct_salt {
416 log::info!(
417 "History sync provided NCT salt ({} bytes); applying as backfill only",
418 salt.len()
419 );
420 self.persistence_manager
421 .process_command(
422 wacore::store::commands::DeviceCommand::SetNctSaltFromHistorySync(salt),
423 )
424 .await;
425 }
426
427 for candidate in sync_result.tc_token_candidates {
429 self.store_tc_token_candidate(candidate).await;
430 }
431
432 self.store_history_sync_msg_secret_entries(secret_entries, secret_seed_config)
433 .await;
434
435 if !sync_result.lid_mappings.is_empty() {
441 let pairs: Vec<(String, String)> = sync_result
442 .lid_mappings
443 .into_iter()
444 .map(|m| (m.lid, m.phone_number))
445 .collect();
446 log::info!(
447 "History sync provided {} PN-LID mappings; learning",
448 pairs.len()
449 );
450 self.learn_lid_pn_mappings_batch(
451 pairs,
452 crate::lid_pn_cache::LearningSource::Other,
453 false,
454 )
455 .await;
456 }
457
458 if let Some(compressed) = sync_result.compressed_bytes {
464 let lazy_hs = LazyHistorySync::new(
465 compressed,
466 sync_result.decompressed_size,
467 notification.sync_type.map(|t| t as i32).unwrap_or(0),
468 notification.chunk_order,
469 notification.progress,
470 )
471 .with_peer_data_request_session_id(
472 notification.peer_data_request_session_id.take(),
473 );
474 self.core
475 .event_bus
476 .dispatch(Event::HistorySync(Box::new(lazy_hs)));
477 }
478 }
479 Some((Err(e), _)) => {
480 log::error!("Failed to process HistorySync data: {:?}", e);
481 }
482 None => {
483 log::error!("History sync blocking task was cancelled");
484 }
485 }
486 }
487
488 #[cfg_attr(
489 feature = "tracing",
490 tracing::instrument(
491 name = "wa.history.secrets.store",
492 level = "debug",
493 skip_all,
494 fields(entries = entries.len() as u64)
495 )
496 )]
497 async fn store_history_sync_msg_secret_entries(
498 &self,
499 entries: Vec<MsgSecretEntry>,
500 seed_config: HistorySecretSeedConfig,
501 ) -> usize {
502 if !seed_config.enabled {
503 log::debug!(
505 target: "Client/MsgSecret",
506 "Skipping history-sync msg_secret seed (seed_msg_secrets_from_history = false)"
507 );
508 return 0;
509 }
510 if !seed_config.policy.persists() {
511 log::debug!(
513 target: "Client/MsgSecret",
514 "Skipping history-sync msg_secret seed (policy = {:?})",
515 seed_config.policy
516 );
517 return 0;
518 }
519
520 if entries.is_empty() {
521 return 0;
522 }
523
524 match self
529 .persistence_manager
530 .backend()
531 .put_msg_secrets(entries)
532 .await
533 {
534 Ok(stored) => stored,
535 Err(e) => {
536 log::warn!("failed to persist history-sync messageSecrets: {e:?}");
537 0
538 }
539 }
540 }
541
542 #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.media.history_sync_error_receipt", level = "debug", skip_all, fields(msg_id = %message_id), err(Debug)))]
551 pub async fn send_history_sync_server_error_receipt(
552 &self,
553 message_id: &str,
554 media_key: &[u8],
555 ) -> Result<(), anyhow::Error> {
556 let own_jid = self
557 .pn()
558 .ok_or(crate::client::ClientError::NotLoggedIn)?
559 .to_non_ad();
560 let (ciphertext, iv) =
561 wacore::media_retry::encrypt_media_retry_receipt(media_key, message_id)?;
562 let node = wacore::media_retry::build_history_sync_server_error_receipt(
563 &own_jid,
564 message_id,
565 &ciphertext,
566 &iv,
567 );
568 self.send_node(node).await?;
569 Ok(())
570 }
571
572 async fn store_tc_token_candidate(&self, candidate: TcTokenCandidate) {
574 let jid: Jid = match candidate.id.parse() {
575 Ok(j) => j,
576 Err(_) => return,
577 };
578
579 let resolved_lid = if jid.is_lid() {
580 None
581 } else {
582 self.lid_pn_cache.get_current_lid(&jid.user).await
583 };
584 let token_key: &str = resolved_lid.as_deref().unwrap_or(&jid.user);
585
586 let backend = self.persistence_manager.backend();
587
588 if let Err(e) = backend
592 .store_received_tc_token(
593 token_key,
594 &candidate.tc_token,
595 candidate.tc_token_timestamp as i64,
596 )
597 .await
598 {
599 log::warn!(target: "Client/TcToken", "Failed to store history sync tctoken for {}: {e}", jid.observe());
600 return;
601 }
602 if let Some(sender_ts) = candidate.tc_token_sender_timestamp
603 && let Err(e) = backend
604 .touch_tc_token_sender_timestamp(token_key, sender_ts as i64)
605 .await
606 {
607 log::warn!(target: "Client/TcToken", "Failed to record history sync sender_timestamp for {}: {e}", jid.observe());
608 }
609 log::debug!(target: "Client/TcToken", "Stored tctoken from history sync for {} (t={})", jid.observe(), candidate.tc_token_timestamp);
610 }
611}
612
613const MAX_HISTORY_SECRET_SENDERS: usize = 2;
616type HistorySecretSenders = [Option<Jid>; MAX_HISTORY_SECRET_SENDERS];
617
618fn history_msg_secret_senders(
619 chat: &Jid,
620 record: HistoryMsgSecretRecordRef<'_>,
621 own_pn: Option<&Jid>,
622 own_lid: Option<&Jid>,
623) -> HistorySecretSenders {
624 let mut senders = std::array::from_fn(|_| None);
625
626 if record.from_me {
627 if let Some(lid) = own_lid {
628 push_unique_sender(&mut senders, lid.to_non_ad());
629 }
630 if let Some(pn) = own_pn {
631 push_unique_sender(&mut senders, pn.to_non_ad());
632 }
633 return senders;
634 }
635
636 if chat.is_pn() || chat.is_lid() || chat.is_bot() {
637 push_unique_sender(&mut senders, chat.to_non_ad());
638 if chat.is_bot()
639 && let Some(lid) = own_lid
640 {
641 push_unique_sender(&mut senders, lid.to_non_ad());
642 }
643 return senders;
644 }
645
646 if let Some(raw_sender) = record.key_participant.or(record.web_msg_participant)
647 && let Ok(sender) = raw_sender.parse::<Jid>()
648 {
649 push_unique_sender(&mut senders, sender.to_non_ad());
650 }
651
652 senders
653}
654
655fn push_unique_sender(senders: &mut HistorySecretSenders, sender: Jid) {
656 if senders.iter().flatten().any(|existing| existing == &sender) {
657 return;
658 }
659 let empty = senders.iter_mut().find(|slot| slot.is_none());
660 debug_assert!(empty.is_some(), "history secret sender capacity exhausted");
661 if let Some(slot) = empty {
662 *slot = Some(sender);
663 }
664}
665
666#[cfg(test)]
667#[allow(clippy::disallowed_methods)]
668mod tests {
669 use super::*;
670 use buffa::Message as ProtoMessage;
671 use flate2::{Compression, write::ZlibEncoder};
672 use std::io::Write;
673 use std::sync::atomic::Ordering;
674 use waproto::whatsapp as wa;
675 use waproto::whatsapp::message::HistorySyncNotification;
676
677 fn compress_history_sync(history_sync: &wa::HistorySync) -> Vec<u8> {
678 let raw = history_sync.encode_to_vec();
679 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
680 encoder.write_all(&raw).expect("zlib write");
681 encoder.finish().expect("zlib finish")
682 }
683
684 fn sender_record(from_me: bool) -> HistoryMsgSecretRecordRef<'static> {
685 HistoryMsgSecretRecordRef {
686 conversation_index: 0,
687 chat_id: "",
688 from_me,
689 key_participant: None,
690 web_msg_participant: None,
691 msg_id: "M",
692 secret: &[],
693 timestamp: None,
694 is_poll_or_event: false,
695 is_bot_invocation: false,
696 }
697 }
698
699 #[test]
700 fn fixed_sender_capacity_covers_outgoing_and_incoming_bot_aliases() {
701 let pn: Jid = "5511000000001@s.whatsapp.net".parse().unwrap();
702 let lid: Jid = "111222333444555@lid".parse().unwrap();
703 let bot: Jid = "867051314767696@bot".parse().unwrap();
704
705 let outgoing = history_msg_secret_senders(&bot, sender_record(true), Some(&pn), Some(&lid));
706 assert_eq!(
707 outgoing.into_iter().flatten().collect::<Vec<_>>(),
708 vec![lid.to_non_ad(), pn.to_non_ad()],
709 "outgoing messages return before the chat-alias branch"
710 );
711
712 let incoming =
713 history_msg_secret_senders(&bot, sender_record(false), Some(&pn), Some(&lid));
714 assert_eq!(
715 incoming.into_iter().flatten().collect::<Vec<_>>(),
716 vec![bot.to_non_ad(), lid.to_non_ad()],
717 "incoming bot messages retain the chat and account-LID aliases"
718 );
719 }
720
721 #[test]
722 fn collector_refreshes_chat_cache_when_id_changes_within_conversation() {
723 const CONVERSATION_INDEX: usize = 0;
724 const FIRST_CHAT: &str = "15550000001@s.whatsapp.net";
725 const FIRST_MSG_ID: &str = "FIRST";
726 const SECOND_CHAT: &str = "15550000002@s.whatsapp.net";
727 const SECOND_MSG_ID: &str = "SECOND";
728 const SECRET: [u8; HISTORY_MSG_SECRET_SIZE] = [0x5a; HISTORY_MSG_SECRET_SIZE];
729 let config = HistorySecretSeedConfig {
730 enabled: true,
731 policy: MsgSecretPolicy::Full,
732 retention: MsgSecretRetention::default(),
733 now: 0,
734 };
735 let mut collector = HistorySecretSeedCollector::new(config, None, None);
736
737 for (chat_id, msg_id) in [(FIRST_CHAT, FIRST_MSG_ID), (SECOND_CHAT, SECOND_MSG_ID)] {
738 collector.collect(HistoryMsgSecretRecordRef {
739 conversation_index: CONVERSATION_INDEX,
740 chat_id,
741 from_me: false,
742 key_participant: None,
743 web_msg_participant: None,
744 msg_id,
745 secret: &SECRET,
746 timestamp: None,
747 is_poll_or_event: false,
748 is_bot_invocation: false,
749 });
750 }
751
752 let entries = collector.into_entries();
753 assert_eq!(entries.len(), 2);
754 assert_eq!(entries[0].chat.as_ref(), FIRST_CHAT);
755 assert_eq!(entries[0].sender, entries[0].chat);
756 assert_eq!(entries[1].chat.as_ref(), SECOND_CHAT);
757 assert_eq!(entries[1].sender, entries[1].chat);
758 }
759
760 #[tokio::test]
761 async fn process_history_sync_task_stores_message_secrets_without_handlers() {
762 let client = crate::test_utils::create_test_client_with_name("history_msg_secret").await;
763 client
764 .persistence_manager
765 .process_command(wacore::store::commands::DeviceCommand::SetId(Some(
766 "5511000000001:0@s.whatsapp.net".parse().unwrap(),
767 )))
768 .await;
769 client.is_running.store(true, Ordering::Relaxed);
770
771 let chat = "5511777776666@s.whatsapp.net";
772 let parent_id = "HIST_PARENT";
773 let secret = vec![0x44u8; 32];
774 let history_sync = wa::HistorySync {
775 sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP,
776 conversations: vec![wa::Conversation {
777 id: chat.to_string(),
778 messages: vec![wa::HistorySyncMsg {
779 message: buffa::MessageField::some(wa::WebMessageInfo {
780 key: buffa::MessageField::some(wa::MessageKey {
781 remote_jid: Some(chat.to_string()),
782 from_me: Some(false),
783 id: Some(parent_id.to_string()),
784 participant: None,
785 }),
786 message: buffa::MessageField::some(wa::Message {
787 conversation: Some("historical".to_string()),
788 ..Default::default()
789 }),
790 message_secret: Some(secret.clone()),
791 ..Default::default()
792 }),
793 msg_order_id: Some(1),
794 }],
795 ..Default::default()
796 }],
797 ..Default::default()
798 };
799 let compressed = compress_history_sync(&history_sync);
800 let notification = HistorySyncNotification {
801 file_length: Some(compressed.len() as u64),
802 sync_type: Some(wa::message::HistorySyncType::INITIAL_BOOTSTRAP),
803 initial_hist_bootstrap_inline_payload: Some(compressed),
804 ..Default::default()
805 };
806
807 client
808 .process_history_sync_task("HIST_SYNC_SECRET".to_string(), notification.into())
809 .await;
810
811 let got = client
812 .persistence_manager
813 .backend()
814 .get_msg_secret(chat, chat, parent_id)
815 .await
816 .unwrap();
817 assert_eq!(got, Some(secret));
818 }
819
820 #[tokio::test]
821 async fn process_history_sync_task_learns_lid_mappings() {
822 let client = crate::test_utils::create_test_client_with_name("history_lid_mappings").await;
823 client.is_running.store(true, Ordering::Relaxed);
824
825 let history_sync = wa::HistorySync {
826 sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP,
827 phone_number_to_lid_mappings: vec![wa::PhoneNumberToLIDMapping {
828 pn_jid: Some("5511777776666@s.whatsapp.net".to_string()),
829 lid_jid: Some("111222333444555@lid".to_string()),
830 }],
831 ..Default::default()
832 };
833 let compressed = compress_history_sync(&history_sync);
834 let notification = HistorySyncNotification {
835 file_length: Some(compressed.len() as u64),
836 sync_type: Some(wa::message::HistorySyncType::INITIAL_BOOTSTRAP),
837 initial_hist_bootstrap_inline_payload: Some(compressed),
838 ..Default::default()
839 };
840
841 client
842 .process_history_sync_task("HIST_SYNC_LID".to_string(), notification.into())
843 .await;
844
845 assert_eq!(
846 client
847 .lid_pn_cache
848 .get_current_lid("5511777776666")
849 .await
850 .as_deref(),
851 Some("111222333444555"),
852 "history-sync mapping must be learned into the LID-PN cache"
853 );
854 }
855
856 #[tokio::test]
857 async fn process_history_sync_task_dispatches_compressed_lazy_event() {
858 let client = crate::test_utils::create_test_client_with_name("history_lazy_event").await;
859 client.is_running.store(true, Ordering::Relaxed);
860
861 let chat = "5511777776666@s.whatsapp.net";
862 let history_sync = wa::HistorySync {
863 sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP,
864 conversations: vec![wa::Conversation {
865 id: chat.to_string(),
866 ..Default::default()
867 }],
868 ..Default::default()
869 };
870 let raw_len = history_sync.encode_to_vec().len();
871 let compressed = compress_history_sync(&history_sync);
872 let compressed_copy = compressed.clone();
873 let notification = HistorySyncNotification {
874 file_length: Some(compressed.len() as u64),
875 sync_type: Some(wa::message::HistorySyncType::INITIAL_BOOTSTRAP),
876 initial_hist_bootstrap_inline_payload: Some(compressed),
877 ..Default::default()
878 };
879
880 let (handler, event_rx) = wacore::types::events::ChannelEventHandler::new();
882 client.core.event_bus.subscribe_handler(handler).detach();
883
884 client
885 .process_history_sync_task("HIST_LAZY_EVENT".to_string(), notification.into())
886 .await;
887
888 let event = event_rx.try_recv().expect("HistorySync event dispatched");
889 let Event::HistorySync(lazy) = &*event else {
890 panic!("expected HistorySync event, got {event:?}");
891 };
892
893 assert_eq!(lazy.compressed_bytes().as_ref(), &compressed_copy[..]);
896 assert_eq!(lazy.decompressed_size(), raw_len);
897 let decoded = lazy.get().expect("decodes");
898 assert_eq!(decoded.conversations[0].id, chat);
899 let mut stream = lazy.stream();
900 assert_eq!(
901 stream.next_conversation().unwrap().unwrap().id,
902 chat,
903 "stream still works after get()"
904 );
905 }
906
907 #[tokio::test]
908 async fn process_history_sync_task_stores_bot_dm_secret_alias() {
909 let client =
910 crate::test_utils::create_test_client_with_name("history_bot_msg_secret").await;
911 client
912 .persistence_manager
913 .process_command(wacore::store::commands::DeviceCommand::SetLid(Some(
914 "999888777666555:0@lid".parse().unwrap(),
915 )))
916 .await;
917 client.is_running.store(true, Ordering::Relaxed);
918
919 let chat = "867051314767696@bot";
920 let parent_id = "HIST_BOT_PARENT";
921 let secret = vec![0x61u8; 32];
922 let history_sync = wa::HistorySync {
923 sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP,
924 conversations: vec![wa::Conversation {
925 id: chat.to_string(),
926 messages: vec![wa::HistorySyncMsg {
927 message: buffa::MessageField::some(wa::WebMessageInfo {
928 key: buffa::MessageField::some(wa::MessageKey {
929 remote_jid: Some(chat.to_string()),
930 from_me: Some(false),
931 id: Some(parent_id.to_string()),
932 participant: None,
933 }),
934 message: buffa::MessageField::some(wa::Message {
935 conversation: Some("bot historical".to_string()),
936 ..Default::default()
937 }),
938 message_secret: Some(secret.clone()),
939 ..Default::default()
940 }),
941 msg_order_id: Some(1),
942 }],
943 ..Default::default()
944 }],
945 ..Default::default()
946 };
947 let compressed = compress_history_sync(&history_sync);
948 let notification = HistorySyncNotification {
949 file_length: Some(compressed.len() as u64),
950 sync_type: Some(wa::message::HistorySyncType::INITIAL_BOOTSTRAP),
951 initial_hist_bootstrap_inline_payload: Some(compressed),
952 ..Default::default()
953 };
954
955 client
956 .process_history_sync_task("HIST_SYNC_BOT_SECRET".to_string(), notification.into())
957 .await;
958
959 let backend = client.persistence_manager.backend();
960 let primary = backend.get_msg_secret(chat, chat, parent_id).await.unwrap();
961 let alias = backend
962 .get_msg_secret(chat, "999888777666555@lid", parent_id)
963 .await
964 .unwrap();
965
966 assert_eq!(primary, Some(secret.clone()));
967 assert_eq!(alias, Some(secret));
968 }
969
970 fn history_msg(
973 chat: &str,
974 msg_id: &str,
975 secret: &[u8],
976 ts_secs: u64,
977 is_poll: bool,
978 ) -> wa::HistorySyncMsg {
979 let message = if is_poll {
980 wa::Message {
981 poll_creation_message: buffa::MessageField::some(
982 wa::message::PollCreationMessage::default(),
983 ),
984 ..Default::default()
985 }
986 } else {
987 wa::Message {
988 conversation: Some("historical".to_string()),
989 ..Default::default()
990 }
991 };
992 wa::HistorySyncMsg {
993 message: buffa::MessageField::some(wa::WebMessageInfo {
994 key: buffa::MessageField::some(wa::MessageKey {
995 remote_jid: Some(chat.to_string()),
996 from_me: Some(false),
997 id: Some(msg_id.to_string()),
998 participant: None,
999 }),
1000 message: buffa::MessageField::some(message),
1001 message_secret: Some(secret.to_vec()),
1002 message_timestamp: Some(ts_secs),
1003 ..Default::default()
1004 }),
1005 msg_order_id: Some(1),
1006 }
1007 }
1008
1009 fn history_notification(
1010 chat: &str,
1011 messages: Vec<wa::HistorySyncMsg>,
1012 ) -> HistorySyncNotification {
1013 let history_sync = wa::HistorySync {
1014 sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP,
1015 conversations: vec![wa::Conversation {
1016 id: chat.to_string(),
1017 messages,
1018 ..Default::default()
1019 }],
1020 ..Default::default()
1021 };
1022 let compressed = compress_history_sync(&history_sync);
1023 HistorySyncNotification {
1024 file_length: Some(compressed.len() as u64),
1025 sync_type: Some(wa::message::HistorySyncType::INITIAL_BOOTSTRAP),
1026 initial_hist_bootstrap_inline_payload: Some(compressed),
1027 ..Default::default()
1028 }
1029 }
1030
1031 async fn seeded_client(name: &str, policy: MsgSecretPolicy) -> Arc<Client> {
1032 let cfg = crate::cache_config::CacheConfig {
1033 msg_secret_policy: policy,
1034 ..Default::default()
1035 };
1036 let client = crate::test_utils::create_test_client_with_config(
1037 name,
1038 Arc::new(crate::test_utils::MockHttpClient),
1039 cfg,
1040 )
1041 .await;
1042 client
1043 .persistence_manager
1044 .process_command(wacore::store::commands::DeviceCommand::SetId(Some(
1045 "5511000000001:0@s.whatsapp.net".parse().unwrap(),
1046 )))
1047 .await;
1048 client.is_running.store(true, Ordering::Relaxed);
1049 client
1050 }
1051
1052 #[tokio::test]
1053 async fn history_seed_managed_drops_old_text_keeps_recent() {
1054 use crate::cache_config::MsgSecretPolicy;
1055 let client = seeded_client("seed_managed_text", MsgSecretPolicy::Managed).await;
1056 let chat = "5511777776666@s.whatsapp.net";
1057 let now = wacore::time::now_secs() as u64;
1058 let old_ts = now - 60 * 86_400; let recent_ts = now - 86_400; let notification = history_notification(
1062 chat,
1063 vec![
1064 history_msg(chat, "OLD_TEXT", &[0x11u8; 32], old_ts, false),
1065 history_msg(chat, "RECENT_TEXT", &[0x22u8; 32], recent_ts, false),
1066 ],
1067 );
1068 client
1069 .process_history_sync_task("S1".to_string(), notification.into())
1070 .await;
1071
1072 let backend = client.persistence_manager.backend();
1073 assert_eq!(
1074 backend
1075 .get_msg_secret(chat, chat, "OLD_TEXT")
1076 .await
1077 .unwrap(),
1078 None,
1079 "a text secret past its 30d horizon must not be seeded"
1080 );
1081 assert_eq!(
1082 backend
1083 .get_msg_secret(chat, chat, "RECENT_TEXT")
1084 .await
1085 .unwrap(),
1086 Some(vec![0x22u8; 32]),
1087 "a recent text secret must be seeded"
1088 );
1089 }
1090
1091 #[tokio::test]
1092 async fn history_seed_managed_keeps_old_poll_within_90d() {
1093 use crate::cache_config::MsgSecretPolicy;
1094 let client = seeded_client("seed_managed_poll", MsgSecretPolicy::Managed).await;
1095 let chat = "5511777776666@s.whatsapp.net";
1096 let now = wacore::time::now_secs() as u64;
1097 let ts = now - 60 * 86_400; let notification = history_notification(
1100 chat,
1101 vec![history_msg(chat, "OLD_POLL", &[0x33u8; 32], ts, true)],
1102 );
1103 client
1104 .process_history_sync_task("S2".to_string(), notification.into())
1105 .await;
1106
1107 assert_eq!(
1108 client
1109 .persistence_manager
1110 .backend()
1111 .get_msg_secret(chat, chat, "OLD_POLL")
1112 .await
1113 .unwrap(),
1114 Some(vec![0x33u8; 32]),
1115 "a poll parent within the 90d horizon must be seeded even past 30d"
1116 );
1117 }
1118
1119 #[tokio::test]
1120 async fn history_seed_full_keeps_old_text() {
1121 use crate::cache_config::MsgSecretPolicy;
1122 let client = seeded_client("seed_full", MsgSecretPolicy::Full).await;
1123 let chat = "5511777776666@s.whatsapp.net";
1124 let now = wacore::time::now_secs() as u64;
1125 let old_ts = now - 365 * 86_400; let notification = history_notification(
1128 chat,
1129 vec![history_msg(chat, "ANCIENT", &[0x44u8; 32], old_ts, false)],
1130 );
1131 client
1132 .process_history_sync_task("S3".to_string(), notification.into())
1133 .await;
1134
1135 assert_eq!(
1136 client
1137 .persistence_manager
1138 .backend()
1139 .get_msg_secret(chat, chat, "ANCIENT")
1140 .await
1141 .unwrap(),
1142 Some(vec![0x44u8; 32]),
1143 "Full seeds everything regardless of age"
1144 );
1145 }
1146
1147 #[tokio::test]
1148 async fn history_seed_disabled_stores_nothing() {
1149 use crate::cache_config::MsgSecretPolicy;
1150 let client = seeded_client("seed_disabled", MsgSecretPolicy::Disabled).await;
1151 let chat = "5511777776666@s.whatsapp.net";
1152 let now = wacore::time::now_secs() as u64;
1153
1154 let notification = history_notification(
1155 chat,
1156 vec![history_msg(chat, "ANY", &[0x55u8; 32], now - 60, false)],
1157 );
1158 client
1159 .process_history_sync_task("S4".to_string(), notification.into())
1160 .await;
1161
1162 assert_eq!(
1163 client
1164 .persistence_manager
1165 .backend()
1166 .get_msg_secret(chat, chat, "ANY")
1167 .await
1168 .unwrap(),
1169 None,
1170 "Disabled persists nothing"
1171 );
1172 }
1173
1174 #[tokio::test]
1175 async fn history_seed_managed_stamps_expires_at_from_message_time() {
1176 use crate::cache_config::MsgSecretPolicy;
1177 let client = seeded_client("seed_expires", MsgSecretPolicy::Managed).await;
1178 let chat = "5511777776666@s.whatsapp.net";
1179 let now = wacore::time::now_secs();
1180 let msg_ts = (now - 86_400) as u64; let notification = history_notification(
1183 chat,
1184 vec![history_msg(chat, "RECENT", &[0x66u8; 32], msg_ts, false)],
1185 );
1186 client
1187 .process_history_sync_task("S5".to_string(), notification.into())
1188 .await;
1189
1190 let backend = client.persistence_manager.backend();
1191 backend.delete_expired_msg_secrets(now).await.unwrap();
1193 assert!(
1194 backend
1195 .get_msg_secret(chat, chat, "RECENT")
1196 .await
1197 .unwrap()
1198 .is_some(),
1199 "row must survive a prune before its deadline"
1200 );
1201 let removed = backend
1204 .delete_expired_msg_secrets(now + 31 * 86_400)
1205 .await
1206 .unwrap();
1207 assert_eq!(removed, 1);
1208 assert!(
1209 backend
1210 .get_msg_secret(chat, chat, "RECENT")
1211 .await
1212 .unwrap()
1213 .is_none(),
1214 "row must be pruned once its message-time deadline passes"
1215 );
1216 }
1217
1218 #[tokio::test]
1219 async fn history_seed_skipped_when_flag_disabled() {
1220 use crate::cache_config::{CacheConfig, MsgSecretPolicy};
1221 let cfg = CacheConfig {
1222 msg_secret_policy: MsgSecretPolicy::Managed,
1223 seed_msg_secrets_from_history: false,
1224 ..Default::default()
1225 };
1226 let client = crate::test_utils::create_test_client_with_config(
1227 "seed_flag_off",
1228 Arc::new(crate::test_utils::MockHttpClient),
1229 cfg,
1230 )
1231 .await;
1232 client
1233 .persistence_manager
1234 .process_command(wacore::store::commands::DeviceCommand::SetId(Some(
1235 "5511000000001:0@s.whatsapp.net".parse().unwrap(),
1236 )))
1237 .await;
1238 client.is_running.store(true, Ordering::Relaxed);
1239
1240 let chat = "5511777776666@s.whatsapp.net";
1241 let now = wacore::time::now_secs() as u64;
1242 let notification = history_notification(
1243 chat,
1244 vec![history_msg(chat, "RECENT", &[0x77u8; 32], now - 60, false)],
1245 );
1246 client
1247 .process_history_sync_task("S6".to_string(), notification.into())
1248 .await;
1249
1250 assert_eq!(
1251 client
1252 .persistence_manager
1253 .backend()
1254 .get_msg_secret(chat, chat, "RECENT")
1255 .await
1256 .unwrap(),
1257 None,
1258 "seed flag off must skip history seeding even under Managed"
1259 );
1260 }
1261
1262 fn group_history_msg(
1265 chat: &str,
1266 participant: &str,
1267 msg_id: &str,
1268 secret: &[u8],
1269 ts_secs: u64,
1270 bot_prompt: bool,
1271 ) -> wa::HistorySyncMsg {
1272 let message_context_info = bot_prompt.then(|| wa::MessageContextInfo {
1273 bot_metadata: buffa::MessageField::some(wa::BotMetadata {
1274 persona_id: Some("867051314767696".into()),
1275 ..Default::default()
1276 }),
1277 ..Default::default()
1278 });
1279 wa::HistorySyncMsg {
1280 message: buffa::MessageField::some(wa::WebMessageInfo {
1281 key: buffa::MessageField::some(wa::MessageKey {
1282 remote_jid: Some(chat.to_string()),
1283 from_me: Some(false),
1284 id: Some(msg_id.to_string()),
1285 participant: Some(participant.to_string()),
1286 }),
1287 message: buffa::MessageField::some(wa::Message {
1288 extended_text_message: buffa::MessageField::some(
1289 wa::message::ExtendedTextMessage {
1290 text: Some("hi".into()),
1291 ..Default::default()
1292 },
1293 ),
1294 message_context_info: message_context_info.into(),
1295 ..Default::default()
1296 }),
1297 message_secret: Some(secret.to_vec()),
1298 message_timestamp: Some(ts_secs),
1299 ..Default::default()
1300 }),
1301 msg_order_id: Some(1),
1302 }
1303 }
1304
1305 #[tokio::test]
1306 async fn history_seed_botonly_keeps_group_bot_prompt_skips_plain() {
1307 use crate::cache_config::MsgSecretPolicy;
1308 let client = seeded_client("seed_botonly_bot", MsgSecretPolicy::BotOnly).await;
1309 let group = "120363021033254949@g.us";
1310 let participant = "5511888887777@s.whatsapp.net";
1311 let now = wacore::time::now_secs() as u64;
1312
1313 let notification = history_notification(
1314 group,
1315 vec![
1316 group_history_msg(
1317 group,
1318 participant,
1319 "BOT_PROMPT",
1320 &[0x88u8; 32],
1321 now - 60,
1322 true,
1323 ),
1324 group_history_msg(
1325 group,
1326 participant,
1327 "PLAIN_GRP",
1328 &[0x99u8; 32],
1329 now - 60,
1330 false,
1331 ),
1332 ],
1333 );
1334 client
1335 .process_history_sync_task("SB".to_string(), notification.into())
1336 .await;
1337
1338 let backend = client.persistence_manager.backend();
1339 assert_eq!(
1340 backend
1341 .get_msg_secret(group, participant, "BOT_PROMPT")
1342 .await
1343 .unwrap(),
1344 Some(vec![0x88u8; 32]),
1345 "BotOnly must seed a group bot prompt (botMetadata = bot context)"
1346 );
1347 assert_eq!(
1348 backend
1349 .get_msg_secret(group, participant, "PLAIN_GRP")
1350 .await
1351 .unwrap(),
1352 None,
1353 "BotOnly must skip a plain group message"
1354 );
1355 }
1356
1357 #[tokio::test]
1358 async fn history_sync_tctoken_replaces_byteless_placeholder() {
1359 let client = crate::test_utils::create_test_client_with_name("history_tctoken_ph").await;
1360 let backend = client.persistence_manager.backend();
1361
1362 backend
1365 .touch_tc_token_sender_timestamp("555000999", 2000)
1366 .await
1367 .unwrap();
1368
1369 client
1370 .store_tc_token_candidate(TcTokenCandidate {
1371 id: "555000999@lid".to_string(),
1372 tc_token: vec![0xAB, 0xCD],
1373 tc_token_timestamp: 1000,
1374 tc_token_sender_timestamp: None,
1375 })
1376 .await;
1377
1378 let stored = backend.get_tc_token("555000999").await.unwrap().unwrap();
1379 assert_eq!(
1380 stored.token,
1381 vec![0xAB, 0xCD],
1382 "history-sync token must replace the placeholder despite its older timestamp"
1383 );
1384 assert_eq!(stored.token_timestamp, 1000);
1385 assert_eq!(
1386 stored.sender_timestamp,
1387 Some(2000),
1388 "the placeholder's sender bucket must be preserved"
1389 );
1390 }
1391
1392 #[tokio::test]
1393 async fn history_sync_tctoken_seeds_sender_bucket() {
1394 let client = crate::test_utils::create_test_client_with_name("history_tctoken_seed").await;
1395 let backend = client.persistence_manager.backend();
1396
1397 client
1399 .store_tc_token_candidate(TcTokenCandidate {
1400 id: "555000998@lid".to_string(),
1401 tc_token: vec![0x01],
1402 tc_token_timestamp: 1000,
1403 tc_token_sender_timestamp: Some(1500),
1404 })
1405 .await;
1406
1407 let stored = backend.get_tc_token("555000998").await.unwrap().unwrap();
1408 assert_eq!(stored.token, vec![0x01]);
1409 assert_eq!(stored.sender_timestamp, Some(1500));
1410 }
1411}