Skip to main content

whatsapp_rust/
client.rs

1mod accessors;
2mod adapters;
3mod app_state;
4pub(crate) use app_state::SyncSettles;
5mod builder;
6mod context_impl;
7mod device_registry;
8pub(crate) mod device_topology;
9#[cfg(feature = "client-lifecycle")]
10mod extension_lifecycle;
11mod iq_ops;
12mod lid_pn;
13mod lifecycle;
14mod messaging;
15mod node_io;
16pub(crate) mod offline_resume;
17mod sender_keys;
18mod sessions;
19mod voip;
20use builder::{ClientAssembly, ClientExtensions};
21pub use builder::{ClientBuild, ClientBuilder, ClientBuilderError};
22#[cfg(feature = "client-lifecycle")]
23use extension_lifecycle::LifecycleRegistration;
24#[cfg(feature = "client-lifecycle")]
25#[cfg_attr(docsrs, doc(cfg(feature = "client-lifecycle")))]
26pub use extension_lifecycle::{ClientLifecycle, ConnectionScope, ConnectionScopeState};
27pub use voip::{CallError, Voip};
28
29use crate::cache::Cache;
30use crate::cache_store::TypedCache;
31use crate::handshake;
32use crate::lid_pn_cache::LidPnCache;
33use crate::pair;
34use anyhow::Result;
35use futures::FutureExt;
36#[cfg(test)]
37use std::borrow::Cow;
38use std::collections::{HashMap, HashSet};
39use std::num::NonZeroU64;
40
41use wacore::xml::{DisplayableNode, DisplayableNodeRef};
42use wacore_binary::JidExt;
43use wacore_binary::Node;
44use wacore_binary::builder::NodeBuilder;
45#[cfg(test)]
46use wacore_binary::{Attrs, NodeValue};
47
48use crate::appstate_sync::AppStateProcessor;
49use crate::handlers::chatstate::ChatStateEvent;
50use crate::jid_utils::server_jid;
51use crate::store::{commands::DeviceCommand, persistence_manager::PersistenceManager};
52use crate::types::enc_handler::EncHandler;
53use crate::types::events::{ConnectFailureReason, Event};
54
55use log::{debug, error, info, trace, warn};
56
57use rand::{Rng, RngExt};
58use scopeguard;
59use wacore_binary::Jid;
60
61use portable_atomic::{AtomicI64, AtomicU64};
62use std::sync::Arc;
63use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
64
65/// Lease that keeps raw decoded stanza events enabled for one consumer.
66///
67/// Dropping the final lease disables forwarding. The lease holds only a weak
68/// client reference, so it cannot keep the client alive.
69#[must_use = "dropping the lease immediately releases raw-node forwarding"]
70pub struct RawNodeLease {
71    client: std::sync::Weak<Client>,
72}
73
74impl Drop for RawNodeLease {
75    fn drop(&mut self) {
76        let Some(client) = self.client.upgrade() else {
77            return;
78        };
79        let previous = client.raw_node_forwarding.fetch_sub(1, Ordering::Relaxed);
80        debug_assert!(previous > 0, "raw-node forwarding lease underflow");
81    }
82}
83
84/// Filter for matching incoming stanzas (nodes) by tag and attributes.
85///
86/// Used with [`Client::wait_for_node`] to wait for specific stanzas.
87/// Zero-cost when no waiters are active (single atomic load per node).
88///
89/// # Example
90/// ```ignore
91/// // Wait for a w:gp2 notification from a specific group
92/// let waiter = client.wait_for_node(
93///     NodeFilter::tag("notification")
94///         .attr("type", "w:gp2")
95///         .attr("from", "group@g.us"),
96/// );
97/// // ... trigger the action ...
98/// let node = waiter.await?;
99/// ```
100#[derive(Debug, Clone)]
101pub struct NodeFilter {
102    tag: String,
103    attrs: Vec<(String, String)>,
104}
105
106impl NodeFilter {
107    /// Create a filter matching nodes with the given tag.
108    pub fn tag(tag: impl Into<String>) -> Self {
109        Self {
110            tag: tag.into(),
111            attrs: Vec::new(),
112        }
113    }
114
115    /// Add an attribute constraint. All attributes must match.
116    pub fn attr(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
117        self.attrs.push((key.into(), value.into()));
118        self
119    }
120
121    /// Shorthand for `.attr("from", jid.to_string())`.
122    pub fn from_jid(self, jid: &Jid) -> Self {
123        self.attr("from", jid.to_string())
124    }
125
126    fn matches(&self, node: &wacore_binary::NodeRef<'_>) -> bool {
127        node.tag == self.tag.as_str()
128            && self.attrs.iter().all(|(k, v)| {
129                node.get_attr(k.as_str())
130                    .is_some_and(|attr| attr == v.as_str())
131            })
132    }
133}
134
135struct NodeWaiter {
136    filter: NodeFilter,
137    tx: futures::channel::oneshot::Sender<Arc<wacore_binary::OwnedNodeRef>>,
138}
139
140struct SentNodeWaiter {
141    filter: NodeFilter,
142    tx: futures::channel::oneshot::Sender<Arc<Node>>,
143}
144
145fn resolve_waiters(
146    waiters_mutex: &std::sync::Mutex<Vec<NodeWaiter>>,
147    counter: &AtomicUsize,
148    node: &Arc<wacore_binary::OwnedNodeRef>,
149) {
150    let nr = node.get();
151    let mut waiters = waiters_mutex
152        .lock()
153        .unwrap_or_else(|poisoned| poisoned.into_inner());
154    let mut i = 0;
155    while i < waiters.len() {
156        if waiters[i].tx.is_canceled() {
157            waiters.swap_remove(i);
158            counter.fetch_sub(1, Ordering::Release);
159        } else if waiters[i].filter.matches(nr) {
160            let w = waiters.swap_remove(i);
161            counter.fetch_sub(1, Ordering::Release);
162            let _ = w.tx.send(Arc::clone(node));
163        } else {
164            i += 1;
165        }
166    }
167}
168
169use async_lock::Mutex;
170use async_lock::RwLock;
171use std::time::Duration;
172use thiserror::Error;
173
174use wacore::appstate::patch_decode::WAPatchName;
175use wacore::client::context::GroupInfo;
176
177/// Group metadata cache. Values are `Arc`-wrapped so a warm `query_info` hit
178/// shares the metadata (refcount bump) instead of deep-cloning the participant
179/// list and LID/PN maps on every group send.
180type GroupCache = TypedCache<Jid, Arc<GroupInfo>>;
181
182/// Memoized SKDM warm state per group: the `(devices, sender-key map)` Weak
183/// pair + map generation it was computed against, the exact sending identity
184/// the filter ran as (it excludes that device, and own-device classification
185/// depends on it — a mid-session identity change must miss), and the memoized
186/// `needs_skdm` targets (empty or own-devices-only). See `skdm_warm_memo`.
187pub(crate) type SkdmWarmMemoEntry = (
188    std::sync::Weak<wacore::send::ResolvedGroupDevices>,
189    std::sync::Weak<crate::sender_key_device_cache::SenderKeyDeviceMap>,
190    u64,
191    Jid,
192    Vec<Jid>,
193);
194use wacore::runtime::timeout as rt_timeout;
195use waproto::whatsapp as wa;
196
197use crate::cache_config::CacheConfig;
198use crate::socket::{NoiseSocket, SocketError, error::EncryptSendError};
199use crate::sync_task::MajorSyncTask;
200use wacore::runtime::Runtime;
201
202/// Type alias for chatstate event handler functions.
203type ChatStateHandler = Arc<dyn Fn(ChatStateEvent) + Send + Sync>;
204
205/// Per-chat lane for sequential message processing. Combines the enqueue lock
206/// and queue sender into a single cached entry (one lookup instead of two).
207/// Keyed by `Jid` to avoid per-message `to_string()` allocation.
208#[derive(Clone)]
209pub(crate) struct ChatLane {
210    pub enqueue_lock: Arc<Mutex<()>>,
211    pub queue_tx: async_channel::Sender<QueuedChatMessage>,
212}
213
214impl ChatLane {
215    pub(crate) fn try_enqueue(
216        &self,
217        node: Arc<wacore_binary::OwnedNodeRef>,
218    ) -> Result<(), async_channel::TrySendError<QueuedChatMessage>> {
219        self.queue_tx.try_send(QueuedChatMessage {
220            node,
221            lane_liveness: Arc::clone(&self.enqueue_lock),
222        })
223    }
224}
225
226pub(crate) struct QueuedChatMessage {
227    pub node: Arc<wacore_binary::OwnedNodeRef>,
228    pub lane_liveness: Arc<Mutex<()>>,
229}
230
231const APP_STATE_RETRY_MAX_ATTEMPTS: u32 = 6;
232
233/// WA Web: MQTT `MqttProtocolClient.connect()` uses `CONNECT_TIMEOUT = 20s`,
234/// DGW `connectTimeoutMs` defaults to `20000ms`.
235const TRANSPORT_CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
236
237pub use wacore::stats::{
238    AllocSnapshot, CollectionStats, HttpResourceReport, StatsSnapshot, StorageResourceReport,
239    TransportResourceReport,
240};
241
242/// On-demand report of the client's internal collections: entry counts plus
243/// estimated retained heap bytes for the memory-dominant caches.
244///
245/// Counts are approximate (caches may have pending evictions); byte figures
246/// are honest estimates (encoded-size proxies for Signal records, payload
247/// sums elsewhere — see [`wacore::stats::HeapSize`]), suitable for
248/// per-session attribution and leak detection, not byte-exact accounting.
249/// Store-backed caches report `bytes: 0` — their entries live outside this
250/// process.
251///
252/// Call [`Client::memory_report`] to obtain one. Nothing is computed unless
253/// called.
254#[non_exhaustive]
255#[derive(Debug, Clone)]
256pub struct MemoryReport {
257    // -- TTL/capacity-bounded caches --
258    pub group_cache: CollectionStats,
259    pub device_registry_cache: CollectionStats,
260    pub lid_pn_lid_entries: CollectionStats,
261    /// Entry count of the PN-direction map. Both maps share the same
262    /// `Arc<LidPnEntry>` payloads, attributed to
263    /// [`Self::lid_pn_lid_entries`]; bytes here cover only entries the LID
264    /// map no longer holds (normally 0), so the total counts each once.
265    pub lid_pn_pn_entries: CollectionStats,
266    pub recent_messages: CollectionStats,
267    pub sender_key_device_cache: CollectionStats,
268    pub group_devices_memo: CollectionStats,
269    pub dm_devices_memo: CollectionStats,
270    pub message_retry_counts: u64,
271    pub undecryptable_dispatched: u64,
272    pub pdo_pending_requests: u64,
273    pub pdo_requested: u64,
274    /// Queued/running history-sync tasks and their logical compressed-payload
275    /// byte sum. A shared `Bytes` slice may retain a larger backing allocation,
276    /// whose capacity is not exposed by the type.
277    pub history_sync_tasks: CollectionStats,
278    /// Lifetime high-water mark of queued/running history-sync tasks.
279    pub history_sync_tasks_peak: u64,
280    /// Lifetime high-water mark of logical compressed-payload bytes.
281    pub history_sync_payload_bytes_peak: u64,
282    // -- Capacity-only caches (coordination, counts only) --
283    pub session_locks: u64,
284    pub chat_lanes: u64,
285    pub group_distribution_locks: u64,
286    /// Cumulative capacity evictions; poll successive reports to derive a rate.
287    pub group_distribution_lock_evictions: u64,
288    /// Cumulative attempts that kept a live lane and temporarily exceeded capacity.
289    pub group_distribution_lock_eviction_blocks: u64,
290    pub resend_rate_limiter_chats: u64,
291    // -- Unbounded collections --
292    /// Deferred acks queued for the transport-ack worker. Unbounded, and each
293    /// entry retains the full inbound node plus a flush guard, so a stalled
294    /// transport shows up here as a growing backlog.
295    pub transport_ack_queue: usize,
296    /// Delivery receipts queued for their worker, same shape as above.
297    pub delivery_receipt_queue: usize,
298    pub response_waiters: usize,
299    pub node_waiters: usize,
300    pub pending_retries: usize,
301    pub presence_subscriptions: usize,
302    pub app_state_key_requests: usize,
303    pub app_state_syncing: usize,
304    pub signal_sessions: CollectionStats,
305    pub signal_identities: CollectionStats,
306    pub signal_sender_keys: CollectionStats,
307    /// Admission snapshots retained while a call-link join ACK is in flight.
308    #[cfg(feature = "voip-runtime")]
309    pub pending_call_link_updates: CollectionStats,
310    /// Active/ringing calls and bounded pre-offer group controls, including their snapshots/queues.
311    #[cfg(feature = "voip-runtime")]
312    pub active_calls: CollectionStats,
313    #[cfg(feature = "plugins")]
314    pub plugins: u64,
315    #[cfg(feature = "plugins")]
316    pub plugin_install_tasks: u64,
317    #[cfg(feature = "plugins")]
318    pub plugin_connection_tasks: u64,
319    #[cfg(feature = "plugins")]
320    pub plugin_connection_generations: u64,
321    #[cfg(feature = "plugins")]
322    pub plugin_core_event_subscriptions: u64,
323    #[cfg(feature = "plugins")]
324    pub plugin_event_endpoints: u64,
325    #[cfg(feature = "plugins")]
326    pub plugin_event_endpoint_capacity: u64,
327    /// Unique custom-event envelopes and payload bytes still retained in endpoint queues.
328    #[cfg(feature = "plugins")]
329    pub plugin_event_queue: CollectionStats,
330    // -- Misc --
331    pub chatstate_handlers: usize,
332    pub custom_enc_handlers: usize,
333}
334
335impl MemoryReport {
336    /// Common byte-carrying collections used by both totals and `Display`.
337    /// Feature-specific collections stay beside their gated report section.
338    fn collections(&self) -> [(&'static str, &CollectionStats); 12] {
339        [
340            ("group_cache:", &self.group_cache),
341            ("device_registry_cache:", &self.device_registry_cache),
342            ("lid_pn (lid):", &self.lid_pn_lid_entries),
343            ("lid_pn (pn):", &self.lid_pn_pn_entries),
344            ("recent_messages:", &self.recent_messages),
345            ("sk_device_cache:", &self.sender_key_device_cache),
346            ("group_devices_memo:", &self.group_devices_memo),
347            ("dm_devices_memo:", &self.dm_devices_memo),
348            ("signal_sessions:", &self.signal_sessions),
349            ("signal_identities:", &self.signal_identities),
350            ("signal_sender_keys:", &self.signal_sender_keys),
351            ("history_sync_tasks:", &self.history_sync_tasks),
352        ]
353    }
354
355    /// Sum of every estimated byte figure in the report.
356    pub fn total_estimated_bytes(&self) -> u64 {
357        let total: u64 = self.collections().iter().map(|(_, c)| c.bytes).sum();
358        #[cfg(feature = "voip-runtime")]
359        let total = total
360            .saturating_add(self.pending_call_link_updates.bytes)
361            .saturating_add(self.active_calls.bytes);
362        #[cfg(feature = "plugins")]
363        let total = total.saturating_add(self.plugin_event_queue.bytes);
364        total
365    }
366}
367
368impl std::fmt::Display for MemoryReport {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        fn line(
371            f: &mut std::fmt::Formatter<'_>,
372            name: &str,
373            c: &CollectionStats,
374        ) -> std::fmt::Result {
375            writeln!(f, "  {name:<22} {:>7} entries {:>10} B", c.entries, c.bytes)
376        }
377        // First TTL_BOUNDED entries of collections() are the TTL-bounded
378        // caches; the next SIGNAL_CACHES are Signal store caches. The final
379        // entry is transient history-sync retention. Adding a cache to
380        // collections() means moving this boundary, or the sections shift.
381        const TTL_BOUNDED: usize = 8;
382        const SIGNAL_CACHES: usize = 3;
383        let collections = self.collections();
384        writeln!(f, "=== Memory Report ===")?;
385        writeln!(f, "--- TTL-bounded caches ---")?;
386        for (name, c) in &collections[..TTL_BOUNDED] {
387            line(f, name, c)?;
388        }
389        writeln!(f, "  message_retry_counts:   {}", self.message_retry_counts)?;
390        writeln!(
391            f,
392            "  undec_dispatched:       {}",
393            self.undecryptable_dispatched
394        )?;
395        writeln!(f, "  pdo_pending_requests:   {}", self.pdo_pending_requests)?;
396        writeln!(f, "  pdo_requested:          {}", self.pdo_requested)?;
397        writeln!(f, "--- Capacity-only caches ---")?;
398        writeln!(f, "  session_locks:          {}", self.session_locks)?;
399        writeln!(f, "  chat_lanes:             {}", self.chat_lanes)?;
400        writeln!(
401            f,
402            "  group_dist_locks:       {} (evicted: {}, blocked: {})",
403            self.group_distribution_locks,
404            self.group_distribution_lock_evictions,
405            self.group_distribution_lock_eviction_blocks
406        )?;
407        writeln!(
408            f,
409            "  resend_rl_chats:        {}",
410            self.resend_rate_limiter_chats
411        )?;
412        writeln!(f, "--- Unbounded collections ---")?;
413        writeln!(f, "  transport_ack_queue:    {}", self.transport_ack_queue)?;
414        writeln!(
415            f,
416            "  delivery_receipt_queue: {}",
417            self.delivery_receipt_queue
418        )?;
419        writeln!(f, "  response_waiters:       {}", self.response_waiters)?;
420        writeln!(f, "  node_waiters:           {}", self.node_waiters)?;
421        writeln!(f, "  pending_retries:        {}", self.pending_retries)?;
422        writeln!(
423            f,
424            "  presence_subscriptions: {}",
425            self.presence_subscriptions
426        )?;
427        writeln!(
428            f,
429            "  app_state_key_requests: {}",
430            self.app_state_key_requests
431        )?;
432        writeln!(f, "  app_state_syncing:      {}", self.app_state_syncing)?;
433        writeln!(f, "--- Signal store caches ---")?;
434        for (name, c) in &collections[TTL_BOUNDED..TTL_BOUNDED + SIGNAL_CACHES] {
435            line(f, name, c)?;
436        }
437        #[cfg(feature = "voip-runtime")]
438        {
439            writeln!(f, "--- VoIP state ---")?;
440            line(f, "pending_link_updates:", &self.pending_call_link_updates)?;
441            line(f, "active_calls:", &self.active_calls)?;
442        }
443        writeln!(f, "--- In-flight history sync ---")?;
444        line(
445            f,
446            collections[TTL_BOUNDED + SIGNAL_CACHES].0,
447            &self.history_sync_tasks,
448        )?;
449        writeln!(
450            f,
451            "  peak tasks:             {}",
452            self.history_sync_tasks_peak
453        )?;
454        writeln!(
455            f,
456            "  peak payload storage:   {} B",
457            self.history_sync_payload_bytes_peak
458        )?;
459        #[cfg(feature = "plugins")]
460        {
461            writeln!(f, "--- Plugins ---")?;
462            writeln!(f, "  installed:              {}", self.plugins)?;
463            writeln!(f, "  install tasks:          {}", self.plugin_install_tasks)?;
464            writeln!(
465                f,
466                "  connection tasks:       {} (generations: {})",
467                self.plugin_connection_tasks, self.plugin_connection_generations
468            )?;
469            writeln!(
470                f,
471                "  core subscriptions:     {}",
472                self.plugin_core_event_subscriptions
473            )?;
474            writeln!(
475                f,
476                "  event endpoints:        {} (capacity: {})",
477                self.plugin_event_endpoints, self.plugin_event_endpoint_capacity
478            )?;
479            line(f, "event_queue:", &self.plugin_event_queue)?;
480        }
481        writeln!(f, "--- Misc ---")?;
482        writeln!(f, "  chatstate_handlers:     {}", self.chatstate_handlers)?;
483        writeln!(f, "  custom_enc_handlers:    {}", self.custom_enc_handlers)?;
484        writeln!(
485            f,
486            "  total estimated:        {} B",
487            self.total_estimated_bytes()
488        )?;
489        Ok(())
490    }
491}
492
493/// Unified per-session resource estimate: the client's own collections plus the
494/// components that live *outside* the `Client` and dominate real per-session
495/// RAM — the storage backend, transport, and HTTP client — and an optional
496/// allocation-churn snapshot.
497///
498/// Obtain one from [`Client::resource_report`]. Each out-of-client component
499/// fills only what it can introspect (see the per-field types), so absent
500/// figures mean "not reported", not "zero".
501#[non_exhaustive]
502#[derive(Debug, Clone)]
503pub struct ResourceReport {
504    /// The client's own in-process collections — identical to
505    /// [`Client::memory_report`].
506    pub client: MemoryReport,
507    /// Storage-backend footprint (SQLite page cache, etc.). All-`None` for
508    /// backends that don't report.
509    pub storage: StorageResourceReport,
510    /// Transport buffers + TLS/noise state, if the transport reports them.
511    pub transport: Option<TransportResourceReport>,
512    /// HTTP connection-pool + in-flight footprint, if the client reports it.
513    pub http: Option<HttpResourceReport>,
514    /// Allocation churn attributed to this client's instrumented work, present
515    /// only when an [`AllocMeter`](wacore::stats::AllocMeter) was installed via
516    /// `BotBuilder::with_alloc_meter`. It is a churn/attribution signal, not a
517    /// retained figure, so it is deliberately excluded from
518    /// [`Self::total_estimated_bytes`].
519    pub alloc: Option<AllocSnapshot>,
520}
521
522impl ResourceReport {
523    /// Best-effort sum of **retained** bytes across the present point-in-time
524    /// components (client collections + storage + transport + HTTP).
525    ///
526    /// Exactness varies by component and this is a **lower bound** overall:
527    /// - client collections and transport/HTTP buffers are honest estimates;
528    /// - storage `memory_bytes` is an upper bound on the SQLite page cache
529    ///   (`min(cache cap, db size)`), 0 for remote backends;
530    /// - components reporting `None` contribute 0 (absent, not zero);
531    /// - `alloc` (churn, not residency) is excluded.
532    pub fn total_estimated_bytes(&self) -> u64 {
533        // Saturating: a caller-built or backend-supplied component could carry a
534        // large value; the total must stay conservative, never wrap.
535        self.client
536            .total_estimated_bytes()
537            .saturating_add(self.storage.total_bytes())
538            .saturating_add(self.transport.map_or(0, |t| t.total_bytes()))
539            .saturating_add(self.http.map_or(0, |h| h.total_bytes()))
540    }
541}
542
543impl std::fmt::Display for ResourceReport {
544    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545        writeln!(f, "=== Resource Report ===")?;
546        writeln!(
547            f,
548            "  client collections:     {:>10} B",
549            self.client.total_estimated_bytes()
550        )?;
551        writeln!(
552            f,
553            "  storage backend:        {:>10} B (pages: {:?})",
554            self.storage.total_bytes(),
555            self.storage.pages
556        )?;
557        writeln!(
558            f,
559            "  transport:              {:>10} B",
560            self.transport.map_or(0, |t| t.total_bytes())
561        )?;
562        writeln!(
563            f,
564            "  http client:            {:>10} B",
565            self.http.map_or(0, |h| h.total_bytes())
566        )?;
567        if let Some(alloc) = self.alloc {
568            writeln!(
569                f,
570                "  alloc churn:            {:>10} B allocated / {:>10} B freed ({} allocs)",
571                alloc.allocated_bytes, alloc.freed_bytes, alloc.allocations
572            )?;
573        }
574        writeln!(
575            f,
576            "  total retained (lower bound): {} B",
577            self.total_estimated_bytes()
578        )?;
579        Ok(())
580    }
581}
582
583/// Shared base error for transport/connection concerns.
584///
585/// The DRY foundation every per-domain error builds on (each domain embeds it
586/// via `#[from]`): it carries the cases common to every network operation —
587/// `NotConnected`, `NotLoggedIn`, IQ failures, socket / encrypt-send errors. It
588/// is NOT an umbrella over the whole API; the per-domain typed errors remain
589/// the public return types.
590#[derive(Debug, Error)]
591#[non_exhaustive]
592pub enum ClientError {
593    #[error("client is not connected")]
594    NotConnected,
595    #[error("socket error: {0}")]
596    Socket(#[from] SocketError),
597    #[error("encrypt/send error: {0}")]
598    EncryptSend(#[from] EncryptSendError),
599    #[error("client is not logged in")]
600    NotLoggedIn,
601    #[error("IQ request failed: {0}")]
602    Iq(#[from] crate::request::IqError),
603    /// Last-resort catch-all for internal failures threaded through `?` that do
604    /// not (yet) have a dedicated variant. `Display` forwards to the inner
605    /// error while `source()` still exposes it for downcast.
606    #[error("{0}")]
607    Internal(#[from] anyhow::Error),
608}
609
610/// The step of the connect flow a [`ConnectError::Timeout`] refers to.
611#[derive(Debug, Clone, Copy, PartialEq, Eq)]
612#[non_exhaustive]
613pub enum ConnectStage {
614    /// Resolving the app version advertised to the server.
615    VersionFetch,
616    /// Opening the underlying transport.
617    Transport,
618    /// Waiting for the noise socket, which is ready before login.
619    Socket,
620    /// Waiting for login plus the critical app state sync to finish.
621    Ready,
622}
623
624impl std::fmt::Display for ConnectStage {
625    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
626        let stage = match self {
627            ConnectStage::VersionFetch => "version fetch",
628            ConnectStage::Transport => "transport connect",
629            ConnectStage::Socket => "socket wait",
630            ConnectStage::Ready => "connection wait",
631        };
632        f.write_str(stage)
633    }
634}
635
636/// Failure modes of [`Client::connect`] and of the readiness waiters
637/// ([`Client::wait_for_socket`], [`Client::wait_for_connected`]).
638#[derive(Debug, Error)]
639#[non_exhaustive]
640pub enum ConnectError {
641    /// A connection is already up, or another attempt is already in flight.
642    #[error("client is already connected")]
643    AlreadyConnected,
644    /// Construction never completed, so the attempt was rejected before any I/O.
645    #[error("client construction did not activate")]
646    NotActivated,
647    /// A step of the connect flow ran out of time.
648    #[error("{stage} timed out after {timeout:?}")]
649    Timeout {
650        stage: ConnectStage,
651        timeout: Duration,
652    },
653    /// The app version could not be resolved.
654    #[error("failed to resolve app version")]
655    Version(#[source] anyhow::Error),
656    /// The transport factory could not open a connection.
657    #[error("failed to open transport")]
658    Transport(#[source] anyhow::Error),
659    /// The noise handshake failed after the transport was up.
660    #[error("{0}")]
661    Handshake(#[from] handshake::HandshakeError),
662}
663
664/// Failures of the background Signal maintenance surface: signed pre-key
665/// rotation ([`Client::rotate_signed_pre_key`]) and cache durability
666/// ([`Client::flush_pending_signal_state`]).
667///
668/// The split that matters to a caller is corruption versus everything else:
669/// [`Self::CorruptKey`] will keep failing until the stored material is
670/// replaced, while storage, IQ and drain failures are worth retrying.
671#[derive(Debug, Error)]
672#[non_exhaustive]
673pub enum SignalMaintenanceError {
674    /// Key material is unusable: bad encoding, or a missing/wrong-sized field.
675    /// Almost always a staged record that a retry would read back identically.
676    #[error("corrupt signed pre-key material: {0}")]
677    CorruptKey(String),
678    /// The storage backend failed a read, write or flush.
679    #[error("signal storage failure: {0}")]
680    Storage(#[source] anyhow::Error),
681    /// The rotation IQ was rejected by the server or never reached it.
682    #[error("IQ request failed: {0}")]
683    Iq(#[from] crate::request::IqError),
684    /// A Signal primitive failed (e.g. signing the new signed pre-key).
685    #[error("{0}")]
686    Signal(#[from] wacore::libsignal::protocol::SignalProtocolError),
687    /// The inbound drain batch could not be committed, so the Signal cache was
688    /// left unflushed on purpose and the server redelivers those messages.
689    #[error(
690        "inbound drain batch commit failed; Signal cache left unflushed so the server redelivers"
691    )]
692    DrainCommitFailed,
693    /// The client is going away while an inbound drain is active; flushing
694    /// there would persist ratchet advances whose messages have no durable row.
695    #[error("client dropping while inbound drain is active; skipping Signal flush")]
696    DrainShuttingDown,
697}
698
699impl ConnectError {
700    /// A step of the connect flow ran out of time.
701    ///
702    /// Matched exhaustively so a new variant has to be classified here rather
703    /// than defaulting to "not a timeout" unnoticed.
704    pub fn is_timeout(&self) -> bool {
705        match self {
706            ConnectError::Timeout { .. } => true,
707            ConnectError::Handshake(handshake) => handshake.is_timeout(),
708            ConnectError::AlreadyConnected
709            | ConnectError::NotActivated
710            | ConnectError::Version(_)
711            | ConnectError::Transport(_) => false,
712        }
713    }
714}
715
716impl ClientError {
717    pub fn is_transport_unavailable(&self) -> bool {
718        match self {
719            ClientError::NotConnected => true,
720            ClientError::EncryptSend(e) => e.is_transport_unavailable(),
721            ClientError::Iq(e) => e.is_transport_unavailable(),
722            _ => false,
723        }
724    }
725}
726
727use wacore::types::message::ChatMessageId;
728
729/// Metrics for tracking offline sync progress
730#[derive(Debug)]
731pub(crate) struct OfflineSyncMetrics {
732    pub active: AtomicBool,
733    pub total_messages: AtomicUsize,
734    pub processed_messages: AtomicUsize,
735    // Using simple std Mutex for timestamp as it's rarely contended and non-async
736    pub start_time: std::sync::Mutex<Option<wacore::time::Instant>>,
737}
738
739type ResponseWaiterSender = futures::channel::oneshot::Sender<Arc<wacore_binary::OwnedNodeRef>>;
740
741/// What a pending ack/IQ entry is waiting to do once the response arrives.
742///
743/// A phash check used to be an `Iq` waiter plus a spawned task holding the
744/// receiver and a ten second timer, which is a task, a channel and a timer per
745/// outgoing message for a comparison that almost always succeeds. Carrying the
746/// expected value in the map instead lets the read loop compare it inline and
747/// spawn only on the rare mismatch.
748pub(crate) enum ResponseWaiter {
749    /// Classic request/response: hand the node to whoever is awaiting it.
750    Iq(ResponseWaiterSender),
751    /// Compare the server's `phash` against ours; act only if they differ.
752    Phash(PhashWaiter),
753}
754
755pub(crate) struct PhashWaiter {
756    pub(crate) expected: wacore_binary::CompactString,
757    pub(crate) jid: Jid,
758    pub(crate) invalidate_group_cache: bool,
759    /// Sweep epoch this waiter was registered in. Expiry is counted in sweeps
760    /// rather than seconds: a wall deadline is subject to clock jumps (see
761    /// wacore::time) and would have to be derived from an instant sampled well
762    /// before registration, while reading a fresh clock here is what the send
763    /// clock budget forbids. Surviving one full sweep is the trigger, so the
764    /// window is one keepalive tick (15 to 30 s) instead of the old fixed 10 s.
765    pub(crate) registered_epoch: u64,
766}
767
768struct ResponseWaiterEntry {
769    generation: NonZeroU64,
770    waiter: ResponseWaiter,
771}
772
773/// Map of pending IQ/ack response waiters, keyed by request id.
774///
775/// Every registration carries a unique generation so guarded IQ cleanup cannot
776/// remove a newer waiter that reused the same explicit ID.
777#[derive(Default)]
778pub(crate) struct ResponseWaiterMap {
779    entries: HashMap<String, ResponseWaiterEntry>,
780    last_generation: u64,
781    /// Advanced once per sweep. Registration reads it under the lock it already
782    /// takes, so a waiter records its age without touching a clock.
783    sweep_epoch: u64,
784}
785
786impl ResponseWaiterMap {
787    fn next_generation(&mut self) -> NonZeroU64 {
788        loop {
789            self.last_generation = self.last_generation.wrapping_add(1);
790            if let Some(generation) = NonZeroU64::new(self.last_generation) {
791                return generation;
792            }
793        }
794    }
795
796    pub(crate) fn try_insert_guarded(
797        &mut self,
798        request_id: String,
799        waiter: ResponseWaiter,
800    ) -> Option<NonZeroU64> {
801        use std::collections::hash_map::Entry;
802
803        let generation = self.next_generation();
804        match self.entries.entry(request_id) {
805            Entry::Vacant(entry) => {
806                entry.insert(ResponseWaiterEntry { generation, waiter });
807                Some(generation)
808            }
809            Entry::Occupied(_) => None,
810        }
811    }
812
813    pub(crate) fn insert(
814        &mut self,
815        request_id: String,
816        waiter: ResponseWaiter,
817    ) -> Option<ResponseWaiter> {
818        let generation = self.next_generation();
819        self.entries
820            .insert(request_id, ResponseWaiterEntry { generation, waiter })
821            .map(|entry| entry.waiter)
822    }
823
824    pub(crate) fn remove(&mut self, request_id: &str) -> Option<ResponseWaiter> {
825        self.entries.remove(request_id).map(|entry| entry.waiter)
826    }
827
828    /// The epoch a waiter registered now belongs to.
829    pub(crate) fn current_epoch(&self) -> u64 {
830        self.sweep_epoch
831    }
832
833    /// Drop phash waiters that lived through a whole sweep without their ack.
834    ///
835    /// Runs on the keepalive tick, before the recent-activity early return: a
836    /// connection with steady inbound traffic skips the ping entirely, and
837    /// sweeping only inside the ping would let lost acks accumulate for as long
838    /// as traffic keeps flowing. The map is also what makes keepalive treat the
839    /// connection as "IQs pending", so a stranded waiter silences pings.
840    pub(crate) fn drop_expired_phash(&mut self) {
841        let epoch = self.sweep_epoch;
842        self.entries.retain(|_, entry| match &entry.waiter {
843            ResponseWaiter::Phash(waiter) => waiter.registered_epoch >= epoch,
844            ResponseWaiter::Iq(_) => true,
845        });
846        self.sweep_epoch = self.sweep_epoch.wrapping_add(1);
847    }
848
849    pub(crate) fn remove_guarded(&mut self, request_id: &str, cleanup_generation: NonZeroU64) {
850        if self
851            .entries
852            .get(request_id)
853            .is_some_and(|entry| entry.generation == cleanup_generation)
854        {
855            self.entries.remove(request_id);
856        }
857    }
858
859    /// Drop every pending sender and release the map allocation without
860    /// resetting the generation sequence. Guards owned by the drained requests
861    /// may outlive a disconnect and must never match a later registration.
862    pub(crate) fn clear(&mut self) {
863        self.entries = HashMap::new();
864    }
865
866    #[cfg(test)]
867    pub(crate) fn contains_key(&self, request_id: &str) -> bool {
868        self.entries.contains_key(request_id)
869    }
870
871    pub(crate) fn is_empty(&self) -> bool {
872        self.entries.is_empty()
873    }
874
875    pub(crate) fn len(&self) -> usize {
876        self.entries.len()
877    }
878}
879
880/// A single WhatsApp session: the connection, the Signal state, and every
881/// protocol operation built on top of them.
882///
883/// This is the low-level entry point. Build one with
884/// [`ClientBuilder`], which
885/// takes the four platform dependencies (storage backend, transport factory,
886/// HTTP client, async runtime) and validates them at runtime. Most applications
887/// should use [`Bot`](crate::bot::Bot) instead and reach the client through
888/// [`Bot::client`](crate::bot::Bot::client); `Client` is what remains when you
889/// need to drive the lifecycle yourself, from an FFI host, or from a wrapper
890/// that cannot express typestate generics.
891///
892/// The client is always used behind an `Arc` (most methods take `self: &Arc<Self>`)
893/// and is cheap to clone and share across tasks.
894///
895/// # Lifecycle
896///
897/// [`Client::run`] owns the session: it connects, keeps the socket alive, and
898/// reconnects with backoff until [`Client::disconnect`] is called or the device
899/// is logged out. [`Client::connect`] performs a single connection attempt
900/// without the supervision loop, for hosts that manage retries themselves.
901///
902/// # Events
903///
904/// Everything the server reports (messages, receipts, pairing progress,
905/// connection state) is delivered as an [`Event`]
906/// on the event bus. Register a handler with [`Client::subscribe`] (explicit
907/// [`EventInterest`](wacore::types::events::EventInterest) filter) or
908/// [`Client::subscribe_handler`].
909///
910/// # Sending
911///
912/// [`Client::send_message`] covers the common path;
913/// [`Client::send_message_with_options`] takes a [`SendOptions`](crate::send::SendOptions)
914/// for message-id pinning, ephemeral expiration, and cache freshness. Domain
915/// operations hang off accessors such as [`Client::groups`], [`Client::contacts`],
916/// and [`Client::presence`].
917pub struct Client {
918    pub(crate) runtime: Arc<dyn Runtime>,
919    pub(crate) core: wacore::client::CoreClient,
920
921    pub(crate) persistence_manager: Arc<PersistenceManager>,
922    /// Write-behind buffer for inbound messageSecret captures; readers check
923    /// it before the backend so the durable write can leave the receive lane.
924    pub(crate) msg_secret_buffer: Arc<crate::msg_secret_buffer::MsgSecretWriteBuffer>,
925    /// Accumulates decrypted messages during the offline drain for per-batch
926    /// commit (WA Web MessageProcessorCache parity).
927    pub(crate) inbound_commit_batch: crate::message::commit_batch::InboundCommitBatcher,
928    pub(crate) media_conn: Arc<RwLock<Option<crate::mediaconn::MediaConn>>>,
929
930    pub(crate) is_logged_in: Arc<AtomicBool>,
931    #[cfg(feature = "client-lifecycle")]
932    pub(crate) login_transition: std::sync::Mutex<()>,
933    pub(crate) is_connecting: Arc<AtomicBool>,
934    pub(crate) is_running: Arc<AtomicBool>,
935    /// Whether the noise socket is established (connected to WhatsApp servers).
936    /// Uses an AtomicBool instead of probing the noise_socket mutex to avoid
937    /// TOCTOU races where `try_lock()` fails due to contention, not disconnection.
938    is_connected: Arc<AtomicBool>,
939
940    /// whatsmeow's `sendActiveReceipts`: 0 = inactive (default), 1 = active
941    /// (presence available), 2 = forced. When 0, delivery receipts use `type="inactive"`.
942    send_active_receipts: AtomicU32,
943
944    /// Per-process counter of consecutive Noise IK handshake failures, scoped
945    /// to the lifetime of this `Client`. Mirrors `K` in WA Web's
946    /// `WAWebOpenChatSocket` (`ChatSocket.js`): on the first failure within a
947    /// process, the next connect skips IK and falls back to XX so a stale
948    /// cached `serverStaticPublic` doesn't trap us in a loop. Reset to 0 on
949    /// any successful handshake (XX, IK, or XXfallback).
950    pub(crate) ik_handshake_failures: Arc<AtomicU32>,
951    /// Terminal shutdown (process-wide). Fired ONLY by `disconnect()`.
952    /// Long-lived subscribers that must outlive reconnect cycles (saver,
953    /// device registry cleanup) subscribe here.
954    pub(crate) shutdown_notifier: wacore::runtime::ShutdownNotifier,
955
956    /// Per-connection shutdown. Replaced with a fresh notifier on every new
957    /// connection; fired on cleanup_connection_state / stream end / stream
958    /// error / connect_failure / disconnect. Per-connection subscribers
959    /// (keepalive, request waiters, read loop, offline flush) observe this.
960    pub(crate) connection_shutdown: std::sync::Mutex<wacore::runtime::ShutdownNotifier>,
961    /// Allocated only when an extension host installs lifecycle callbacks.
962    #[cfg(feature = "client-lifecycle")]
963    lifecycle: Option<Arc<LifecycleRegistration>>,
964    /// Allocated only when at least one build-time plugin is registered.
965    #[cfg(feature = "plugins")]
966    pub(crate) plugin_host: Option<Arc<crate::plugins::PluginHost>>,
967    /// Per-session wire I/O and activity counters. Written at the transport
968    /// chokepoints (noise sender task, read loop); the keepalive dead-socket
969    /// watchdog reads its activity timestamps. Snapshot via [`Client::stats`].
970    pub(crate) stats: Arc<wacore::stats::SessionStats>,
971
972    pub(crate) transport: Arc<Mutex<Option<Arc<dyn crate::transport::Transport>>>>,
973    pub(crate) transport_events:
974        Arc<Mutex<Option<async_channel::Receiver<crate::transport::TransportEvent>>>>,
975    pub(crate) transport_factory: Arc<dyn crate::transport::TransportFactory>,
976    pub(crate) noise_socket: Arc<Mutex<Option<Arc<NoiseSocket>>>>,
977
978    /// Pending IQ/ack response waiters keyed by request id.
979    ///
980    /// A `std::sync::Mutex` (like the `node_waiters` sibling below): the critical
981    /// section is a trivial map op never held across an `.await`, and a sync lock
982    /// is what lets `ResponseWaiterGuard` remove a cancelled waiter from `Drop`
983    /// (an async lock couldn't). See `send_and_wait_iq`.
984    pub(crate) response_waiters: Arc<std::sync::Mutex<ResponseWaiterMap>>,
985
986    /// Generic node waiters for waiting on specific stanzas by tag/attributes.
987    /// Uses std::sync::Mutex (not tokio) since the critical section is trivial.
988    /// Guarded by `node_waiter_count` for zero-cost when no waiters are active.
989    node_waiters: std::sync::Mutex<Vec<NodeWaiter>>,
990    node_waiter_count: AtomicUsize,
991    /// Waiters for raw outgoing nodes before encryption.
992    sent_node_waiters: std::sync::Mutex<Vec<SentNodeWaiter>>,
993    sent_node_waiter_count: AtomicUsize,
994
995    pub(crate) unique_id: String,
996    pub(crate) id_counter: Arc<AtomicU64>,
997
998    pub(crate) unified_session: crate::unified_session::UnifiedSessionManager,
999
1000    /// In-memory cache for Signal protocol state (sessions, identities, sender keys).
1001    /// Matches WhatsApp Web's SignalStoreCache pattern: crypto ops read/write this
1002    /// cache, and DB writes are flushed out of it — synchronously on the send path
1003    /// and coalesced on the receive path (see `signal_flush.rs`).
1004    pub(crate) signal_cache: Arc<crate::store::signal_cache::SignalStoreCache>,
1005
1006    /// Limits message processing concurrency (1 permit during offline sync, N after).
1007    /// Wrapped in Mutex to allow replacing on reconnect.
1008    pub(crate) message_processing_semaphore: std::sync::Mutex<Arc<async_lock::Semaphore>>,
1009    /// Bumped on every semaphore swap so stale Arc clones are rejected.
1010    pub(crate) message_semaphore_generation: Arc<AtomicU64>,
1011
1012    /// Per-device session locks for Signal protocol operations.
1013    /// Prevents race conditions when multiple messages from the same sender
1014    /// are processed concurrently across different chats.
1015    /// Keys are Signal protocol address strings (e.g., "user@s.whatsapp.net:0")
1016    /// to match the SignalProtocolStoreAdapter's internal locking.
1017    pub(crate) session_locks: Cache<String, Arc<Mutex<()>>>,
1018
1019    /// Per-chat lane combining enqueue lock + message queue into a single cached entry.
1020    /// One cache lookup instead of two per incoming message.
1021    pub(crate) chat_lanes: Cache<Jid, ChatLane>,
1022
1023    /// Cache for LID to Phone Number mappings (bidirectional).
1024    /// When we receive a message with sender_lid/sender_pn attributes, we store the mapping here.
1025    /// This allows us to reuse existing LID-based sessions when sending replies.
1026    /// The cache is backed by persistent storage and warmed up on client initialization.
1027    pub(crate) lid_pn_cache: Arc<LidPnCache>,
1028    pub(crate) ab_props: Arc<wacore::store::ab_props::AbPropsCache>,
1029
1030    pub group_cache: Mutex<Option<Arc<GroupCache>>>,
1031
1032    pub(crate) expected_disconnect: Arc<AtomicBool>,
1033    /// Set by `reconnect()` to suppress the "Message loop exited with an error" warning.
1034    /// Unlike `expected_disconnect`, this does NOT skip the reconnect backoff.
1035    pub(crate) intentional_reconnect: AtomicBool,
1036
1037    /// Connection generation counter - incremented on each new connection.
1038    /// Used to detect stale post-login tasks from previous connections.
1039    pub(crate) connection_generation: Arc<AtomicU64>,
1040
1041    /// Cache for recent messages (serialized bytes) for retry functionality.
1042    /// Uses an in-process cache with TTL and max capacity for automatic eviction.
1043    pub(crate) recent_messages: Cache<ChatMessageId, Arc<Vec<u8>>>,
1044
1045    pub(crate) sender_key_device_cache: crate::sender_key_device_cache::SenderKeyDeviceCache,
1046
1047    pub(crate) pending_device_sync: crate::pending_device_sync::PendingDeviceSync,
1048
1049    pub(crate) pending_retries: Arc<std::sync::Mutex<HashSet<String>>>,
1050
1051    /// Track retry attempts per message to prevent infinite retry loops.
1052    /// Key: "{chat}:{msg_id}:{sender}", Value: retry count plus the most
1053    /// recent `RetryReason` we attached, fused so the decrypt-failure path
1054    /// does one cache write and the binary carries one cache instantiation
1055    /// instead of two. The reason is `None` when the count was learned from
1056    /// the sender's echoed stanza `count` attribute rather than a local
1057    /// decrypt failure; diagnostics and regression tests read it to tell
1058    /// which failure arm ran (the count alone can't separate NoSession from
1059    /// BadMac etc.). Matches WhatsApp Web's MAX_RETRY = 5 behavior.
1060    pub(crate) message_retry_counts:
1061        Cache<String, (u8, Option<wacore::protocol::retry::RetryReason>)>,
1062
1063    /// Per-peer timestamp of the last forced session recreate via the
1064    /// "no keys + retry≥2 + >1h since last" path (whatsmeow parity).
1065    /// WA Web's updateLocalSignalSession only deletes on regId mismatch /
1066    /// base-key collision — sessions that diverged without either trigger
1067    /// stay stuck. This map throttles the fallback so a noisy peer can't
1068    /// loop us through prekey fetches.
1069    pub(crate) session_recreate_history: Cache<Jid, wacore::time::Instant>,
1070
1071    /// Per-chat outbound resend rate limiter: bounds the aggregate resend rate
1072    /// to a chat (the anti-abuse signal) so a PN to LID fan-out cannot storm into
1073    /// AccountLocked. Throttled devices still recover via the fresh-SKDM mark.
1074    pub(crate) resend_rate_limiter: crate::resend_rate_limiter::ResendRateLimiter,
1075
1076    /// Dispatch-once gate for `UndecryptableMessage`: a server resend of a
1077    /// failed id re-enters the failure path and would otherwise fire a
1078    /// duplicate event. Mirrors WA Web's DB-level placeholder uniqueness
1079    /// in `WAWebMessageProcessPlaceholder`.
1080    pub(crate) undecryptable_dispatched: Cache<ChatMessageId, ()>,
1081
1082    pub enable_auto_reconnect: Arc<AtomicBool>,
1083    /// Consecutive reconnect failures, drives the Fibonacci backoff. Exposed
1084    /// read-only via [`StatsSnapshot::reconnect_errors`](wacore::stats::StatsSnapshot).
1085    pub(crate) auto_reconnect_errors: Arc<AtomicU32>,
1086    /// Wall-clock ms of the last successful authentication (`<success>`), or 0.
1087    /// Gates the WA Web `resetDelay` backoff reset (see [`should_reset_backoff`]).
1088    pub(crate) connected_at_ms: Arc<AtomicI64>,
1089    /// Set when an explicit backoff penalty was applied this connection (429
1090    /// rate-limit, manual `reconnect()`); cleared on the next `<success>`. Keeps
1091    /// the stability reset from erasing a deliberate penalty (WA Web `cancelReset`).
1092    pub(crate) backoff_reset_suppressed: Arc<AtomicBool>,
1093
1094    pub(crate) needs_initial_full_sync: Arc<app_state::BootstrapGate>,
1095
1096    pub(crate) app_state_processor: Mutex<Option<Arc<AppStateProcessor>>>,
1097    pub(crate) app_state_key_requests: Arc<Mutex<HashMap<Vec<u8>, wacore::time::Instant>>>,
1098    /// Tracks collections currently being synced to prevent duplicate sync tasks.
1099    /// Matches WA Web's in-flight tracking set in WAWebSyncdCollectionsStateMachine.
1100    pub(crate) app_state_syncing: Arc<app_state::SyncInFlight>,
1101    /// Serializes outgoing app-state patch sends.
1102    ///
1103    /// `w:sync:app:state` is optimistic-concurrency: a patch names the base
1104    /// version it was built on, and only one patch can win per version. Two
1105    /// unserialized verbs (two quick `markChatAsRead`s) build on the same base
1106    /// and at most one lands. One lock for every collection, rather than one
1107    /// per collection, matches whatsmeow's single `appStateSyncLock` and WA Web
1108    /// funnelling all collections through one `CollectionsStateMachine`; sends
1109    /// are user-paced, so there is nothing to gain from finer granularity.
1110    pub(crate) app_state_send_lock: Arc<Mutex<()>>,
1111    pub(crate) initial_keys_synced_notifier: Arc<event_listener::Event>,
1112    pub(crate) initial_app_state_keys_received: Arc<AtomicBool>,
1113
1114    /// Prevents concurrent prekey upload operations (matches WA Web's dedup set in `handlePreKeyLow`).
1115    pub(crate) prekey_upload_lock: Arc<Mutex<()>>,
1116    /// Single-flights signed pre-key rotation so overlapping post-login tasks
1117    /// (from reconnect churn) can't run the rotate/upload/prune flow concurrently.
1118    pub(crate) signed_pre_key_rotation_lock: Arc<Mutex<()>>,
1119    /// Notifier for when offline sync (ib offline stanza) is received.
1120    /// WhatsApp Web waits for this before sending passive tasks (prekey upload, active IQ, presence).
1121    pub(crate) offline_sync_notifier: Arc<event_listener::Event>,
1122    /// Flag indicating offline sync has completed (received ib offline stanza).
1123    /// Flips only AFTER the drain-tail commit, so the tail's acks still join
1124    /// the aggregate offline-receipt drain.
1125    pub(crate) offline_sync_completed: Arc<AtomicBool>,
1126    /// Once-guard for the drain finisher (the semaphore swap is not
1127    /// idempotent). Separate from `offline_sync_completed` because the finish
1128    /// runs off the read loop and the flag must flip only after its commit.
1129    pub(crate) offline_sync_finish_started: Arc<AtomicBool>,
1130    /// Delivery receipts buffered during offline sync, flushed as aggregate
1131    /// `<receipt>` stanzas at completion (WA Web `sendAggregateOfflineReceipts`).
1132    /// Empty (zero capacity) outside the offline window.
1133    pub(crate) offline_receipt_buffer:
1134        std::sync::Mutex<Vec<Arc<crate::types::message::MessageInfo>>>,
1135    /// Task count, retained payload storage, peaks, and idle notification for
1136    /// history sync work.
1137    pub(crate) history_sync_activity: Arc<crate::sync_task::HistorySyncActivity>,
1138    /// Flushed by `disconnect()`/`reconnect()` before tearing down the transport
1139    /// so in-flight delivery receipts aren't dropped with `NotConnected`
1140    /// (issue #571).
1141    pub(crate) outbound_flush: Arc<crate::flush_scope::FlushScope>,
1142    /// Feed of the persistent delivery-receipt worker (spawned on first use).
1143    /// Queued items carry a [`crate::flush_scope::FlushGuard`] so `flush()`
1144    /// still waits for receipts that are queued but not yet sent.
1145    pub(crate) delivery_receipt_queue: std::sync::OnceLock<
1146        async_channel::Sender<(
1147            Arc<crate::types::message::MessageInfo>,
1148            crate::flush_scope::FlushGuard,
1149        )>,
1150    >,
1151    /// Feed of the persistent transport-ack worker, mirroring
1152    /// [`Self::delivery_receipt_queue`]. Deferred acks used to be one spawned
1153    /// task each; the queue also gives them FIFO order, which the spawns did
1154    /// not guarantee.
1155    pub(crate) transport_ack_queue: std::sync::OnceLock<
1156        async_channel::Sender<(
1157            Arc<wacore_binary::OwnedNodeRef>,
1158            crate::flush_scope::FlushGuard,
1159        )>,
1160    >,
1161    /// Contacts with active presence subscriptions that must be re-subscribed on reconnect.
1162    pub(crate) presence_subscriptions: Arc<Mutex<HashSet<Jid>>>,
1163    /// Metrics for granular offline sync logging
1164    pub(crate) offline_sync_metrics: Arc<OfflineSyncMetrics>,
1165    /// Drives the WA Web pull-batch loop for offline backlog delivery.
1166    pub(crate) offline_batch: Arc<offline_resume::OfflineBatchCoordinator>,
1167    /// Notifier for when the noise socket is established (before login).
1168    /// Use this to wait for the socket to be ready for sending messages.
1169    pub(crate) socket_ready_notifier: Arc<event_listener::Event>,
1170    /// Set to `true` only when `dispatch_connected()` fires (after critical sync
1171    /// completes). Reset on each new connection attempt. Used by
1172    /// `wait_for_connected()` to avoid a false-positive fast path when the
1173    /// client is logged in but critical app state hasn't synced yet.
1174    pub(crate) is_ready: Arc<AtomicBool>,
1175    /// Notifier for when the client is fully connected and logged in.
1176    /// Triggered after Event::Connected is dispatched.
1177    pub(crate) connected_notifier: Arc<event_listener::Event>,
1178    /// The `connection_generation` that `<success>` finished publishing.
1179    ///
1180    /// `is_logged_in` is set by the dedup swap that has to come *before* the
1181    /// generation is incremented, so between those two stores a reader sees an
1182    /// authenticated client whose generation is about to change underneath it.
1183    /// Work that bound a scope in that window had every attempt rejected as
1184    /// retired. This lags `connection_generation` by exactly that window, so
1185    /// equality means the generation a caller is about to bind is the final one.
1186    pub(crate) authenticated_generation: Arc<AtomicU64>,
1187    /// Fired whenever the answer to *can work reach the server, and is it still
1188    /// worth waiting* may have changed: the session authenticated, or the client
1189    /// became terminal.
1190    ///
1191    /// Neither of the other two notifiers answers that. `socket_ready_notifier`
1192    /// fires before login, so a waiter released by it can send an IQ the server
1193    /// will not answer and whose generation `<success>` then retires;
1194    /// `connected_notifier` fires only after the critical sync, which app-state
1195    /// work must not sit through because it may *be* that sync. And nothing at
1196    /// all announces a client that stops without a replacement socket ever
1197    /// arriving — the case that leaves a detached retry parked forever, holding
1198    /// the `Arc<Client>` whose drop would have been the only other way out.
1199    ///
1200    /// Every terminal transition must fire this. See [`Client::is_terminal`].
1201    pub(crate) session_state_notifier: Arc<event_listener::Event>,
1202    pub(crate) major_sync_task_sender: async_channel::Sender<MajorSyncTask>,
1203    pub(crate) pairing_cancellation_tx: Arc<Mutex<Option<async_channel::Sender<()>>>>,
1204    /// Asks the QR rotation task to re-render the ref it is already showing.
1205    /// The payload embeds the adv secret, so a rotation has to reach the code
1206    /// on screen and not just the next one.
1207    pub(crate) pairing_qr_refresh_tx: Arc<Mutex<Option<async_channel::Sender<()>>>>,
1208
1209    /// State machine for pair code authentication flow.
1210    /// Tracks the pending pair code request and ephemeral keys.
1211    pub(crate) pair_code_state: Arc<Mutex<wacore::pair_code::PairCodeState>>,
1212
1213    /// SHORTCAKE_PASSKEY linking flow state: the pending handoff key, the
1214    /// per-attempt ephemeral linking cache, and the optional host authenticator.
1215    pub(crate) passkey_state: Arc<Mutex<crate::passkey::flow::PasskeyFlowState>>,
1216
1217    /// Wait-free "an open is in flight" reservation for the passkey flow. Kept
1218    /// outside `passkey_state` so it can be released synchronously on drop (a
1219    /// cancelled open can't leave it stuck), unlike a flag behind the async lock.
1220    pub(crate) passkey_opening: AtomicBool,
1221
1222    /// Custom handlers for encrypted message types. Set once at `Bot::build` and
1223    /// immutable afterward, so the receive hot path reads it with a plain
1224    /// `OnceLock::get` (no lock) and no per-node guard acquisition.
1225    pub custom_enc_handlers: std::sync::OnceLock<HashMap<String, Arc<dyn EncHandler>>>,
1226
1227    /// Optional inbound durability hook. When set, the transport ack for a
1228    /// decrypted user message is deferred until the hook commits it, converting
1229    /// the consumer to at-least-once delivery. Set once at `Bot::build` and read
1230    /// lock-free on the receive path. `None` (default) keeps the current
1231    /// at-most-once behavior with zero overhead.
1232    pub(crate) inbound_durability_hook:
1233        std::sync::OnceLock<Arc<dyn crate::types::durability_hook::InboundDurabilityHook>>,
1234
1235    /// Optional retry-receipt admission policy (see
1236    /// [`crate::types::retry_admission::RetryAdmission`]): an operator opt-in to
1237    /// drop some group/status retries. `None` (default) keeps WA Web behavior
1238    /// with a single lock-free `OnceLock::get` on the receive path.
1239    pub(crate) retry_admission:
1240        std::sync::OnceLock<Arc<dyn crate::types::retry_admission::RetryAdmission>>,
1241
1242    /// Chat state (typing indicator) handlers registered by external consumers.
1243    /// Each handler receives a `ChatStateEvent` describing the chat, optional participant and state.
1244    pub(crate) chatstate_handlers: Arc<RwLock<Vec<ChatStateHandler>>>,
1245
1246    pub(crate) pdo_pending_requests: Cache<ChatMessageId, crate::pdo::PendingPdoRequest>,
1247
1248    /// Messages already covered by a placeholder-resend PDO request. Mirrors
1249    /// the session-lifetime set in
1250    /// `WAWebNonMessageDataRequestPlaceholderMessageResendUtils`: at most one
1251    /// request per message, no matter how many times the server redelivers
1252    /// the undecryptable original. Entries are dropped on send failure so a
1253    /// transient error does not block the next attempt.
1254    pub(crate) pdo_requested: Cache<ChatMessageId, ()>,
1255
1256    /// LRU cache for device registry (matches WhatsApp Web's 5000 entry limit).
1257    /// Maps user ID to DeviceListRecord for fast device existence checks.
1258    /// Backed by persistent storage.
1259    /// Device registry fused with its topology tracker: every write records
1260    /// the change by construction, so the device-list memos below can never
1261    /// be left stale by a forgotten bump.
1262    pub(crate) device_registry_cache: device_topology::DeviceRegistryCache,
1263    /// Shared topology tracker (generation + changed-users log). LidPnCache
1264    /// records mapping changes into it; the memos validate against it.
1265    pub(crate) device_topology: Arc<device_topology::DeviceTopology>,
1266    /// Whether the device-list memos (group and DM) may be used: false when
1267    /// the registry or LID-PN caches are store-backed (a shared external
1268    /// store can be written by other processes, which the in-process
1269    /// topology tracker cannot observe).
1270    pub(crate) device_memos_enabled: bool,
1271    /// Per-group memo of the fully resolved (LID-converted) device list,
1272    /// validated by GroupInfo identity + the device topology. Serves the
1273    /// per-send full-set resolution in `resolve_skdm_targets` so a warm
1274    /// repeat send skips the per-member cache fan-out.
1275    pub(crate) group_devices_memo: Cache<Jid, Arc<device_registry::GroupDevicesMemo>>,
1276    /// Per-recipient memo of the resolved DM fan-out (recipient devices +
1277    /// own companions, partitioned, with its phash), keyed by the resolved
1278    /// wire jid and validated by the sending identity + the device topology.
1279    /// A warm repeat DM skips both registry lookups, the list rebuild and
1280    /// the phash.
1281    pub(crate) dm_devices_memo: Cache<Jid, Arc<device_registry::DmDevicesMemo>>,
1282    /// Full DM fan-out recomputes (memo miss or bypass), so tests can prove a
1283    /// repeat send really served the memo instead of redoing the resolution.
1284    #[cfg(test)]
1285    pub(crate) dm_devices_memo_recomputes: AtomicU64,
1286
1287    /// Single-flight for cold SKDM distribution, keyed per group. Concurrent
1288    /// cold sends each re-ran the full per-member fan-out before any of them
1289    /// marked the devices warm; the loser now waits here and re-resolves,
1290    /// finding everything warm. Warm sends never touch it.
1291    pub(crate) group_distribution_locks: Cache<Jid, Arc<Mutex<()>>>,
1292
1293    /// Last `(devices, sender-key-device map)` Arc pair whose `needs_skdm`
1294    /// was warm — empty, or only our own devices (which are never memoized
1295    /// warm and re-receive their SKDM every send; WA Web `!isMeDevice`) —
1296    /// plus the map's generation and that needs set, so a warm repeat send
1297    /// skips `filter_skdm_targets` and reuses the memoized targets. `Weak`
1298    /// keeps the pointer comparison ABA-safe (matching `GroupDevicesMemo`);
1299    /// the generation catches an in-place cold flip that leaves the `Arc`
1300    /// pointer unchanged.
1301    pub(crate) skdm_warm_memo: Cache<Jid, SkdmWarmMemoEntry>,
1302
1303    /// Router for dispatching stanzas to their appropriate handlers
1304    pub(crate) stanza_router: crate::handlers::router::StanzaRouter,
1305
1306    /// Whether to send ACKs synchronously or in a background task
1307    pub(crate) synchronous_ack: bool,
1308
1309    /// HTTP client for making HTTP requests (media upload/download, version fetching)
1310    pub http_client: Arc<dyn crate::http::HttpClient>,
1311
1312    /// Version override for testing or manual specification
1313    pub(crate) override_version: Option<(u32, u32, u32)>,
1314
1315    /// When true, history sync notifications are acknowledged but not downloaded
1316    /// or processed. Set via `BotBuilder::skip_history_sync()`.
1317    pub(crate) skip_history_sync: AtomicBool,
1318
1319    /// Number of one-time pre-keys generated per upload batch. Defaults to
1320    /// [`crate::prekeys::DEFAULT_WANTED_PRE_KEY_COUNT`]; set via
1321    /// [`BotBuilder::with_wanted_pre_key_count`] or [`Client::set_wanted_pre_key_count`].
1322    /// Clamped to the protocol-safe range at upload time.
1323    pub(crate) wanted_pre_key_count: AtomicUsize,
1324
1325    /// Cache configuration for TTL and capacity of all caches.
1326    /// Stored for use by lazily-initialized caches (group_cache).
1327    pub(crate) cache_config: CacheConfig,
1328
1329    /// Weak self-reference for spawning background tasks from `&self` methods.
1330    /// Initialized after `Arc::new(this)` in the constructor.
1331    pub(crate) self_weak: std::sync::OnceLock<std::sync::Weak<Client>>,
1332
1333    /// Single-flight state for the coalesced Signal-cache flush worker:
1334    /// `(connection_generation << 2) | RUNNING/DIRTY bits` (see `signal_flush.rs`).
1335    pub(crate) signal_flush_state: AtomicU64,
1336    /// Barrier between a coalesced-flush worker's backend write and teardown's
1337    /// Signal-cache settle. The generation-scoped atomic only orders
1338    /// `signal_flush_state`, not the writes themselves: a worker that passed its
1339    /// pre-flush generation check could still be mid-flush when teardown settles
1340    /// the cache and the next connection's drain dirties it, persisting rowless
1341    /// advances out of band. The worker holds this only across the flush (never
1342    /// across sleep/backoff) and re-checks the generation under it; teardown
1343    /// holds it around the settle. Lock order is always this-gate → processing
1344    /// permit / sessions lock, so no inversion.
1345    pub(crate) signal_flush_lifecycle: Mutex<()>,
1346    /// Injected failures for the coalesced flush (consumed one per attempt),
1347    /// so tests can exercise the retry/backoff path deterministically.
1348    #[cfg(test)]
1349    pub(crate) signal_flush_test_failures: AtomicU32,
1350    /// Blocks each coalesced flush attempt while set, so a test can hold a
1351    /// worker inside the flush and drive a concurrent generation change.
1352    #[cfg(test)]
1353    pub(crate) signal_flush_test_block: AtomicBool,
1354    /// Counts entries into the coalesced flush attempt, so a test can wait
1355    /// until a worker is actually inside the (blocked) flush.
1356    #[cfg(test)]
1357    pub(crate) signal_flush_test_in_attempt: AtomicU32,
1358    /// Keeps retry regressions deterministic without corrupting the test database.
1359    #[cfg(test)]
1360    pub(crate) app_state_key_share_prepare_test_failures: AtomicU32,
1361
1362    /// Holds the background saver's AbortHandle so the task lifetime follows
1363    /// `Arc<Client>` ref count instead of the Bot wrapper's. Set once by
1364    /// `Bot::build`; on Client drop (last Arc), the handle drops and the saver
1365    /// is aborted.
1366    pub(crate) saver_handle: std::sync::OnceLock<wacore::runtime::AbortHandle>,
1367
1368    /// Typed handle to an [`AllocMeter`](wacore::stats::AllocMeter) installed via
1369    /// `BotBuilder::with_alloc_meter`, so [`Client::resource_report`] can fold in
1370    /// its allocation-churn snapshot. Unset unless that builder method was used.
1371    pub(crate) alloc_meter: std::sync::OnceLock<Arc<wacore::stats::AllocMeter>>,
1372
1373    /// Number of consumers currently requesting `Event::RawNode` forwarding.
1374    raw_node_forwarding: AtomicUsize,
1375
1376    /// Active VoIP calls and their media-task abort handles. `abort_all` runs from the
1377    /// connection-cleanup path so a disconnect/reconnect tears down every in-flight call. Behind the
1378    /// `voip` feature: it is populated only by the `voip` media facade.
1379    #[cfg(feature = "voip-runtime")]
1380    pub(crate) call_registry: Arc<wacore::voip::CallRegistry>,
1381
1382    /// Admission snapshots that can race a call-link join ACK before its call id is registered.
1383    /// Kept beside the client-side join lifecycle so `wacore` does not authorize unknown calls.
1384    #[cfg(feature = "voip-runtime")]
1385    pending_call_link_joins: Arc<std::sync::Mutex<voip::PendingCallLinkJoins>>,
1386
1387    /// Serializes call-link joins until the ACK reveals which call id owns any admission state
1388    /// buffered during the request. This keeps a bounded overflow tied to one join instead of
1389    /// letting it reject an unrelated concurrent join.
1390    #[cfg(feature = "voip-runtime")]
1391    pending_call_link_join_lane: Arc<Mutex<()>>,
1392
1393    /// Serializes incoming-answer registration with generation-aware teardown. A failed answer holds
1394    /// its call-id lane until `<terminate>` has been written, so a same-call-id re-offer cannot become
1395    /// current in the removal-before-send window. Stripes bound storage while allowing independent
1396    /// lanes to progress concurrently.
1397    #[cfg(feature = "voip-runtime")]
1398    pub(crate) answer_transition_locks: [Arc<Mutex<()>>; 16],
1399
1400    /// Outgoing calls awaiting their relay. The initiator's relay is not in the offer; it arrives
1401    /// from the server AFTER the offer (live-only), so each `voip().call()` parks the material needed
1402    /// to spawn the engine here, keyed by call-id, until a `<call>` carrying a `<relay>` for that id
1403    /// arrives. Behind the `voip` feature; populated only by the media facade.
1404    #[cfg(feature = "voip-runtime")]
1405    pub(crate) pending_outgoing_calls:
1406        Arc<std::sync::Mutex<HashMap<String, crate::voip::facade::PendingOutgoing>>>,
1407}
1408
1409/// Builds a pong response node for a server-initiated ping.
1410///
1411/// Matches WhatsApp Web (`WAWebCommsHandleStanza`): only includes `id`
1412/// when the server ping carried one.
1413fn build_pong(to: String, id: Option<&str>) -> Node {
1414    let mut builder = NodeBuilder::new("iq").attr("to", to).attr("type", "result");
1415    if let Some(id) = id {
1416        builder = builder.attr("id", id);
1417    }
1418    builder.build()
1419}
1420
1421/// Compare decoded attribute values by their wire display without allocating.
1422#[inline]
1423fn value_refs_display_equal(
1424    left: &wacore_binary::node::ValueRef<'_>,
1425    right: &wacore_binary::node::ValueRef<'_>,
1426) -> bool {
1427    use wacore_binary::node::ValueRef;
1428
1429    match (left, right) {
1430        (ValueRef::String(left), ValueRef::String(right)) => left == right,
1431        (ValueRef::Jid(left), ValueRef::Jid(right)) => left.display_eq_jid(right),
1432        (ValueRef::String(left), ValueRef::Jid(right)) => right.display_eq(left),
1433        (ValueRef::Jid(left), ValueRef::String(right)) => left.display_eq(right),
1434    }
1435}
1436
1437#[derive(Clone, Copy)]
1438enum AckParticipantPolicy {
1439    Preserve,
1440    OmitReceiptDestinationDuplicate,
1441}
1442
1443#[inline]
1444fn ack_participant<'node, 'data>(
1445    node: &'node wacore_binary::NodeRef<'data>,
1446    from: &wacore_binary::node::ValueRef<'data>,
1447    policy: AckParticipantPolicy,
1448) -> Option<&'node wacore_binary::node::ValueRef<'data>> {
1449    node.get_attr("participant")
1450        .filter(|participant| match policy {
1451            AckParticipantPolicy::Preserve => true,
1452            AckParticipantPolicy::OmitReceiptDestinationDuplicate => {
1453                node.tag != "receipt" || !value_refs_display_equal(participant, from)
1454            }
1455        })
1456}
1457
1458/// Build an `<ack/>` for the given stanza, matching WA Web / whatsmeow behavior:
1459///
1460/// - `class` = original stanza tag
1461/// - `id`, `to` (flipped from `from`) copied from original
1462/// - `participant` follows the generic or receipt-specialized policy
1463/// - `from` = own device PN, only for message acks
1464/// - `type` echoed when present, except `notification type="encrypt"` with
1465///   an `<identity/>` child
1466///
1467/// For receipt acks, WA Web uses `MAYBE_CUSTOM_STRING(ackString)` where
1468/// `ackString = maybeAttrString("type")` — so `type` is only included when
1469/// explicitly present on the incoming receipt (delivery receipts normally
1470/// have no type attribute, meaning the ack also has no type).
1471///
1472/// Encode an ack stanza directly to bytes, bypassing Node + marshal_auto.
1473/// Acks are the most frequent outbound stanza (~1 per inbound message).
1474fn encode_ack_bytes(
1475    node: &wacore_binary::NodeRef<'_>,
1476    own_device_pn: Option<&Jid>,
1477    participant_policy: AckParticipantPolicy,
1478) -> Result<Vec<u8>, crate::features::StanzaResponseError> {
1479    use wacore_binary::encoder::{ByteWriter, EncodeNode, Encoder};
1480
1481    let id_val = crate::features::required_stanza_attr(node, "id")?;
1482    let from_val = crate::features::required_stanza_attr(node, "from")?;
1483    let tag = node.tag.as_ref();
1484    let participant_val = ack_participant(node, from_val, participant_policy);
1485    // Server expects `recipient` echoed back so it can route the ack to the
1486    // origin companion/device (hosted-companion, peer, LID-routed stanzas).
1487    // Dropping it makes the server close the stream with `<stream:error><ack/>`.
1488    let recipient_val = node.get_attr("recipient");
1489
1490    let typ_val = if !is_encrypt_identity_notification(node) {
1491        node.get_attr("type")
1492    } else {
1493        None
1494    };
1495
1496    // WA Web stamps the own device JID for both classes.
1497    let own_device_pn = if tag == "message" || tag == "status" {
1498        Some(own_device_pn.ok_or(crate::features::StanzaResponseError::MissingLocalIdentity)?)
1499    } else {
1500        None
1501    };
1502
1503    // Count attrs: class + id + to + optional(from, participant, recipient, type)
1504    let attr_count = 3
1505        + usize::from(own_device_pn.is_some())
1506        + usize::from(participant_val.is_some())
1507        + usize::from(recipient_val.is_some())
1508        + usize::from(typ_val.is_some());
1509
1510    struct AckNode<'a> {
1511        id: &'a wacore_binary::node::ValueRef<'a>,
1512        from: &'a wacore_binary::node::ValueRef<'a>,
1513        participant: Option<&'a wacore_binary::node::ValueRef<'a>>,
1514        recipient: Option<&'a wacore_binary::node::ValueRef<'a>>,
1515        typ: Option<&'a wacore_binary::node::ValueRef<'a>>,
1516        own_pn: Option<&'a Jid>,
1517        tag_str: &'a str,
1518        attr_count: usize,
1519    }
1520
1521    impl EncodeNode for AckNode<'_> {
1522        fn tag(&self) -> &str {
1523            "ack"
1524        }
1525        fn attrs_len(&self) -> usize {
1526            self.attr_count
1527        }
1528        fn has_content(&self) -> bool {
1529            false
1530        }
1531        fn encode_attrs<'a, W: ByteWriter>(
1532            &self,
1533            enc: &mut Encoder<'a, W>,
1534        ) -> wacore_binary::Result<()> {
1535            enc.write_string("class")?;
1536            enc.write_string(self.tag_str)?;
1537            enc.write_string("id")?;
1538            self.id.encode_value(enc)?;
1539            enc.write_string("to")?;
1540            self.from.encode_value(enc)?;
1541            if let Some(pn) = self.own_pn {
1542                enc.write_string("from")?;
1543                enc.write_jid_owned(pn)?;
1544            }
1545            if let Some(p) = self.participant {
1546                enc.write_string("participant")?;
1547                p.encode_value(enc)?;
1548            }
1549            if let Some(r) = self.recipient {
1550                enc.write_string("recipient")?;
1551                r.encode_value(enc)?;
1552            }
1553            if let Some(t) = self.typ {
1554                enc.write_string("type")?;
1555                t.encode_value(enc)?;
1556            }
1557            Ok(())
1558        }
1559        fn encode_content<'a, W: ByteWriter>(
1560            &self,
1561            _enc: &mut Encoder<'a, W>,
1562        ) -> wacore_binary::Result<()> {
1563            Ok(())
1564        }
1565    }
1566
1567    let ack = AckNode {
1568        id: id_val,
1569        from: from_val,
1570        participant: participant_val,
1571        recipient: recipient_val,
1572        typ: typ_val,
1573        own_pn: own_device_pn,
1574        tag_str: tag,
1575        attr_count,
1576    };
1577
1578    let mut buf = Vec::with_capacity(64);
1579    let mut encoder = Encoder::new_vec(&mut buf)?;
1580    encoder.write_node(&ack)?;
1581    Ok(buf)
1582}
1583
1584/// Minimal `<message>` stanza carrying the attrs `encode_ack_bytes` needs,
1585/// reconstructed after the node tree has been dropped. The original `from`
1586/// is the group for group/broadcast stanzas and the sender otherwise (sender
1587/// keeps the device qualifier; `chat` is device-stripped for DMs). Mirrors
1588/// whatsmeow's `sendAck` (`to`=from, copy recipient/participant).
1589fn message_ack_source_node(info: &crate::types::message::MessageInfo) -> Node {
1590    let from = if info.source.is_group {
1591        &info.source.chat
1592    } else {
1593        &info.source.sender
1594    };
1595    let mut builder = NodeBuilder::new("message")
1596        .attr("id", &info.id)
1597        .attr("from", from);
1598    if let Some(recipient) = &info.source.recipient {
1599        builder = builder.attr("recipient", recipient);
1600    }
1601    if info.source.is_group {
1602        builder = builder.attr("participant", &info.source.sender);
1603    }
1604    builder.build()
1605}
1606
1607/// Build an automatic ack Node (used in tests for structure verification).
1608#[cfg(test)]
1609fn build_ack_node(node: &wacore_binary::NodeRef<'_>, own_device_pn: Option<&Jid>) -> Option<Node> {
1610    let id = node.get_attr("id")?.to_node_value();
1611    let from_ref = node.get_attr("from")?;
1612    let from = from_ref.to_node_value();
1613    let tag = node.tag.as_ref();
1614    let participant = ack_participant(
1615        node,
1616        from_ref,
1617        AckParticipantPolicy::OmitReceiptDestinationDuplicate,
1618    )
1619    .map(|value| value.to_node_value());
1620    let recipient = node.get_attr("recipient").map(|v| v.to_node_value());
1621    let typ = if !is_encrypt_identity_notification(node) {
1622        node.get_attr("type").map(|v| v.to_node_value())
1623    } else {
1624        None
1625    };
1626    let mut attrs = Attrs::with_capacity(7);
1627    attrs.insert("class", NodeValue::from(tag));
1628    attrs.insert("id", id);
1629    attrs.insert("to", from);
1630    if tag == "message"
1631        && let Some(own_device_pn) = own_device_pn
1632    {
1633        attrs.insert("from", NodeValue::Jid(own_device_pn.clone()));
1634    }
1635    if let Some(p) = participant {
1636        attrs.insert("participant", p);
1637    }
1638    if let Some(r) = recipient {
1639        attrs.insert("recipient", r);
1640    }
1641    if let Some(t) = typ {
1642        attrs.insert("type", t);
1643    }
1644    Some(Node {
1645        tag: Cow::Borrowed("ack"),
1646        attrs,
1647        content: None,
1648    })
1649}
1650
1651/// WA Web omits `type` when ACKing `<notification type="encrypt"><identity/></notification>`.
1652fn is_encrypt_identity_notification(node: &wacore_binary::NodeRef<'_>) -> bool {
1653    node.tag == "notification"
1654        && node
1655            .get_attr("type")
1656            .is_some_and(|value| value == "encrypt")
1657        && node.get_optional_child("identity").is_some()
1658}
1659
1660/// Whether the reconnect backoff counter should snap back to its 1s base after
1661/// a disconnect — WA Web's `resetDelay` (30s) semantics. `penalty_pending`
1662/// mirrors WA Web's `cancelReset()`: an explicit penalty applied this cycle
1663/// (429 rate-limit, or a manual `reconnect()` step) must survive, so a
1664/// long-lived-then-rate-limited connection keeps its deliberate backoff instead
1665/// of snapping to 1s.
1666pub(crate) fn should_reset_backoff(
1667    connected_at_ms: i64,
1668    now_ms: i64,
1669    penalty_pending: bool,
1670) -> bool {
1671    !penalty_pending
1672        && connected_at_ms != 0
1673        && now_ms.saturating_sub(connected_at_ms) >= Client::STABLE_CONNECTION_RESET_MS
1674}
1675
1676/// Computes a reconnect delay matching WhatsApp Web's Fibonacci backoff:
1677/// `{ algo: { type: "fibonacci", first: 1000, second: 1000 }, jitter: 0.1, max: 9e5 }`
1678///
1679/// Sequence: 1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s, 55s, 89s, 144s, ... capped at 900s.
1680/// Each value gets ±10% random jitter.
1681fn fibonacci_backoff(attempt: u32) -> Duration {
1682    const MAX_MS: u64 = 900_000; // WA Web: 9e5
1683
1684    let mut a: u64 = 1000;
1685    let mut b: u64 = 1000;
1686    for _ in 0..attempt {
1687        let next = a.saturating_add(b).min(MAX_MS);
1688        a = b;
1689        b = next;
1690    }
1691    let base = a.min(MAX_MS);
1692
1693    // ±10% jitter (WA Web: jitter: 0.1)
1694    let jitter_range = base / 10;
1695    let jitter = if jitter_range > 0 {
1696        rand::make_rng::<rand::rngs::StdRng>().random_range(0..=(jitter_range * 2)) as i64
1697            - jitter_range as i64
1698    } else {
1699        0
1700    };
1701    let ms = (base as i64 + jitter).max(0) as u64;
1702    Duration::from_millis(ms)
1703}
1704
1705#[cfg(test)]
1706mod tests;