Skip to main content

whatsapp_rust/
history_sync.rs

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/// Immutable seed policy snapshot shared by the streaming filter and the
18/// persistence pass. Keeping one snapshot prevents a long parse from applying
19/// different time boundaries at its two stages.
20#[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
70/// Builds the store's final representation directly from each borrowed core
71/// record. The parsed chat is cached per conversation; valid history syncs no
72/// longer allocate an intermediate record or parse the same JID per message.
73struct 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            // Send receipt so the phone considers this chunk delivered and stops
223            // retrying. This intentionally diverges from WhatsApp Web's AB prop
224            // drop path (which sends no receipt) because bots will never process
225            // history, and without the receipt the phone would keep re-uploading
226            // blobs that will never be consumed.
227            self.send_protocol_receipt(
228                message_id,
229                crate::types::presence::ReceiptType::HistorySync,
230            )
231            .await;
232            return;
233        }
234
235        // Enqueue a MajorSyncTask for the dedicated sync worker to consume.
236        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    /// Process history sync: decompress, extract internal data (tctokens,
253    /// pushname, nct_salt), then dispatch a single `Event::HistorySync`
254    /// with the full decompressed blob for on-demand consumer decoding.
255    #[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        // Use take() to avoid cloning large payloads - moves ownership instead
306        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            // The native in-memory downloader streams decryption and safely
323            // pre-sizes its fresh retry buffer from the declared file length.
324            // Reuse it here so a ~1 MiB history blob does not climb the Vec
325            // doubling ladder (1 MiB -> 2 MiB) on every successful download.
326            match self.download(&notification).await {
327                Ok(bytes) => {
328                    log::info!("Successfully downloaded history sync blob.");
329                    // Payload accounting uses logical compressed bytes for
330                    // both owned Vecs and shared Bytes slices. Bytes does not
331                    // expose the capacity of its backing allocation.
332                    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        // Always carry the compressed input through (a move, no copy or extra
359        // inflate); handler interest is evaluated at dispatch time below, so a
360        // handler that registers while a large blob is being parsed still gets
361        // the event instead of racing a pre-parse snapshot.
362        // Small blobs (PushName, Recent): decode inline to avoid spawn_blocking overhead.
363        // Large blobs: use blocking thread to avoid stalling the async runtime.
364        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                // Update own push name if found
408                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                // Store NCT salt if found.
414                // WA Web: storeNctSaltFromHistorySync in MsgHandlerAction.js
415                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                // Store tctokens extracted during streaming (move to avoid cloning)
428                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                // Bulk PN-LID identity seed from field 15; persist and migrations
436                // run detached, like the other batch learn paths. `Other`
437                // matches WA Web's `learningSource: "other"` for this harvest
438                // (WAWebHistorySyncChunk): a conservative seed that only adds
439                // new LIDs and never clobbers a live-learned mapping.
440                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                // No interest pre-check: dispatch() evaluates handler interest
459                // against a single bus snapshot (and skips materializing the
460                // Arc when nobody listens), so deferring to it removes the
461                // check-to-dispatch race window entirely. Building the event
462                // is just a Bytes refcount move plus metadata.
463                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            // Opt-out of the pairing-time seed; live capture still runs.
504            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            // Disabled: rely on the resolver / app store, seed nothing.
512            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        // This is already the final owned batch. Routing it through the live
525        // write-behind would retain the Vec alongside its keyed Arc map and
526        // flush snapshot; the backend consumes it directly and applies its
527        // own bounded statement batching.
528        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    /// Ask the phone to re-upload a history-sync blob whose download failed,
543    /// by sending a `<receipt type="server-error" category="peer">` with the
544    /// blob's `media_key`.
545    ///
546    /// WA Web (`WAWebHandleHistorySyncNotification`) sends this on a non-network
547    /// download failure; the encrypted payload is the same `ServerErrorReceipt`
548    /// used for media retries. Exposed for consumers that detect an undownloadable
549    /// or unwanted history-sync chunk and want the phone to re-send it.
550    #[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    /// Store a tctoken candidate extracted during history sync streaming.
573    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        // Newer-wins lives in the store now (atomic, lock-free), so concurrent
589        // history-sync chunks and the privacy path converge without a
590        // get-then-store or a lock.
591        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
613/// At most two aliases are persisted for one secret: the account's PN/LID
614/// pair for an outgoing message, or an incoming bot chat plus the account LID.
615const 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        // Register a handler BEFORE the task so retain_blob is true.
881        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        // The event carries the original compressed payload plus the exact
894        // inflated size, and every consumption path works.
895        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    /// One inbound history message in `chat`, stamped at `ts_secs`, optionally a
971    /// poll-creation message, carrying `secret`.
972    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; // past the 30d text horizon
1059        let recent_ts = now - 86_400; // within it
1060
1061        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; // past 30d text but within 90d poll/event
1098
1099        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; // a year old
1126
1127        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; // 1 day old text → expires at msg_ts + 30d
1181
1182        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        // Deadline is msg_ts + 30d ≈ now + 29d: a prune at "now" keeps it.
1192        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        // A prune past msg_ts + 30d removes it, proving the deadline tracks
1202        // message time, not seed time.
1203        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    /// A group message in `chat` from `participant`, optionally carrying
1263    /// botMetadata (a bot invocation), with `secret`.
1264    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        // Post-send issuance wrote a placeholder: no bytes, token_timestamp is a
1363        // recent sender epoch (newer than the real token minted earlier).
1364        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        // No prior local state — the candidate's own sender timestamp seeds it.
1398        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}