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;