# WhatsApp Binary Protocol Source: https://whatsapp-rust.jlucaso.com/advanced/binary-protocol Deep dive into WhatsApp's custom binary serialization format, node marshaling, and protocol specifics ## Overview WhatsApp uses a custom binary protocol for all communication between clients and servers. This format is significantly more compact than JSON or XML and optimized for mobile network conditions. The protocol encodes messages as **nodes** - hierarchical structures with tags, attributes, and content. All nodes are serialized to binary format before encryption and transmission. ## Architecture The binary protocol implementation is in `wacore/binary/`, a platform-agnostic crate: ``` wacore/binary/src/ ├── marshal.rs # Serialization entry points ├── encoder.rs # Binary encoding logic ├── decoder.rs # Binary decoding logic ├── node.rs # Node data structures ├── token.rs # Token dictionary ├── jid.rs # JID (identifier) handling └── builder.rs # Fluent API for node construction ``` ## Node Structure ### Node Definition A node represents a protocol message or message component: ```rust theme={null} use compact_str::CompactString; use std::borrow::Cow; pub struct Node { pub tag: Cow<'static, str>, // e.g., "message", "receipt", "iq" pub attrs: Attrs, // Key-value attributes pub content: Option, // Optional content } pub enum NodeContent { Bytes(Vec), // Binary payload String(CompactString), // Text payload Nodes(Vec), // Child nodes } ``` The `tag` field uses `Cow<'static, str>` so that known protocol tags (like `"message"`, `"iq"`, `"receipt"`) are borrowed as zero-allocation static references from the token dictionary, while unknown tags fall back to an owned `String`. Location: `wacore/binary/src/node.rs:459` ### Attributes Attributes are stored as key-value pairs with specialized value types: ```rust theme={null} use compact_str::CompactString; pub enum NodeValue { String(CompactString), Jid(Jid), // Optimized for WhatsApp identifiers } pub type AttrsVec = smallvec::SmallVec<[(Cow<'static, str>, NodeValue); 2]>; pub struct Attrs(pub AttrsVec); ``` Like `Node.tag`, attribute keys use `Cow<'static, str>` so that common protocol attribute names (like `"id"`, `"type"`, `"to"`, `"from"`) reference static memory from the token dictionary rather than allocating on the heap. `AttrsVec` is a `SmallVec` with inline capacity 2. Nodes carrying ≤2 attributes — the common per-recipient fanout shapes (`to` with 1 attr, `enc` with 2) — keep their attributes on the stack alongside the node with no heap allocation. Nodes with ≥3 attributes spill to the heap as before. See [Inline attribute storage](#inline-attribute-storage) for performance numbers. #### NodeValue API `NodeValue` provides exactly two methods for accessing the underlying value, regardless of variant: ```rust theme={null} use std::borrow::Cow; // Get a string view of the value (works for both variants) // - String variant: Cow::Borrowed(&str) — zero copy // - Jid variant: Cow::Owned(formatted) — allocates only when needed pub fn as_str(&self) -> Cow<'_, str> // Convert to an owned Jid, parsing from string if necessary // - Jid variant: clones the Jid directly // - String variant: attempts to parse, returns None on failure pub fn to_jid(&self) -> Option ``` This simplified API means you never need to match on the variant directly — use `as_str()` when you need the value as text, and `to_jid()` when you need a structured JID: ```rust theme={null} // Reading an attribute value as a string let msg_type = node.attrs.get("type") .map(|v| v.as_str().into_owned()); // Reading an attribute value as a JID let recipient = node.attrs.get("to") .and_then(|v| v.to_jid()); ``` `NodeValue` also implements `PartialEq` for zero-allocation comparisons — the `Jid` variant compares byte-by-byte against the formatted string without allocating. Location: `wacore/binary/src/node.rs:39-58` **Why Jid as a separate type?** JIDs (Jabber IDs) like `15551234567@s.whatsapp.net` appear frequently in the protocol. Storing them as structured data avoids repeated parsing/formatting overhead: ```rust theme={null} use compact_str::CompactString; pub struct Jid { pub user: CompactString, // "15551234567" pub server: Server, // Server::Pn (s.whatsapp.net) pub agent: u8, // Agent byte — only meaningful for @bot/@interop; always 0 for Pn/Lid/Hosted/HostedLid pub device: u16, // Device ID (0 for primary) pub integrator: u16, // Integrator ID (used with interop server) } ``` For the AD-capable servers (`Pn`, `Lid`, `Hosted`, `HostedLid`), the wire form spells the server as a leading domain byte on `AD_JID` (see [AD\_JID](#ad_jid-device-specific-jid) below). The decoder resolves that byte into `server`. It does not also copy the byte into `agent`. Earlier versions kept a redundant copy in `agent`. A JID decoded off the wire got `agent` set to the domain byte. The same JID parsed from text got `agent` set to `0`, even though `Display` rendered both the same way. `PartialEq`/`Hash` were derived at the time, so those two JIDs compared unequal and could hash to different values. `agent` now stays `0` for these servers on both paths. A wire-decoded JID and a text-parsed JID therefore compare equal and hash the same. But `PartialEq`/`Hash` no longer *rely* on that invariant holding everywhere. `Jid` and `JidRef` implement both by hand now, routed through a private module-level `identity_agent(server, agent)` helper. That helper reads as `0` on any server where `Server::renders_agent()` is false, regardless of what the raw field holds. This closes a gap the decoder fix alone didn't: `swap_pn_lid_namespace` and similar code can still carry a nonzero `agent` across a namespace conversion. Equality treats the result the same as the clean JID either way. `agent` is only ever identity-relevant for `Bot`/`Interop`, which do render it. `integrator` is *not* normalized the same way. It is folded into identity unconditionally, matching `is_same_chat_as`, since the field is never set outside `Interop` in practice. A separate public method, `Jid::identity_agent(&self)`, wraps that same private helper. Code building its own key over a JID — sorting, deduplicating, indexing — can call it to apply the identical rule the hand-written `PartialEq`/`Hash` use, instead of reading `jid.agent` directly. The `user` field uses `CompactString` (re-exported from `compact_str`) instead of `String`. `CompactString` stores short strings inline (up to 24 bytes on 64-bit platforms) without heap allocation, which benefits typical phone numbers and user identifiers. The library re-exports it as `wacore_binary::CompactString` and `whatsapp_rust::CompactString` for convenience. `CompactString` implements `From<&str>`, `From`, and `Deref`, so it works as a drop-in replacement in most contexts — but code that relied on `Jid.user` being a `String` (e.g., passing it to functions expecting `&String` or calling `String`-specific methods) may need updating. #### Server enum The `server` field is a `Server` enum (`#[repr(u8)]`) that maps to the wire protocol's AD\_JID domain type. This replaces the previous `Cow<'static, str>` string representation, eliminating all heap allocation for server identifiers and enabling `match`-based dispatch instead of string comparisons: ```rust theme={null} #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] #[repr(u8)] pub enum Server { #[default] Pn = 0, // s.whatsapp.net — Standard phone-number JIDs Lid = 1, // lid — Linked Identity JIDs Group = 2, // g.us — Group chat JIDs Broadcast = 3, // broadcast — Broadcast lists and status Newsletter = 4, // newsletter — Newsletter / channel JIDs Hosted = 5, // hosted — Cloud API business devices (phone-based) HostedLid = 6, // hosted.lid — Cloud API business devices (LID-based) Messenger = 7, // msgr — Messenger interop JIDs Interop = 8, // interop — Cross-platform interop JIDs Bot = 9, // bot — Bot JIDs Legacy = 10, // c.us — Legacy user server (pre-multidevice) } ``` `Server` implements `Display` (returns the wire string like `"s.whatsapp.net"`), `as_str()` for zero-cost string access, `TryFrom<&str>` for parsing, `Serialize`/`Deserialize` (as the wire string), and `PartialEq` / `PartialEq<&str>` for backward-compatible string comparisons: ```rust theme={null} let server = Server::Pn; assert_eq!(server.as_str(), "s.whatsapp.net"); assert!(server == "s.whatsapp.net"); // PartialEq for backward compat let parsed = Server::try_from("g.us").unwrap(); assert_eq!(parsed, Server::Group); ``` If you previously compared `jid.server` to string constants like `"s.whatsapp.net"`, the `PartialEq` impl on `Server` preserves backward compatibility. However, `match` on the enum variant is preferred for exhaustiveness checking and performance. `Server::parse_known(s: &str) -> Option` is an allocation-free alternative to `TryFrom<&str>`: it returns `None` for an unknown suffix instead of building a `JidError::InvalidFormat` (and the `String` message inside it). `TryFrom` is implemented on top of it, so the two always agree: ```rust theme={null} assert_eq!(Server::parse_known("g.us"), Some(Server::Group)); assert_eq!(Server::parse_known("unknown"), None); ``` Use `parse_known` when you only need an `Option`. This is useful for classifying short strings that might not be JIDs, such as an email address containing `@`. Use `try_from` when you need a descriptive error. String constants are still available for backward compatibility and use in non-JID contexts: | Constant | Value | Server variant | | --------------------- | ---------------- | -------------------- | | `DEFAULT_USER_SERVER` | `s.whatsapp.net` | `Server::Pn` | | `HIDDEN_USER_SERVER` | `lid` | `Server::Lid` | | `GROUP_SERVER` | `g.us` | `Server::Group` | | `BROADCAST_SERVER` | `broadcast` | `Server::Broadcast` | | `NEWSLETTER_SERVER` | `newsletter` | `Server::Newsletter` | | `HOSTED_SERVER` | `hosted` | `Server::Hosted` | | `HOSTED_LID_SERVER` | `hosted.lid` | `Server::HostedLid` | | `MESSENGER_SERVER` | `msgr` | `Server::Messenger` | | `INTEROP_SERVER` | `interop` | `Server::Interop` | | `BOT_SERVER` | `bot` | `Server::Bot` | | `LEGACY_USER_SERVER` | `c.us` | `Server::Legacy` | #### Typed constructors Convenience constructors avoid specifying the server directly: ```rust theme={null} Jid::pn("15551234567") // @s.whatsapp.net, device 0 Jid::lid("ABC123") // @lid, device 0 Jid::group("12345678") // @g.us Jid::newsletter("12345678") // @newsletter Jid::pn_device("15551234567", 1) // @s.whatsapp.net, device 1 Jid::lid_device("ABC123", 2) // @lid, device 2 Jid::status_broadcast() // status@broadcast Jid::new("user", Server::Pn) // arbitrary server variant ``` #### Borrowing types For zero-allocation lookups and comparisons, the protocol also provides: * **`JidRef<'a>`** — a borrowing version of `Jid` where `user` is `NodeStr<'a>` (borrowed or inline) and `server` is the `Server` enum (already `Copy`). Used for zero-copy decoded JIDs in `NodeRef` attributes * **`DeviceKey<'a>`** — a lightweight key containing `(&'a str, &'a str, u16)` for user/server/device, used for `HashSet` lookups without cloning Location: `wacore/binary/src/node.rs:10-112`, `wacore/binary/src/jid.rs` #### JidExt trait The `JidExt` trait provides type-checking methods on JIDs. It is implemented for `Jid`, `JidRef`, and other borrowing types so you can inspect a JID's server type without string comparisons: ```rust theme={null} use wacore_binary::jid::{Jid, JidExt}; let jid = Jid::pn("15551234567"); assert!(jid.is_ad()); // true — s.whatsapp.net is an AD server assert!(!jid.is_group()); // false — not a @g.us JID ``` | Method | Returns `true` when | | ------------------------ | --------------------------------------------------------------------------------------------------------------- | | `is_ad()` | Server is `Pn`, `Lid`, `Hosted`, or `HostedLid` and device > 0 | | `is_group()` | Server is `Group` | | `is_broadcast_list()` | Server is `Broadcast` and user is not `"status"` | | `is_status_broadcast()` | User is `"status"` and server is `Broadcast` | | `is_newsletter()` | Server is `Newsletter` | | `is_hosted()` | Device is 99, or server is `Hosted` / `HostedLid` | | `is_bot()` | Server is `Bot`, or phone number starts with known bot prefixes | | `is_interop()` | Server is `Interop` and integrator > 0 | | `is_messenger()` | Server is `Messenger` and device > 0 | | `is_empty()` | User is empty | | `is_same_user_as(other)` | Both JIDs share the same user | | `is_psa()` | Server is `Pn` or `Legacy` and user is `"0"` — the system/announcements account (`0@s.whatsapp.net` / `0@c.us`) | The trait also exposes basic accessor methods (`user() -> &str`, `server() -> Server`, `device() -> u16`, `integrator() -> u16`) that work uniformly across owned and borrowed JID types. Additional helper methods `is_pn()` and `is_lid()` are available directly on `Jid` for the most common server checks. `is_same_user_as` compares only the `user` part. It ignores `server`. A LID and a PN can share the same digits by coincidence while addressing two different accounts. `is_same_user_as` reports such a pair as equal anyway. Don't use it to decide "is this JID mine" or "is this JID the same account as that other one" — a peer's LID whose digits happen to spell your own phone number is not you. For a same-namespace comparison, use `Jid::is_same_chat_as(&self, other: &Jid) -> bool` instead. It's an inherent method on `Jid`, not part of `JidExt`. It additionally compares `server` and `integrator`, and `agent` where the server renders it. It stays insensitive to `device`. `is_same_chat_as` still compares `server`, so it does *not* treat an account's PN and its LID as the same chat — they're different namespaces for the same account. If your own account has both, "is this JID mine" means comparing the candidate against each known identity separately and combining the results with `||`, not a single `is_same_chat_as` call. This is the same rule WA Web's `isSameAccountAndAddressingMode` follows. Location: `wacore/binary/src/jid.rs:304-363` ### NodeBuilder API The `NodeBuilder` provides a fluent chaining API for constructing nodes. All setter methods consume and return `Self`: ```rust theme={null} use wacore_binary::builder::NodeBuilder; use wacore_binary::jid::Jid; let message = NodeBuilder::new("message") .attr("to", "15551234567@s.whatsapp.net") .attr("type", "text") .attr("id", "ABCD1234") .children(vec![ NodeBuilder::new("body").string_content("Hello, world!").build(), ]) .build(); ``` #### Available methods | Method | Signature | Description | | ---------------- | ------------------------------------------------------------------------- | ----------------------------------------------- | | `new` | `new(tag: &'static str) -> Self` | Create a builder with a static tag (zero-alloc) | | `new_dynamic` | `new_dynamic(tag: String) -> Self` | Create a builder with a dynamic tag | | `attr` | `attr(self, key: &'static str, value: impl Into) -> Self` | Add a string attribute (zero-alloc key) | | `jid_attr` | `jid_attr(self, key: &'static str, jid: Jid) -> Self` | Add a JID attribute without stringifying | | `attrs` | `attrs(self, attrs: impl IntoIterator) -> Self` | Bulk-add attributes from an iterator | | `children` | `children(children: impl IntoIterator) -> Self` | Set child nodes as content | | `bytes` | `bytes(bytes: impl Into>) -> Self` | Set raw bytes as content | | `string_content` | `string_content(s: impl Into) -> Self` | Set string as content | | `apply_content` | `apply_content(content: Option) -> Self` | Set arbitrary content | | `build` | `build(self) -> Node` | Consume the builder and produce a `Node` | The `new` and `attr` methods accept `&'static str` for tags and keys, which creates `Cow::Borrowed` values on the owned `Node` with zero heap allocation. For rare cases where the tag is computed at runtime, use `new_dynamic`. Location: `wacore/binary/src/builder.rs` #### jid\_attr vs attr The `jid_attr` method stores JIDs as `NodeValue::Jid(jid)` directly in the attribute map, avoiding the allocation cost of `jid.to_string()`. Use `jid_attr` for JID-valued attributes like `to`, `from`, and `participant` on hot paths: ```rust theme={null} // Prefer jid_attr for JID attributes — avoids string allocation let receipt = NodeBuilder::new("receipt") .attr("id", &message_id) .jid_attr("to", chat_jid.clone()) .jid_attr("participant", sender_jid.clone()) .build(); // Equivalent but allocates a string per JID let receipt = NodeBuilder::new("receipt") .attr("id", &message_id) .attr("to", chat_jid.to_string()) .attr("participant", sender_jid.to_string()) .build(); ``` #### Conditional chaining Use `let mut builder` with reassignment for conditional attributes: ```rust theme={null} let mut builder = NodeBuilder::new("receipt") .attr("id", &info.id) .jid_attr("to", info.source.chat.clone()); if info.category == MessageCategory::Peer { builder = builder.attr("type", "peer_msg"); } if info.source.is_group { builder = builder.jid_attr("participant", info.source.sender.clone()); } let node = builder.build(); ``` ## Token Dictionary The protocol uses a token dictionary to compress common strings into single bytes. ### Token Types ```rust theme={null} // Single-byte tokens (4-235) pub const LIST_EMPTY: u8 = 0; pub const INTEROP_JID: u8 = 245; // Interop JID pub const FB_JID: u8 = 246; // Facebook JID pub const AD_JID: u8 = 247; // JID with device ID pub const LIST_8: u8 = 248; // List with <256 items pub const LIST_16: u8 = 249; // List with ≥256 items pub const JID_PAIR: u8 = 250; // JID in user@server format pub const HEX_8: u8 = 251; // Packed hex string pub const BINARY_8: u8 = 252; // Binary data <256 bytes pub const BINARY_20: u8 = 253; // Binary data <1MB pub const BINARY_32: u8 = 254; // Binary data ≥1MB pub const NIBBLE_8: u8 = 255; // Packed numeric string ``` Location: `wacore/binary/src/token.rs` ### Unified token lookup Both single-byte and double-byte tokens are resolved by a single compile-time [hashify](https://crates.io/crates/hashify) `tiny_map`, generated by a build script from `tokens.json`. The dispatch uses a length-bucketed strategy: `match key.len()` first, then compare only discriminator bytes—no full-key hash. A single call to `index_of_token` resolves any known protocol string: ```rust theme={null} use wacore_binary::token::{index_of_token, TokenKind}; index_of_token("message") => Some(TokenKind::Single(19)) index_of_token("iq") => Some(TokenKind::Single(18)) index_of_token("body") => Some(TokenKind::Single(7)) index_of_token("participant") => Some(TokenKind::Double(dict, idx)) index_of_token("unknown_string") => None ``` The `TokenKind` enum distinguishes single-byte from double-byte tokens: ```rust theme={null} pub enum TokenKind { Single(u8), Double(u8, u8), // (dictionary index, token index) } ``` The dictionary includes: * Protocol tags ("message", "iq", "presence") * Common attributes ("id", "type", "to", "from") * Frequent values ("text", "chat", "available") Reverse lookups (index → string) use separate arrays: ```rust theme={null} get_single_token(19) => Some("message") get_double_token(0, 42) => Some("participant") ``` Location: `wacore/binary/src/token.rs` ## Encoding Process ### Marshal Functions ```rust theme={null} // Basic serialization pub fn marshal(node: &Node) -> Result> // Serialize to existing buffer (zero-copy for output) pub fn marshal_to_vec(node: &Node, output: &mut Vec) -> Result<()> // Two-pass encoding with exact size pre-calculation pub fn marshal_exact(node: &Node) -> Result> // Auto-sizing with heuristics pub fn marshal_auto(node: &Node) -> Result> ``` Location: `wacore/binary/src/marshal.rs:31-76` ### The format byte Every `marshal*` function above writes one byte before the node bytes: a format byte. It's a flag with a single defined bit, and it's the whole reason a decoded buffer is one byte shorter than the marshal output it came from. ```rust theme={null} // wacore/binary/src/util.rs pub const FORMAT_PLAIN: u8 = 0; // uncompressed — the only value marshal* writes pub const FORMAT_COMPRESSED: u8 = 2; // node bytes that follow are zlib-compressed — inbound only pub fn unpack(data: &[u8]) -> Result>; pub fn unpack_bytes(data: BytesMut) -> Result; pub fn pack(node_bytes: &[u8]) -> Vec; pub fn check_plain_payload(data: &[u8]) -> Result<()>; ``` `unpack`/`unpack_bytes` strip the format byte from a received frame — decompressing the node bytes behind it when `FORMAT_COMPRESSED` is set — before the decoder ever sees them; this is what the receive path calls ahead of `OwnedNodeRef::new`. `pack` is the inverse: given node bytes, such as [`OwnedNodeRef::backing_bytes()`](#ownednoderef-yoke-zero-copy), it prefixes `FORMAT_PLAIN` and returns a buffer shaped like marshal output — the form a send path such as [`Client::send_raw_bytes`](/api/client#send_raw_bytes) accepts. `check_plain_payload` is the shape check a send path runs before touching a caller-supplied buffer: it accepts only `FORMAT_PLAIN` followed by at least one node byte, and rejects anything else with a named reason rather than forwarding it to the socket for the peer to reject by hanging up. `FORMAT_COMPRESSED` is refused here too — it's a legitimate inbound frame, but nothing any `marshal*` function writes, so a caller that only ever handles our own output holds a buffer it did not build if it sees one. `unmarshal_packed_ref(data: &[u8]) -> Result>` (in `marshal.rs`) decodes a packed payload directly — format byte plus node bytes, exactly what `marshal` produces — sharing `check_plain_payload` with the send-side check. It only accepts the uncompressed form, since the returned `NodeRef` borrows from `data` and decompressed bytes would have nowhere to live; `unmarshal_ref` is still the function for node bytes alone (post-`unpack`, or what `OwnedNodeRef::backing_bytes()` holds). Forwarding or replaying a received stanza therefore goes through `pack(&node_ref.backing_bytes())`, not `backing_bytes()` alone — see [`OwnedNodeRef::backing_bytes`](#ownednoderef-yoke-zero-copy) below and [`Client::send_raw_bytes`](/api/client#send_raw_bytes). Location: `wacore/binary/src/util.rs`, `wacore/binary/src/marshal.rs` ### Encoding Strategy The encoder uses multiple strategies based on data characteristics: ```rust theme={null} enum StringHint { Empty, // "" → BINARY_8 + 0 SingleToken(u8), // "message" → 19 DoubleToken { dict: u8, token: u8 }, PackedNibble, // "123-456" → compressed PackedHex, // "DEADBEEF" → compressed Jid(ParsedJidMeta), // JID-specific encoding RawBytes, // Fallback } ``` Location: `wacore/binary/src/encoder.rs:227-237` ### Packed Encoding Nibble and hex packing share one code path, `write_packed_bytes`: an ASCII→nibble lookup table (`NIBBLE_ENC` or `HEX_ENC`, picked by `data_type`) maps each input byte to its packed nibble, pairs are packed two at a time into a stack buffer, and validity is checked once via an OR accumulator (`seen`) instead of per pair — that is what lets the pair loop unroll. #### Nibble packing (numeric strings) Strings containing only digits, dash, and dot are packed into 4 bits per character: ```rust theme={null} // Input: "123-456.789" // Encoding: // '1' → 1, '2' → 2, '3' → 3, '-' → 10, '4' → 4, ... // Packed: 0x12, 0x3A, 0x45, 0x67, 0x89 pub const PACKED_MAX: u8 = 127; // Max length for packed/token strings /// ASCII to nibble for `NIBBLE_8`: digits plus the two punctuation characters /// a phone number can carry. static NIBBLE_ENC: [u8; 256] = { let mut table = [PACK_INVALID; 256]; let mut c = b'0'; while c <= b'9' { table[c as usize] = c - b'0'; c += 1; } table[b'-' as usize] = 10; table[b'.' as usize] = 11; table[0] = 15; // padding for an odd-length string table }; ``` Location: `wacore/binary/src/encoder.rs:32-43` #### Hex packing Uppercase hex strings (0-9, A-F) are packed into 4 bits per character, against `HEX_ENC` instead of `NIBBLE_ENC`: ```rust theme={null} // Input: "DEADBEEF" // Packed: 0xDE, 0xAD, 0xBE, 0xEF /// ASCII to nibble, the inverse of the decoder's `HEX_PAIRS`. static HEX_ENC: [u8; 256] = { let mut table = [PACK_INVALID; 256]; let mut c = b'0'; while c <= b'9' { table[c as usize] = c - b'0'; c += 1; } let mut c = b'A'; while c <= b'F' { table[c as usize] = 10 + (c - b'A'); c += 1; } table[0] = 15; // padding for an odd-length string table }; ``` Location: `wacore/binary/src/encoder.rs:14-28` Packing used to run through per-character `match` ladders (`pack_nibble`/`pack_hex`) reached via a `fn` pointer, and for a while carried a `portable_simd` fast path for long strings on top of that. Both are gone: measurement showed the vector path only ever beat the `match` ladders, not a lookup table, and lost to the table at every string length tested — `HEX_PAIRS[byte]` (the decoder's mirror-image table) is one 2-byte load, and a shuffle/interleave/store sequence doesn't beat that. The two lookup tables above replaced all three call paths; an exhaustive test (`encode_tables_match_the_ladders_they_replaced`) checks every one of the 256 byte values against the original `match` ladders so the tables can't silently drift from the encoding they replaced. ### JID Encoding JIDs have special compact encodings: #### JID\_PAIR (Standard JID) ```rust theme={null} // Format: JID_PAIR + user + server // Example: "15551234567@s.whatsapp.net" self.write_u8(token::JID_PAIR)?; if user.is_empty() { self.write_u8(token::LIST_EMPTY)?; } else { self.write_string(user)?; // "15551234567" } self.write_string(server)?; // "s.whatsapp.net" ``` Location: `wacore/binary/src/encoder.rs:706-715` #### AD\_JID (Device-Specific JID) ```rust theme={null} // Format: AD_JID + domain_type + device + user // Example: "15551234567:1@s.whatsapp.net" (device 1) self.write_u8(token::AD_JID)?; self.write_u8(server_to_domain_type(jid.server, jid.agent))?; self.write_u8(device)?; // Device number self.write_string(user)?; // User part only ``` The `domain_type` byte is **derived from the `Server` enum variant** at encoding time, not from the `agent` field directly. Since `Server` is `#[repr(u8)]`, the mapping is a direct cast for known variants: | `domain_type` | Server variant | Wire string | Description | | ------------- | ------------------- | ---------------- | ------------------------------------------- | | `0` | `Server::Pn` | `s.whatsapp.net` | Standard phone-number JIDs | | `1` | `Server::Lid` | `lid` | Linked Identity JIDs | | `128` | `Server::Hosted` | `hosted` | Cloud API / Meta Business API (phone-based) | | `129` | `Server::HostedLid` | `hosted.lid` | Cloud API / Meta Business API (LID-based) | | fallback | *(other)* | varies | Uses the `agent` byte from the JID | The `domain_type` must be derived from the JID's `server` field via `server_to_domain_type()`, **not** from `jid.agent`. A previous bug wrote `jid.agent` (which is 0 for most JIDs) unconditionally, causing LID JIDs to be encoded with `domain_type=0` instead of `domain_type=1`. This made LID group messages silently rejected by the server with error 421. Decoding used to have the mirror-image asymmetry. `domain_type` resolves to `server`, but the decoder also wrote that same byte into `agent`. For `Pn`/`Lid`/`Hosted`/`HostedLid` specifically, the encoder above always re-derives `domain_type` from `server` and ignores `agent`, and `Display` never renders `agent` for these servers either — so nothing consumed the redundant copy. (Other variants, like `Bot`/`Interop`, do read `agent` through the fallback row in the table above.) The only effect was that `agent` differed by provenance: `0` if you parsed the JID from text, the domain byte if you had just decoded it off the wire. With `PartialEq`/`Hash` derived at the time, the identical JID compared unequal and could hash to a different value — the same shape of bug as the encoder one above, just on the other side of the wire. The decoder now leaves `agent` at `0` for `Pn`/`Lid`/`Hosted`/`HostedLid`, so encode → decode is idempotent, and a wire-decoded JID equals the same JID parsed from text. `Jid`'s hand-written `PartialEq`/`Hash` (see the note above) mean this no longer depends solely on the decoder holding that line, either — any other code path that leaves a stray byte in `agent` on these servers still compares and hashes as identity-equal. Location: `wacore/binary/src/encoder.rs:699-705`, `362-369`; decoder fix: `wacore/binary/src/decoder.rs` #### INTEROP\_JID (cross-platform interop JID) ```rust theme={null} // Format: INTEROP_JID + user + device (u16 BE) + integrator (u16 BE) // No server is written — the token itself implies Server::Interop self.write_u8(token::INTEROP_JID)?; self.write_string(user)?; self.write_u16_be(device)?; self.write_u16_be(integrator)?; ``` The encoder only takes this path when `server == Server::Interop && integrator != 0`. A zero-integrator interop JID still encodes as `JID_PAIR`, as it always has. Device-specific `Pn`/`Lid`/`Hosted`/`HostedLid` JIDs are unaffected — they continue to use `AD_JID`, as described above. `JID_PAIR` has no field for `integrator`. Before this token was written, every interop JID encoded that way — including ones with a non-zero `integrator` — so the field was silently dropped on the way out, even though the decoder parses it on the way in. Two interop JIDs differing only in `integrator` addressed different things but produced identical bytes. This token is asymmetric between the two directions, and deliberately so. WA Web's own writer emits only the three fields above. That writer is the evidence for what the server actually accepts. WA Web's *decoder* additionally reads a trailing server byte. That extra byte describes what the server sends to the client, not what it expects to receive. Our decoder mirrors that same trailing read. As a result, encoding an interop JID this way does not round-trip through our own decoder for that token. That is a property of the protocol having two different shapes for the two directions, not a bug to paper over by making both ends agree locally. Location: `wacore/binary/src/encoder.rs` (`write_interop_jid`, `needs_interop_jid`) ### List Encoding Lists (including node structures) have length-prefixed encoding: ```rust theme={null} fn write_list_start(&mut self, len: usize) -> Result<()> { if len == 0 { self.write_u8(token::LIST_EMPTY)?; // 0x00 } else if len < 256 { self.write_u8(token::LIST_8)?; // 0xF8 self.write_u8(len as u8)?; } else { self.write_u8(token::LIST_16)?; // 0xF9 self.write_u16_be(len as u16)?; } Ok(()) } ``` Location: `wacore/binary/src/encoder.rs:865-876` ### Node encoding format A complete node is encoded as: ``` LIST_START(list_len) tag attr_key_1 attr_value_1 attr_key_2 attr_value_2 ... [content] // If present ``` Where `list_len = 1 (tag) + (num_attrs * 2) + (content ? 1 : 0)` ```rust theme={null} pub fn write_node(&mut self, node: &N) -> Result<()> { let content_len = if node.has_content() { 1 } else { 0 }; let list_len = 1 + (node.attrs_len() * 2) + content_len; self.write_list_start(list_len)?; self.write_string(node.tag())?; node.encode_attrs(self)?; node.encode_content(self)?; Ok(()) } ``` Location: `wacore/binary/src/encoder.rs:879-889` ## Decoding Process ### Decoder Structure ```rust theme={null} pub struct Decoder<'a> { data: &'a [u8], offset: usize, } impl<'a> Decoder<'a> { pub fn read_node_ref(&mut self) -> Result> pub fn read_list_size(&mut self) -> Result pub fn read_string(&mut self, len: usize) -> Result> } ``` Location: `wacore/binary/src/decoder.rs` ### Zero-copy decoding The decoder uses `NodeRef<'a>` to avoid allocations. String and byte payloads borrow directly from the input buffer. Decoded strings use `NodeStr<'a>` — a borrowed-or-inline string type that stores short owned values (up to 24 bytes) inline via `CompactString`, avoiding heap allocation: ```rust theme={null} /// Borrowed-or-inline string for decoded nodes. pub enum NodeStr<'a> { Borrowed(&'a str), // Zero-copy: points into input buffer Owned(CompactString), // Inline for short strings (≤24 bytes) } pub struct NodeRef<'a> { pub tag: NodeStr<'a>, // Borrowed or inline pub attrs: AttrsRef<'a>, // Vec<(NodeStr<'a>, ValueRef<'a>)> pub content: Option>, } pub enum NodeContentRef<'a> { Bytes(Cow<'a, [u8]>), // Zero-copy for byte content String(NodeStr<'a>), // Borrowed or inline Nodes(Box>), // Recursive borrowing } ``` `NodeStr` implements `Deref`, `AsRef`, `PartialEq`, and `PartialEq<&str>`, so you can use it anywhere a `&str` is expected. It also provides `to_compact_string()` for efficient conversion to an owned `CompactString`. `NodeStr` replaces the previous `Cow<'a, str>` used in `NodeRef`, `AttrsRef`, `ValueRef`, and `NodeContentRef`. The key difference is that the `Owned` variant uses `CompactString` (inline up to 24 bytes) instead of `String` (always heap-allocated), reducing allocation pressure for the many short protocol strings that can't be statically interned. `content` used to be `Option>>`. The box existed only to keep the `content` field itself pointer-sized (`NodeContentRef` is larger than a pointer) and was never a deliberate design decision — profiling a group-message fanout (one child `NodeRef` per device) found it responsible for the only allocation most content-bearing nodes made, about 11% of decode time in that shape. The field is now `Option>` directly: `size_of::()` grows from 48 to 72 bytes, paid back by removing that allocation on every node that carries content (string, bytes, or children). Nodes with no content, like a bare ``, are unaffected either way. Code matching on `node.content` doesn't need a match-arm change, but code borrowing through it — `node.content.as_deref()` — needs to become `node.content.as_ref()`, since there is no longer a `Box` to deref through. Location: `wacore/binary/src/node.rs:10-106`, `465-469`, `437-441` ### Node nesting depth cap `read_node_ref` decodes `LIST`-typed content recursively — a child `LIST_8`/`LIST_16` node causes `read_node_ref` to call itself for each nested child. Since a single `LIST` node needs only \~4 wire bytes to nest one level (`list_size == 2`, zero attrs, one child), a hostile or malformed frame could otherwise force tens of thousands of recursion levels from a tiny payload and overflow the native call stack. The decoder rejects this before it happens: recursion depth is tracked through `read_node_ref` → `read_content` → `read_content_from_tag`, and any node nested past `MAX_NODE_DEPTH` (128 levels) returns `BinaryError::MaxDepthExceeded` instead of recursing further. Real WhatsApp stanza trees are well under 20 levels deep, so this only ever rejects pathological input. ```rust theme={null} const MAX_NODE_DEPTH: usize = 128; ``` Location: `wacore/binary/src/decoder.rs` ### OwnedNodeRef (yoke zero-copy) `OwnedNodeRef` is a self-referential type that owns the decompressed network buffer while the inner `NodeRef` borrows string and byte payloads directly from it. This avoids copying payloads out of the buffer during decoding — only container allocations (attribute `Vec`, child `Vec`) occur. ```rust theme={null} pub struct OwnedNodeRef { inner: Yoke, Bytes>, } impl OwnedNodeRef { /// Decode a node from an owned buffer. pub fn new(buffer: impl Into) -> Result; /// Access the borrowed node. pub fn get(&self) -> &NodeRef<'_>; /// Convert to an owned Node (allocates — use sparingly). pub fn to_owned_node(&self) -> Node; /// The whole backing buffer, verbatim: exactly what `new` consumed. pub fn backing_bytes(&self) -> Bytes; // Convenience accessors: tag(), attrs(), get_attr(), // children(), get_optional_child(), content_bytes(), slice_bytes(), etc. } ``` Received stanzas flow through the system as `Arc`, giving handlers cheap shared access to the zero-copy decoded node. The `to_owned_node()` method is available as an escape hatch when you need a fully owned `Node`, but it allocates all strings and bytes — defeating the zero-copy benefit. `backing_bytes()` goes the other direction: it hands back the entire decoded buffer as a `Bytes` — a refcount bump into the same allocation `slice_bytes()` already views into, not a copy. It returns exactly what was passed to `new()` — the buffer after decompression, past the leading format byte `unpack` already strips. That's not necessarily the raw bytes as they arrived on the wire. Use it when you need to forward that buffer onward — to another process, a recording, a replay harness — instead of reading it. Re-encoding via `marshal_ref` is the alternative, and a worse one here: it costs a second pass over the tree, and it's only byte-faithful while the token dictionaries match the ones that decoded the node. `backing_bytes()` has no such dependency; it stays true regardless of what the dictionaries do. These are node bytes, though, not a sendable frame: a send path like [`Client::send_raw_bytes`](/api/client#send_raw_bytes) expects a packed payload — the format byte in front of the node bytes — not node bytes alone. Put the byte back with `pack` before forwarding `backing_bytes()` anywhere that expects marshal output; see [The format byte](#the-format-byte) above. Passing node bytes directly is exactly the shape `check_plain_payload` now rejects with [`BinaryError::UnexpectedFormatByte`](#error-handling), where it used to reach the socket and get the connection closed by the peer instead. Location: `wacore/binary/src/node.rs:594-693` #### Node vs NodeRef usage pattern * **`Node`** (owned) — used for building and sending outgoing stanzas. Constructed via `NodeBuilder`. * **`NodeRef<'a>`** (borrowed) — used for reading received stanzas. Borrows from the network buffer. * **`OwnedNodeRef`** — wraps a `NodeRef` with its backing buffer via `yoke`, enabling safe zero-copy sharing across handler tasks as `Arc`. #### Zero-copy serialization The entire `NodeRef` type family implements `serde::Serialize` (gated behind the `serde` feature), producing output identical to their owned counterparts. This means you can serialize a `NodeRef`, `OwnedNodeRef`, `ValueRef`, `JidRef`, or `NodeContentRef` directly — without converting to an owned `Node` first — avoiding all intermediate allocations. ```rust theme={null} use serde_json; // Serialize an OwnedNodeRef directly — zero-copy from the network buffer let owned_ref: OwnedNodeRef = OwnedNodeRef::new(buffer)?; let json = serde_json::to_string(&owned_ref)?; // Equivalent but allocates: convert to Node first let json_owned = serde_json::to_string(&owned_ref.to_owned_node())?; // Both produce identical JSON output assert_eq!(json, json_owned); ``` The following types implement `Serialize`: | Type | Serializes as | Notes | | -------------------- | ------------------ | --------------------------------------------------------------- | | `NodeRef<'a>` | `Node` struct | Fields: `tag` (as `&str`), `attrs` (newtype wrapper), `content` | | `NodeStr<'a>` | `&str` | Borrows the inner string directly | | `ValueRef<'a>` | `NodeValue` enum | Variant names match: `String`, `Jid` | | `JidRef<'a>` | `Jid` struct | Fields: `user`, `server`, `agent`, `device`, `integrator` | | `NodeContentRef<'a>` | `NodeContent` enum | Variant names match: `Bytes`, `String`, `Nodes` | | `OwnedNodeRef` | `Node` struct | Delegates to inner `NodeRef::serialize` | The `Serialize` implementations use an `AttrsRefWrapper` to match the newtype-struct framing that serde's derive produces for `Attrs(Vec<...>)`. This ensures compatibility with binary formats like bincode and postcard, which distinguish between a bare sequence and a newtype struct wrapper. This is useful for logging, debugging, protocol inspection, and forwarding stanzas to external systems without paying the cost of `to_owned_node()`. Location: `wacore/binary/src/node.rs:67-71`, `397-407`, `473-488`, `612-632`, `875-880`; `wacore/binary/src/jid.rs:603-615` The borrowed counterpart to `NodeValue` is `ValueRef<'a>`, used in the decoder path and in `NodeRef` attributes: ```rust theme={null} pub enum ValueRef<'a> { String(NodeStr<'a>), Jid(JidRef<'a>), } ``` `ValueRef` provides three methods: `as_str()` (returns `Cow<'_, str>` — zero-copy for `String`, allocates for `Jid`), `as_jid()` (returns `Option<&JidRef>`, only for `Jid` variant), and `to_jid()` (converts either variant to owned `Jid`, parsing from string if necessary). Location: `wacore/binary/src/node.rs:282-313` ### Attribute parsing `AttrParser` and `AttrParserRef` provide structured attribute extraction from owned `Node` and borrowed `NodeRef` values respectively. They accumulate parse errors instead of panicking: ```rust theme={null} let mut parser = node.attr_parser(); let msg_id: String = parser.required_str("id"); let msg_type: Option = parser.optional_str("type"); let recipient: Option = parser.optional_jid("to"); parser.finish()?; // Returns accumulated errors if any ``` The `optional_jid` method handles both `NodeValue` variants: * If the attribute is a `NodeValue::Jid`, it returns the JID directly via clone (zero parse cost). * If the attribute is a `NodeValue::String`, it parses via `Jid::from_str`. Parse failures are captured in the error list and surfaced when you call `finish()`, rather than being silently discarded. This ensures that malformed JID strings in protocol messages are reported as `BinaryError::Jid` (for `AttrParser`) or `BinaryError::AttrParse` (for `AttrParserRef`) instead of silently returning `None`. Location: `wacore/binary/src/attrs.rs` ### Unpacking Reverse of the packing process: ```rust theme={null} fn unpack_nibble(packed: u8, position: u8) -> u8 { let nibble = if position == 0 { (packed >> 4) & 0x0F } else { packed & 0x0F }; match nibble { 0..=9 => b'0' + nibble, 10 => b'-', 11 => b'.', 15 => 0, // Padding _ => panic!("Invalid nibble"), } } ``` Unpacking a run of packed values dispatches through a 256-entry byte-pair table — one lookup and a 2-byte store per input byte, rather than the two-shift/two-lookup scalar walk this snippet shows. This is an implementation detail of `read_packed`; the value mapping above still describes what each nibble decodes to for `NIBBLE_8`. `HEX_8` unpacking is the mirror of [`HEX_ENC`](#hex-packing) shown earlier — 0–9 to digits, 10–15 to `A`–`F` — not the nibble table above. Location: `wacore/binary/src/decoder.rs:400-450` ### Streaming decode (NodeStream) The tree decoder above needs every decompressed byte of a node in memory at once, plus one heap object per node and per attribute list. For a response of a few thousand small children — the A/B props catalog `fetch_props` requests is the case that motivated this — that costs an order of magnitude more than the wire bytes. A fixture with 2,660 codes (29,707 B compressed, 61,120 B decompressed) peaked at **\~770 KB** decoded as a tree (largest single block 191,520 B), on top of the 47.5 KB inflate state and a second 28 KB copy of the frame that `FrameDecoder::feed` made before `feed_owned` existed. On a target with a few hundred KB of heap total, that node cannot be decoded that way at all. `NodeStream` (`wacore/binary/src/stream.rs`) decodes the same wire format from a source that produces bytes on demand, so a caller that wants a handful of children out of thousands keeps one child, and one inflate window, alive at a time. On the same fixture, streamed decode peaked at **4,984 B** (largest block 4,096 B) with the thread's inflate state already parked, or **52,536 B** (largest block 47,552 B, the zlib-rs state plus its 32 KB window) when the state had to be built on demand. The cursor moves through a node the way its bytes are laid out — four operations: ```rust theme={null} impl<'a> NodeStream<'a> { /// Decode the head of the next node (tag, attrs, child count) and descend /// into it. At depth 0 that's the root; inside an open node it's that /// node's next child, which then becomes the open node. pub fn open(&mut self) -> Result>>; /// Decode the open node's next child whole, subtree and all. pub fn next_child(&mut self) -> Result>>; /// Leave the open node, decoding and discarding any children not yet /// read, so the cursor stands at the parent's next child. pub fn close(&mut self) -> Result<()>; /// Consume everything after the cursor and check that the bytes end /// where the node does — nothing left over behind the root, and for a /// compressed payload, a properly terminated zlib stream (the trailer's /// checksum is what says every inflated byte was the one the peer sent). pub fn finish(&mut self) -> Result<()>; } ``` `open` returns an `OpenNode<'b>` borrowing the stream's buffer — `tag`, `attrs`, and `content` (an `OpenContent` telling you whether what follows is children, bytes, or a string) — valid until the stream's next call. `next_child` returns a full `NodeRef<'_>`, the same borrowed type the tree decoder produces for one node, so a spec that wants a child intact doesn't need a second parsing path for it. The inflate window behind a compressed stream is 4 KB (`FRAME_INFLATE_CHUNK`) — sized for the sub-100-byte children a props response is made of — and only grows to fit a child larger than that. A spec opts into this by implementing `IqStreamSpec` (`wacore::iq::spec`) alongside `IqSpec` and calling it through [`Client::execute_streaming`](/api/client#execute_streaming) instead of `execute`. `PropsSpec::retaining(codes)` (`wacore/src/iq/props.rs`) is the reference implementation: it opens the `` child, walks `next_child` filtering by config code, and closes — the response never exists in memory as more than one `` at a time plus what it keeps. `IqSpec::parse_response` keeps working for the same spec and agrees with the streamed reading; it's still the path taken for an error response or a session with a raw-node observer attached, both of which need the tree held whole anyway. Location: `wacore/binary/src/stream.rs` ## Performance Optimizations ### Token interning with Cow When converting decoded `NodeRef` values to owned `Node` values, the `intern_cow` function maps known protocol strings to their static references using the unified hashify lookup: ```rust theme={null} fn intern_cow(s: &str) -> Cow<'static, str> { if let Some(kind) = token::index_of_token(s) { let interned = match kind { token::TokenKind::Single(idx) => token::get_single_token(idx), token::TokenKind::Double(dict, idx) => token::get_double_token(dict, idx), }; if let Some(token) = interned { return Cow::Borrowed(token); // Zero-alloc: points to static str } } Cow::Owned(s.to_string()) // Fallback: heap-allocate unknown strings } ``` The unified token map is generated at compile time via `hashify::tiny_map!` with length-bucketed dispatch. The resulting single lookup is O(1) and hash-free. Since the vast majority of node tags and attribute keys are part of the WhatsApp token dictionary, this eliminates most heap allocations during protocol decoding. This optimization applies to: * **`Node.tag`** — protocol tags like `"message"`, `"iq"`, `"receipt"` * **`Attrs` keys** — attribute names like `"id"`, `"type"`, `"to"`, `"from"` `Jid.server` is now a `Server` enum (a `Copy` type, `#[repr(u8)]`), so it requires no allocation at all — neither heap nor interning. This is an improvement over the previous `Cow<'static, str>` approach. Location: `wacore/binary/src/node.rs:108-123` ### Two-Pass Encoding For large or variable-size payloads, exact size calculation prevents buffer growth: ```rust theme={null} pub fn marshal_exact(node: &Node) -> Result> { // Pass 1: Calculate exact size let plan = build_marshaled_node_plan(node); // Pass 2: Encode directly into fixed-size buffer let mut payload = vec![0; plan.size]; let mut encoder = Encoder::new_slice(&mut payload, Some(&plan.hints))?; encoder.write_node(node)?; Ok(payload) } ``` Location: `wacore/binary/src/marshal.rs:67-76` ### String hint cache Repeated strings (like JIDs) are analyzed once and cached. Strings longer than `PACKED_MAX` (127 bytes) are immediately classified as `RawBytes` without running the full classification logic, since they can never be protocol tokens (max 48 bytes), packed nibble/hex, or JIDs: ```rust theme={null} pub struct StringHintCache { hints: Vec<(StrKey, StringHint)>, } impl StringHintCache { fn hint_or_insert(&mut self, s: &str) -> StringHint { // Strings longer than PACKED_MAX can't be tokens, // packed nibble/hex, or JIDs — skip classification entirely if s.len() > token::PACKED_MAX as usize { return StringHint::RawBytes; } if let Some(existing) = self.hints.iter().find(...) { return existing; } let hint = classify_string_hint(s); self.hints.push((key, hint)); hint } } ``` The same length check is applied in the uncached write path (`write_string_uncached`), where strings exceeding `PACKED_MAX` are emitted directly as raw bytes without classification. This avoids unnecessary work for long strings like message bodies, media URLs, and base64-encoded payloads. Location: `wacore/binary/src/encoder.rs:240-287` ### Capacity estimation Auto-sizing strategy samples node structure to estimate capacity. The `Cow<'static, str>` tag on owned `Node` works transparently since `Cow` implements `Deref`: ```rust theme={null} fn estimate_capacity_node(node: &Node) -> usize { let mut estimate = DEFAULT_MARSHAL_CAPACITY + 16; estimate += node.tag.len(); // Works with both Cow::Borrowed and Cow::Owned estimate += node.attrs.len() * AUTO_ATTR_ESTIMATE; // ~24 bytes/attr if let Some(NodeContent::Nodes(children)) = &node.content { estimate += children.len() * AUTO_CHILD_ESTIMATE; // ~96 bytes/child // Sample first 32 children for better accuracy for child in children.iter().take(AUTO_CHILD_SAMPLE_LIMIT) { estimate += child.tag.len() + ... } } estimate.clamp(DEFAULT_MARSHAL_CAPACITY, AUTO_MAX_HINT_CAPACITY) } ``` Location: `wacore/binary/src/marshal.rs:167-200` ### Inline attribute storage `Attrs` uses `AttrsVec = SmallVec<[(Cow<'static, str>, NodeValue); 2]>` instead of a plain `Vec`. The `SmallVec` stores up to 2 entries inline inside the struct itself, eliminating the per-node heap allocation for the attribute buffer on the encode hot path. Inline capacity 2 is the measured optimum: the per-recipient fanout nodes (`to` with 1 attr, `enc` with 2) stay inline, while stanza roots with 3+ attrs spill once per stanza. A larger inline array (4) grows `Node` from \~184 bytes to \~296 bytes — moving nodes through children `Vec`s then costs more than the spared spills save. **Impact (iai-callgrind vs. `Vec` backing):** * `marshal_allocating` (typical stanza): −6.6% instructions * `marshal_many_children_allocating` (2048 children): −9.9% instructions, −25% RAM hits * Allocation count per DM stanza: −27% (15→11 allocs); per group stanza with 800 participants: −40% (4012→2412 allocs) The `spilled()` method on `SmallVec` reports whether a given node's attrs overflowed to the heap — useful in allocation tests (see `wacore/binary/tests/attrs_inline_alloc.rs`). Location: `wacore/binary/src/node.rs`, `wacore/binary/tests/attrs_inline_alloc.rs` ### Unboxed content, byte-pair unpacking Two more `wacore/binary` decode-path changes worth knowing about if you're timing this yourself: * **`NodeRef::content` dropped its `Box`** (see the note under [Zero-copy decoding](#zero-copy-decoding)). The saved allocation only exists on nodes that carry content, so the gain tracks how much of a decode is content-bearing: instructions on a group-fanout decode (one child `NodeRef` per device) drop 9.5%, at the cost of growing `NodeRef` from 48 to 72 bytes. A content-less node like a bare `` makes the same allocations either way. * **`read_packed` unpacks through a 256-entry byte-pair table** instead of a per-byte scalar loop (see [Unpacking](#unpacking)). This was the larger win: a small-stanza decode that mixes a JID pair, nibble-packed, and hex-packed values dropped 15.5% in instructions. Neither changes the wire format. The byte-pair table is purely internal to `read_packed`. Unboxing `NodeRef::content` does change a public field's type — see the caller migration (`as_deref()` → `as_ref()`) noted above — but no method signature changes. ## Common protocol patterns ### IQ (info/query) stanzas ```rust theme={null} // Request NodeBuilder::new("iq") .attr("id", "ABC123") .attr("type", "get") .attr("xmlns", "w:g2") .attr("to", "@s.whatsapp.net") .children(vec![ NodeBuilder::new("query").build(), ]) .build() // Response NodeBuilder::new("iq") .attr("id", "ABC123") .attr("type", "result") .attr("from", "@s.whatsapp.net") .children(vec![ NodeBuilder::new("group") .attr("id", "123456@g.us") .attr("subject", "My Group") .build(), ]) .build() ``` ### Messages ```rust theme={null} NodeBuilder::new("message") .attr("to", "15551234567@s.whatsapp.net") .attr("type", "text") .attr("id", message_id) .children(vec![ NodeBuilder::new("enc") .attr("v", "2") .attr("type", "msg") .bytes(encrypted_payload) .build(), ]) .build() ``` ### Receipts ```rust theme={null} NodeBuilder::new("receipt") .attr("to", "15551234567@s.whatsapp.net") .attr("id", message_id) .attr("type", "read") .attr("t", timestamp) .build() ``` ### Nack reasons When a stanza fails terminally (unparseable proto, missing message secret, exceeded retry budget, …) the client emits a `` so the server stops retransmitting. v0.6 introduced the `wacore::protocol::nack::NackReason` enum which mirrors WA Web's full set: ```rust theme={null} pub enum NackReason { ParsingError, InvalidProtobuf, InvalidStanza, MissingMessageSecret, SessionNotFound, InvalidPreKey, UnknownEncType, DecryptionError, BadMac, DuplicateMessage, MaxRetryReached, UnsupportedFeature, // …21 variants total } ``` Each variant maps to the integer reason code WA Web sends on the wire. The client picks the variant from the decrypt failure path — `ParsingError` for malformed binary, `InvalidProtobuf` for wa::Message decode failures, `MaxRetryReached` after the PDO recovery state machine gives up, and so on. Consumers building custom transports can reuse the enum to produce wire-compatible nacks. ### Manual stanza acknowledgement The automatic receive pipeline already acks and nacks stanzas as it processes them. Callers that intercept raw nodes themselves — custom transports, replay tooling, mock servers — can respond explicitly instead: ```rust theme={null} pub async fn acknowledge_stanza( &self, stanza: &NodeRef<'_>, ) -> Result<(), StanzaResponseError> pub async fn reject_stanza( &self, stanza: &NodeRef<'_>, rejection: StanzaRejection, ) -> Result<(), StanzaResponseError> ``` `acknowledge_stanza` sends a plain `` built from the stanza's `id`/`from` (and, for `message` stanzas, the local device's PN — this fails with `StanzaResponseError::MissingLocalIdentity` if pairing hasn't completed). It always preserves the stanza's `participant` attribute, unlike the automatic pipeline, which omits a `participant` that merely duplicates the `from` JID on `` stanzas. `reject_stanza` sends `` built from a `StanzaRejection`: ```rust theme={null} impl StanzaRejection { pub const fn new(reason: NackReason) -> Self; pub const fn invalid_protobuf(failure_reason: Option) -> Self; pub const fn reason(self) -> NackReason; pub const fn failure_reason(self) -> Option; } ``` `invalid_protobuf` is the only constructor that can carry a `failure_reason` — the typed detail from a `wa::Message` decode failure; every other rejection reason encodes `None`. `acknowledge_stanza` accepts any stanza class; `reject_stanza` accepts only `message`, `receipt`, and `notification` stanzas, unless `rejection.reason()` is `NackReason::UnrecognizedStanza`, in which case any class is accepted — matching the protocol's own catch-all nack path. Malformed input (missing `id`/`from`, or `reject_stanza` called on an unsupported class) is returned to the caller as a typed `StanzaResponseError` instead of being silently dropped, unlike the tolerant automatic receive path: ```rust theme={null} pub enum StanzaResponseError { MissingAttribute(&'static str), MissingLocalIdentity, UnsupportedStanzaClass, Encoding(wacore_binary::error::BinaryError), Client(ClientError), } ``` See [Decryption retry mechanism](/guides/receiving-messages#requesting-a-retry-manually) for the equivalent manual entry point into retry receipts. ## Wire format examples ### Simple Message ``` Node: Binary: F8 03 LIST_8(3) [tag + 2 attrs] 13 Token("message") 16 Token("type") 07 Token("text") ``` ### Message with Body ``` Node: Hi Binary: F8 04 LIST_8(4) [tag + 2 attrs + content] 13 Token("message") 16 Token("type") 07 Token("text") F8 02 LIST_8(2) [child: tag + content] 07 Token("body") FC 02 BINARY_8(2) 48 69 "Hi" ``` ## Debugging Tools ### Inspecting encoded data Use `evcxr` REPL for interactive exploration: ```rust theme={null} :dep wacore-binary = { path = "wacore/binary" } :dep hex = "0.4" use wacore_binary::marshal::unmarshal_ref; use wacore_binary::builder::NodeBuilder; // Decode binary data { let data = hex::decode("f8034c1a07").unwrap(); let node = unmarshal_ref(&data).unwrap(); println!("Tag: {}", node.tag); for (k, v) in node.attrs.iter() { println!(" {}: {}", k, v); } } // Encode and inspect { let node = NodeBuilder::new("message") .attr("type", "text") .build(); let bytes = marshal(&node).unwrap(); println!("Encoded: {:02x?}", bytes); } ``` ## Error Handling ```rust theme={null} pub enum BinaryError { UnexpectedEof, EmptyData, // Payload was empty where one was required InvalidToken(u8), InvalidListSize, AttrParse(String), Jid(JidParseError), // JID parse failures from AttrParser LeftoverData(usize), Io(std::io::Error), MaxDepthExceeded, // Node nesting exceeded MAX_NODE_DEPTH (128) UnexpectedFormatByte(u8), // Packed payload's leading byte wasn't FORMAT_PLAIN } ``` The `Jid` variant is emitted by `AttrParser::optional_jid` when a string attribute fails to parse as a JID. This ensures malformed JIDs in protocol messages are surfaced as typed errors rather than silently ignored. The `EmptyData` variant is emitted by `check_plain_payload` (and `unpack`/`unpack_bytes`) for a buffer with nothing in it, or — for `check_plain_payload` specifically — a format byte with no node bytes behind it. The `UnexpectedFormatByte(u8)` variant is emitted by `check_plain_payload` — and, through it, by `unmarshal_packed_ref` and [`Client::send_raw_bytes`](/api/client#send_raw_bytes) — when a buffer that should be a packed payload doesn't start with `FORMAT_PLAIN`. The typical cause is passing node bytes (e.g. `OwnedNodeRef::backing_bytes()`) where a packed payload is expected, instead of packing them first; see [The format byte](#the-format-byte). The `MaxDepthExceeded` variant is emitted by `read_node_ref` when a decoded frame nests `LIST` nodes past `MAX_NODE_DEPTH` (128 levels) — see [Node nesting depth cap](#node-nesting-depth-cap). Since real stanza trees never approach this depth, callers can treat it the same as any other malformed-frame error (drop the connection / reject the frame). Location: `wacore/binary/src/error.rs` ## Related Components * [Signal Protocol](/advanced/signal-protocol) - How messages are encrypted before marshaling * [WebSocket Handling](/advanced/websocket-handling) - How binary data is framed and transmitted * [State Management](/advanced/state-management) - Protocol state stored in Device ## References * Source: `wacore/binary/src/` * Token dictionary: `wacore/binary/src/token.rs` * Node builder: `wacore/binary/src/builder.rs` # Codegen flags for performance Source: https://whatsapp-rust.jlucaso.com/advanced/build-flags Optional target-feature flags you can set for roughly a fifth fewer instructions on the Signal encrypt paths — and why whatsapp-rust does not set them for you. ## Overview whatsapp-rust sets no `-C target-feature` in its own build. The crate is published to crates.io, so it cannot know what CPU it will run on. The flag has no runtime fallback: a binary built with a feature the CPU lacks does not degrade, it crashes on the first illegal instruction — `SIGILL` on Unix, `STATUS_ILLEGAL_INSTRUCTION` on Windows. If you control your own deployment target, you can trade that safety for fewer instructions executed yourself. Setting `+bmi2,+avx2` measures at roughly **a fifth fewer instructions** (via callgrind, not wall-clock time) on the measured Signal encrypt paths — group and DM encryption; decryption is not separately benchmarked here. This is opt-in for applications with a known deployment target, never a default. If you build without an explicit `--target`, every machine that will build *or* run your binary needs both features — see [below](#the-build-host-needs-the-features-too). ## Recommendation ```toml .cargo/config.toml theme={null} [target.x86_64-unknown-linux-gnu] # Replace this triple with whatever you pass to `cargo build --target` (or # set as `build.target`) — not your build host's triple, which is a # different thing when cross-compiling. Keying this to the wrong triple # (e.g. a musl, Windows, or macOS target) is a silent no-op: Cargo simply # won't read this section. rustflags = ["-Ctarget-feature=+bmi2,+avx2"] ``` If a `target..rustflags` array for the same triple also exists in an ancestor directory's config or in `$CARGO_HOME/config.toml`, Cargo joins the arrays across that hierarchy rather than replacing one with the other. What does *not* combine is the choice between rustflags *sources*. Cargo takes rustflags from exactly one of `CARGO_ENCODED_RUSTFLAGS`, `RUSTFLAGS`, `target..rustflags`, or `build.rustflags`, in that priority order. So an invocation that sets `RUSTFLAGS` for any other reason silently discards the config entry above — merge the feature flags into that variable instead in that case. ### Confirm every deployment target actually has both features Check by feature bit, not by the CPU's age or product name. Intel Silvermont and Goldmont, and AMD Jaguar and Puma, all lack one or both features — Goldmont and Puma despite being newer than Haswell — while other parts sold under the same Atom/Celeron/Pentium names do have both. Use an OS-filtered report, since a raw CPUID read can claim AVX2 is present when the OS hasn't enabled the YMM state it needs. On Linux, `/proc/cpuinfo` is already OS-filtered: ```bash theme={null} # Linux only. Nonzero exit if ANY logical CPU is missing either feature. awk '/^flags/ { if (!/(^| )avx2( |$)/ || !/(^| )bmi2( |$)/) bad++ } END { exit bad > 0 }' /proc/cpuinfo ``` On Windows or macOS, there's no equivalent file to grep — check from Rust itself with [`is_x86_feature_detected!`](https://doc.rust-lang.org/std/macro.is_x86_feature_detected.html), which queries the OS-filtered feature set on every platform stdlib supports. ### The build host needs the features too A `[target.x86_64-unknown-linux-gnu]` rustflags entry reaches your build scripts and proc macros too, but only when you invoke cargo without an explicit `--target` — that's when cargo unifies host and target compilation. `-Ctarget-feature` only tells LLVM it's *allowed* to emit those instructions in code built for the host, not that any given build script or proc macro actually will — so if your builder is an older machine building for a newer fleet, the build itself may or may not fail, depending on what those host artifacts happen to contain. Either require both features on the builder, or pass `--target x86_64-unknown-linux-gnu` explicitly: that splits host tools from the target build, and the rustflags entry no longer reaches them. ## Why not a library default The failure mode is worse than a refusal to start. A load-time ISA check does exist in principle (glibc 2.33+'s `GNU_PROPERTY_X86_ISA_1_NEEDED`), but `-C target-feature` doesn't emit that property — so there's no guaranteed check at startup. The process starts normally, passes its readiness probe, and traps whenever execution first reaches an emitted instruction, which can be well after boot and into live traffic. A clean startup is not evidence of compatibility. That's an acceptable trade if you have a known, homogeneous fleet. It's not one a published library can make on your behalf. ## Measured impact Instruction counts (Ir, via callgrind) on this repository's own Signal benches, as a delta against a build with no `target-feature` set: | Flag | Key gen | Sig create | Sig verify | Group encrypt | DM encrypt | CPU floor (Intel / AMD) | | ----------------- | ---------: | ---------: | ---------: | ------------: | ---------: | ----------------------------- | | `+bmi2` | −11.2% | −11.1% | −6.6% | −11.6% | −12.3% | Haswell 2013 / Excavator 2015 | | `+avx2` | −13.5% | −12.4% | −1.7% | −11.9% | −8.0% | Haswell 2013 / Excavator 2015 | | **`+bmi2,+avx2`** | **−24.1%** | **−22.9%** | **−8.2%** | **−23.0%** | **−19.7%** | Haswell 2013 / Excavator 2015 | The two flags are additive because they touch disjoint code: `+bmi2` speeds up `FieldElement51` arithmetic (LLVM emits `mulx`), while `+avx2` vectorizes the constant-time fixed-base lookup table scan — a different function entirely. `+adx` was measured and rejected: it adds nothing over `+bmi2` alone (0.0004% apart on a full send benchmark) while raising the CPU floor a generation, because `FieldElement51`'s limbs never form the carry chain `adcx`/`adox` would pay for. `-Ctarget-cpu=native` was measured and rejected too — on the measurement host it emitted AVX-512 that made the binary die under Valgrind-based profiling (including CodSpeed CI), and prior wall-clock A/B testing found it slower than the explicit `+bmi2,+avx2` list on the same host family. ## wasm32 For `wasm32-unknown-unknown`, the equivalent is `-Ctarget-feature=+simd128`. Whether you can use it depends on the runtimes you deploy to, not on any CPU: a module using `v128` fails WebAssembly *validation* outright on an engine without the SIMD proposal. whatsapp-rust builds clean with the flag set. No wasm-side speed or size numbers are published here — this workspace declares no `cdylib` and has no wasm benchmark harness to measure one. ## Related * [Signal protocol internals](/advanced/signal-protocol) — the code paths these flags affect. * Full measurement methodology (callgrind commands, per-function attribution, rejected alternatives) lives in [`agent_docs/build_flags.md`](https://github.com/oxidezap/whatsapp-rust/blob/main/agent_docs/build_flags.md) in the source repo. # Inbound Durability Hook Source: https://whatsapp-rust.jlucaso.com/advanced/inbound-durability Opt in to at-least-once message delivery by deferring the transport ack until your consumer durably commits each batch of messages. ## Overview By default the client acknowledges a message to the WhatsApp server **as soon as it is decrypted** (at-most-once delivery). The ack tells the server to drop the message from its offline queue and never resend it. If your process crashes, or your storage write fails, *after* the ack but *before* you persist the message, the message is lost — the server will not redeliver it. Registering an `InboundDurabilityHook` converts the consumer to **at-least-once delivery**: 1. The decrypted message(s) are buffered durably in the `pending_inbound_messages` table **before** the Signal ratchet is flushed. 2. Your hook is awaited with the whole batch. On `Ok` every message in the batch is acked and its buffer row cleared. 3. On `Err` (or a crash), all their acks are suppressed. The server redelivers the batch on the next connect, where the hook runs again from the buffered copies. Default behavior is unchanged — with no hook registered nothing is buffered and the ack path is identical to before. This is the same gap whatsmeow closes with `SynchronousAck` + `EnableDecryptedEventBuffer`. The design follows whatsmeow's decrypt-buffer approach rather than a global gate. ## Batching Live traffic is delivered to the hook one message at a time — a batch of one, committed immediately, so latency is unchanged from the previous per-message behavior. During the **offline drain** (the backlog replayed on reconnect), the client accumulates decrypted messages and commits them as a batch, mirroring WhatsApp Web's `MessageProcessorCache` granularity. A batch flushes on whichever trigger fires first: | Trigger | Value | | ------------------------------------- | ------------ | | Message count | 400 | | Encoded size | 4 MiB | | Timeout since first buffered message | 3 seconds | | End of drain / disconnect / reconnect | forced flush | These triggers are internal constants, not currently exposed as configuration. The message-count trigger matches WhatsApp Web's `web_message_processing_cache_size` (the snapshot flush granularity — distinct from the 200-stanza `` server pull size). The drain→live transition is raceless: the tail batch of the drain always commits before any live-mode message is processed, so a consumer never observes drain and live messages out of order across the boundary. Within a batch, the commit order is: durable buffer write (one transaction) → Signal-cache flush → your hook → buffer clear → acks → buffered offline delivery receipts flushed → `Event::Messages` dispatch. A failure at any step **up to and including acks** leaves the **entire batch** unacked, and the server redelivers all of it — so your hook must commit a batch all-or-nothing. The two trailing steps (the delivery-receipt flush and the `Event::Messages` dispatch) run after the acks have been sent and do not affect redelivery. Offline delivery receipts are flushed once **per durable drain batch**, not only at the end of the full offline drain, matching WA Web's `createSnapshot` → `sendAggregateOfflineReceipts` per snapshot. This bounds the receipt buffer over a large backlog and caps redelivery on a mid-drain disconnect to a single snapshot instead of the whole backlog. Once your hook returns `Ok`, the dispatched `Event::Messages` carries `hook_committed: true` on its [`MessageBatch`](/concepts/events#messages) — a marker for another consumer of the same event stream, not an instruction to skip anything: a hook that persists somewhere other than that consumer's own store still needs it to materialize every batch. An application-level store that materializes this same event stream can use the flag to avoid double-processing a hook-fed batch. ## Opting In ```rust theme={null} use whatsapp_rust::{InboundDurabilityHook, prelude::*}; use async_trait::async_trait; use std::sync::Arc; struct MyStore { /* your DB connection */ } #[async_trait] impl InboundDurabilityHook for MyStore { async fn on_messages( &self, _client: Arc, batch: &[InboundMessage], ) -> anyhow::Result<()> { // Ideally a single INSERT/transaction over the whole batch. This // loop commits per item instead, so a failure partway through (the // `?` on a later item) leaves earlier items already durably // committed — yet the SDK still suppresses every ack and redelivers // the whole batch on Err. `my_db_insert` MUST be an idempotent // upsert (e.g. `INSERT ... ON CONFLICT DO NOTHING`) so that replay // of an already-committed item is a no-op, not a duplicate. for item in batch { my_db_insert(&item.info.id, &item.message).await?; } Ok(()) } } let bot = Bot::builder() .with_backend(SqliteStore::new("whatsapp.db").await?) // Opt in — without this call the client keeps its default at-most-once behavior. .with_inbound_durability_hook(MyStore { /* ... */ }) .on_message(|ctx| async move { // Event handlers still fire once per message, in arrival order; // the ack is deferred in the background. println!("received: {}", ctx.info.id); }) .build() .await?; ``` ## The `InboundDurabilityHook` Trait ```rust theme={null} use async_trait::async_trait; use std::sync::Arc; use wacore::types::events::InboundMessage; #[async_trait] pub trait InboundDurabilityHook { async fn on_messages(&self, client: Arc, batch: &[InboundMessage]) -> anyhow::Result<()>; } ``` | Parameter | Description | | --------- | --------------------------------------------------------------------------------------------------------------------------- | | `client` | The active client — use it for lookups, **not** for sending to a sender present in the batch (see [Caveats](#caveats)). | | `batch` | The decrypted messages to commit, in arrival order. A batch of one on live traffic; possibly many during the offline drain. | Each `InboundMessage` carries: ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct InboundMessage { pub message: Arc, pub info: Arc, } ``` Return `Ok(())` once the whole batch is durably committed. Return `Err` to suppress every ack in the batch. `InboundMessage`, `MessageBatch`, and `BatchOrigin` are re-exported from the crate `prelude`. ## Idempotency Requirement At-least-once means the hook **will be called more than once for the same message** when a crash occurs after the consumer commits but before the ack lands. Your hook **must be idempotent**, and since a failed batch is redelivered whole, a partially-applied batch commit must also be safe to re-run. Deduplicate by the full triplet `(info.source.chat, info.source.sender, info.id)` — **not** `info.id` alone. Stanza IDs are only unique within a `(chat, sender)` pair, so two different chats can reuse the same ID string. ```rust theme={null} // Correct idempotency key, per item in the batch let key = ( item.info.source.chat.to_string(), item.info.source.sender.to_string(), item.info.id.clone(), ); // Wrong — id alone is not globally unique let key = item.info.id.clone(); ``` In SQL, a `UNIQUE` constraint or `INSERT OR IGNORE` on `(chat, sender, id)` is the most robust approach. ## How Redelivery Works When the server redelivers a message the client previously did not ack: 1. The client detects the duplicate stanza. 2. If a hook is registered and a buffered copy exists in `pending_inbound_messages`, the message re-enters the commit pipeline (it can be grouped into the same batch as other stanzas being processed at the time) and the hook runs from the buffered copy. 3. On `Ok` the buffer row is cleared and the message is acked. 4. On `Err` the buffer is kept; the hook runs again on the next redelivery. 5. If no buffered copy exists (genuine duplicate already committed), the message is acked directly without invoking the hook. A 7-day retention sweep removes rows that a permanently-failing hook would otherwise leak. Rows that reach their TTL without being cleared are deleted, and if the server redelivers after that point the message degrades to at-most-once. ## Backend Requirement Durable cross-crash replay requires a backend that implements the pending-inbound methods on `ProtocolStore`: * `store_pending_inbound` — write the decrypted message bytes before the hook runs * `get_pending_inbound` — read the buffer on redelivery * `delete_pending_inbound` — clear the buffer after the hook commits * `delete_expired_pending_inbound` — retention sweep (called unconditionally from keepalive) Two additional batch-oriented methods, `store_pending_inbound_batch` and `delete_pending_inbound_batch`, default to looping the single-row methods above, so existing custom backends keep working unchanged. The bundled `SqliteStore` overrides both to commit a whole batch in one transaction — see [Custom Backends](/guides/custom-backends#pending-inbound-buffer) if you want the same atomicity in your own backend. Backends that implement none of these return an error from the defaults, which causes `Bot::build()` to fail with `BotBuilderError::UnsupportedDurabilityBackend` — a clear error rather than a silent runtime degradation. ## Caveats **At-least-once, not exactly-once.** A crash after your consumer commits but before the ack lands replays the message (or its whole batch). Your hook must be idempotent (deduplicate by `(chat, sender, id)`). **Backpressure.** The hook is awaited inside the receive pipeline. A slow hook backpressures inbound processing for the duration of the commit — the same trade-off as whatsmeow's synchronous ack. Persist and return; spawn any reply logic after the hook returns `Ok`. **No synchronous sends to a sender in the batch.** During 1:1 message processing the per-sender Signal lock is held. Performing a synchronous client operation to a sender present in the batch (e.g. a blocking reply) will deadlock. Use `tokio::spawn` if you need to reply from within the hook. **Scope.** The hook covers end-to-end encrypted messages (1:1 and group). Newsletter and broadcast channel messages use a separate ack path and are never gated by the hook — they dispatch `Event::Messages` directly. PDO placeholder recoveries (`info.unavailable_request_id` set) bypass the hook the same way. **Buffer-write failure.** If the durable buffer write itself fails (e.g. disk full), the acks for that batch are suppressed, but if the process does not crash the Signal ratchet still advances. Those messages degrade to at-most-once on their next redelivery (they can no longer be decrypted and there is no buffered copy to replay). The guarantee holds whenever the buffer write succeeds. **Redelivery `info` fields.** On a redelivery replay, `info` is re-parsed from the stanza. A few fields derived during the first dispatch (the ephemeral timer, encrypted comment threading) may be absent. The `message` body is always the original. **`Event::Messages` is at-least-once too, when a hook is registered.** A redelivery whose buffered copy survived (e.g. the post-commit cleanup failed and the ack was lost) replays through the same commit and dispatches the event again — event handlers need the same idempotency discipline as the hook if they perform side effects. **Resent messages get their own, separate gate.** The redelivery handling above covers a byte-identical stanza the server replays. A sender-side outbox retry instead re-encrypts the same message under a new sender-key iteration, so it isn't byte-identical and reaches the pipeline as an apparently-fresh decrypt. Since [#1352](https://github.com/oxidezap/whatsapp-rust/pull/1352), an in-memory dispatch-once gate (`dispatched_messages` in `CacheConfig`, default 5-minute TTL) collapses that case to one `Event::Messages` too — see [Resend collapse](/concepts/events#messages). Unlike the pending-inbound buffer, this gate has no durable backing and does not survive a restart of your process, so it narrows the window for a duplicate dispatch rather than closing it: your hook and event handlers still need their own idempotency for the reasons above. ## Full Example The repository ships `examples/durability_hook.rs` — a file-backed archiver that appends each message in a batch to disk with a single `fsync` before returning `Ok`, and seeds the deduplication set from the archive on startup so dedupe survives a restart. ```bash theme={null} cargo run --example durability_hook ``` Key patterns from that example: ```rust theme={null} #[async_trait::async_trait] impl InboundDurabilityHook for InboxArchiver { async fn on_messages( &self, _client: Arc, batch: &[whatsapp_rust::types::events::InboundMessage], ) -> anyhow::Result<()> { // Live traffic arrives one message at a time; an offline drain hands // over a whole batch. Either way the commit below is a single append + // fsync, so the durability cost amortizes over the batch. let mut lines = String::new(); let mut keys: Vec = Vec::with_capacity(batch.len()); { let seen = self.seen.lock().map_err(|_| anyhow::anyhow!("seen lock poisoned"))?; for m in batch { let key: CommitKey = ( m.info.source.chat.to_string(), m.info.source.sender.to_string(), m.info.id.clone(), ); // Dedup against the archive AND earlier entries of this same // batch, so one fsync can never append a key twice. if seen.contains(&key) || keys.contains(&key) { continue; } let preview = m.message.conversation.as_deref().unwrap_or("").replace(['\t', '\n'], " "); lines.push_str(&format!("{}\t{}\t{}\t{preview}\n", key.0, key.1, key.2)); keys.push(key); } } if !keys.is_empty() { // Durable commit on a blocking thread: append then fsync — all-or- // nothing for the batch. Returning Ok only after sync_all means // "safe to ack every message"; any error returns Err, so the acks // are suppressed and the server redelivers the batch later. let file = Arc::clone(&self.file); tokio::task::spawn_blocking(move || -> std::io::Result<()> { let mut f = file.lock().expect("file lock poisoned"); f.write_all(lines.as_bytes())?; f.sync_all() }).await.map_err(|e| anyhow::anyhow!("archive write task failed: {e}"))??; let mut seen = self.seen.lock().map_err(|_| anyhow::anyhow!("seen lock poisoned"))?; for key in keys { seen.insert(key); } } Ok(()) } } ``` Always use `tokio::task::spawn_blocking` for disk I/O inside the hook. Blocking calls on the async thread stall the entire receive pipeline, including the batch that is waiting to commit. ## See also * [Retry Admission Hook](/advanced/retry-admission) — the sibling opt-in hook idiom (`OnceLock`, zero overhead unset) for gating inbound group/status retry receipts. # Metrics with the metrics facade Source: https://whatsapp-rust.jlucaso.com/advanced/metrics Emit Prometheus and OTLP-ready wa_* counters, histograms, and gauges from whatsapp-rust with the opt-in metrics feature. ## Overview whatsapp-rust ships an optional `metrics` Cargo feature that emits `wa_*` counters, histograms, and gauges through the [`metrics`](https://docs.rs/metrics) facade. Spans from the [tracing feature](/advanced/observability) tell you the story of a single case; metrics give you the rates and latency percentiles you need for dashboards and alerts. The library only **emits** through the facade. It never installs a recorder and does not depend on Prometheus or OTLP — your application chooses the recorder and exposes the scrape endpoint. The `metrics` feature is **off by default**. With it disabled there is no `metrics` dependency, every emit is an inlined no-op, and the duration `Timer` is a zero-sized type that reads no clock. There is zero runtime cost. ## When to use it Turn on `metrics` when you want to: * Build Grafana, Datadog, or Honeycomb dashboards for connect success rate, IQ latency percentiles, retry receipts, send throughput, or app-state sync health. * Page on connection loss, identity-change spikes, or rising decrypt failure rates. * Compare aggregate behavior across deployments without enabling per-trace export. If you only need to investigate a single incident or trace, prefer the [tracing feature](/advanced/observability) — it is the lower-cardinality counterpart and is designed to work alongside metrics. ## Enabling the feature Add `whatsapp-rust` with the `metrics` feature, plus the recorder you want to expose. The example below uses Prometheus: ```toml Cargo.toml theme={null} [dependencies] whatsapp-rust = { version = "0.7", features = ["metrics"] } metrics = "0.24" metrics-exporter-prometheus = "0.16" ``` For OTLP, swap in `metrics-exporter-opentelemetry` (or any other recorder that implements the `metrics::Recorder` trait). ## Wiring a recorder Install the recorder once at startup, then build your `Client` as usual. A runnable version of this wiring ships as `examples/metrics.rs` in the source repo: ```rust src/main.rs theme={null} fn main() { // Install a Prometheus recorder. `install_recorder()` sets the global recorder // and returns a handle you can render from your own HTTP endpoint. Use // `PrometheusBuilder::install()` instead (inside a Tokio runtime) to serve // `/metrics` on 0.0.0.0:9000 automatically. let handle = metrics_exporter_prometheus::PrometheusBuilder::new() .install_recorder() .expect("install prometheus recorder"); // Optional: register units and help text for the wa_* metrics. whatsapp_rust::telemetry::describe(); // Build and run your `whatsapp_rust::Client` as usual; every wa_* metric is // recorded into the recorder above. // Serve `handle.render()` from your HTTP `/metrics` route. } ``` Run it with the feature on: ```bash theme={null} cargo run --example metrics --features metrics ``` ## Metric catalogue All metrics are prefixed with `wa_` and emitted at the same boundaries as the matching `wa.*` tracing spans. Counters and gauges carry the categorical breakdown; the matching duration histograms are unlabeled. ### Counters | Name | Labels | Description | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `wa_recv_total` | `outcome` = `decrypted`, `duplicate`, `duplicate_resend`, `undecryptable`, `skmsg` | Inbound messages by receive-processing outcome. Most values are decrypt outcomes; `duplicate_resend` is the one post-decrypt exception — it decrypts successfully but is then suppressed by the [dispatch-once gate](/concepts/events#messages) ([#1352](https://github.com/oxidezap/whatsapp-rust/pull/1352)), distinct from `duplicate`, the byte-identical redelivery the Signal ratchet itself rejects before decryption completes | | `wa_send_total` | `kind` = `dm`, `group`, `status` | Outgoing send attempts by kind | | `wa_retry_receipt_total` | `reason` | Retry receipts sent, by reason | | `wa_iq_total` | `result` = `ok`, `timeout`, `error` | IQ requests by result | | `wa_reconnect_total` | — | Reconnect attempts | | `wa_stream_error_total` | — | Stream errors received | | `wa_connect_total` | `outcome` = `ok`, `fail` | Connection attempts by outcome | | `wa_appstate_sync_total` | `outcome` = `ok`, `fail` | App-state collection syncs by outcome | | `wa_appstate_mutations_total` | — | App-state mutations applied | | `wa_identity_change_total` | — | Peer identity changes that triggered a session reset | | `wa_prekey_upload_total` | `outcome` = `ok`, `fail` | Pre-key uploads by outcome | | `wa_session_record_quarantined_total` | — | Stored session rows that failed to decode and were treated as absent for recovery. Steady state is zero — see [session row quarantine](/concepts/storage#signalstorecache) | | `wa_unkeyable_device_total` | `reason` = `no_bundle`, `session_setup`, `session_lookup`, `rejected_406`, `rejected_4xx`, `rejected_5xx`, `rejected_other`, `refused_batch`, `fetch_failed`, `encrypt` | Failed attempts to obtain key material for one device, by reason ([#1304](https://github.com/oxidezap/whatsapp-rust/pull/1304)) | `wa_unkeyable_device_total` is the rate to watch after a session-repair change, or to page on when a chat sits on "Waiting for this message". It is an attempt counter, not a per-delivered-device one — a retry that fails the same way counts again. `406` keeps its own label since it's the only code that changes client behavior. `refused_batch` is kept apart from a named `rejected_*` label because a batch refusal names no device. `stats()`'s [`devices_unkeyed_*` fields](/api/client#stats) carry the same totals without the per-code split. Both are process-wide: they say a device went unkeyed somewhere, never which message lost it. As of [#1362](https://github.com/oxidezap/whatsapp-rust/pull/1362), the per-message question is answered by [`SendResult::recipient_fanout`](/api/send#recipientfanout) instead — a DM's result carries how many recipient devices it addressed, how many actually encrypted, and whether the recipient's primary device was among the ones that didn't. ### Histograms (seconds) | Name | Description | | ----------------------------------- | ---------------------------------- | | `wa_iq_duration_seconds` | IQ request round-trip time | | `wa_connect_duration_seconds` | Connection establishment time | | `wa_decrypt_duration_seconds` | Inbound session-decrypt batch time | | `wa_send_duration_seconds` | Outgoing send time | | `wa_appstate_sync_duration_seconds` | App-state sync time | ### Gauges | Name | Description | | -------------- | ------------------------------------------------ | | `wa_connected` | `1` while the client is connected, `0` otherwise | ## Recording your own durations The same `Timer` the library uses internally is part of the public API. Hold the returned guard for the scope of the operation; it records elapsed seconds on drop: ```rust theme={null} use whatsapp_rust::telemetry; async fn do_iq() { let _t = telemetry::timer(telemetry::IQ_DURATION); // perform the IQ round-trip; the timer records on drop. } ``` ## PII and cardinality Labels are strictly low-cardinality categorical values (`outcome`, `kind`, `result`, `reason`). The library never uses a JID, phone number, or message ID as a label — that would explode the metrics backend and leak PII. Histograms are unlabeled; the matching `_total` counter carries the categorical breakdown. This guarantee only covers identifiers the library emits. If your own code records custom metrics with raw JIDs or phone numbers as labels, you will leak PII and blow up cardinality. Use stable categorical values for your own labels too. ## Overhead | Configuration | Cost | | --------------------------------- | -------------------------------------------------------------------- | | Feature off (default) | No dependency, every emit is an inlined no-op, `Timer` is zero-sized | | Feature on, no recorder installed | Near-zero (the facade short-circuits) | | Feature on, recorder installed | Pay per emit at the recorder's rate | Durations use the pluggable `wacore::time::Instant`, so WASM and deterministic builds are unaffected. ## Related * [Observability with tracing](/advanced/observability) — per-case spans that complement the aggregate metrics here. * [Installation — feature flags](/installation#feature-flags) — full feature matrix including `metrics`, `tracing`, and `tracing-pii`. # Observability with tracing Source: https://whatsapp-rust.jlucaso.com/advanced/observability Wire whatsapp-rust into tracing-subscriber or OpenTelemetry with the optional tracing feature, redacted JIDs, and a wa.* span taxonomy. ## Overview whatsapp-rust ships an optional `tracing` Cargo feature that instruments the library end-to-end: connect/disconnect, receive and decrypt, send, IQ, app state, pairing, media, receipts, retries, notifications, and session/crypto flows. With the feature on you can map a production error to **who** (which account), **where** (which span), **how** (the call path), and **why** (the failure attached to the span). The library only **emits** `tracing` spans and events. It never installs a subscriber and does not depend on OpenTelemetry — your application owns the subscriber, the filtering, and any OTLP/Jaeger exporter. The `tracing` feature is **off by default**. With it disabled there is no `tracing` dependency and the instrumentation attributes vanish at compile time, so there is zero runtime cost. ## Enabling the feature Add `whatsapp-rust` with the `tracing` feature, plus `tracing-subscriber` for the consumer side: ```toml Cargo.toml theme={null} [dependencies] whatsapp-rust = { version = "0.7", features = ["tracing"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } ``` The existing `log::{info,warn,error}!` calls inside the library continue to work. `tracing-subscriber`'s default `tracing-log` feature bridges them into the subscriber, so they appear as events attached to the active `wa.*` span — even before you adopt any new span yourself. Do **not** enable the `log` feature on the `tracing` crate together with the log → tracing bridge. That recurses. whatsapp-rust already pins `tracing` with `default-features = false` so the hazard cannot happen inside the library, but be careful when adding `tracing` to your own dependencies. ## Wiring a subscriber A minimal `tracing-subscriber` setup driven by `RUST_LOG`: ```rust src/main.rs theme={null} use tracing_subscriber::prelude::*; fn main() { let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| { tracing_subscriber::EnvFilter::new("info,whatsapp_rust=debug") }); tracing_subscriber::registry() .with(filter) .with(tracing_subscriber::fmt::layer()) .init(); // From here, build and run your `whatsapp_rust::Client` as usual. // All `wa.*` spans and bridged log events flow into the subscriber above. } ``` Run it with the feature on: ```bash theme={null} RUST_LOG="info,whatsapp_rust=debug" cargo run --features tracing ``` A runnable version of this wiring (with OpenTelemetry stubs) ships as `examples/observability.rs` in the source repo. ### OpenTelemetry / OTLP To export spans to an OTLP collector (Jaeger, Tempo, Honeycomb, etc.), add `opentelemetry`, `opentelemetry-otlp`, and `tracing-opentelemetry`, then append a layer to the subscriber: ```rust src/main.rs theme={null} use tracing_subscriber::prelude::*; let tracer = opentelemetry_otlp::new_pipeline() .tracing() .with_exporter(opentelemetry_otlp::new_exporter().tonic()) .install_batch(opentelemetry_sdk::runtime::Tokio)?; tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::from_default_env()) .with(tracing_subscriber::fmt::layer()) .with(tracing_opentelemetry::layer().with_tracer(tracer)) .init(); ``` Every `wa.*` span is then exported as an OTLP span with its fields intact. ## Span taxonomy Spans are grouped under a stable `wa..` naming scheme so you can filter or build dashboards per area. The current areas are: | Area | Covers | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `wa.conn.*` | Connect, disconnect, reconnect, handshake, frame decrypt, stream errors, plus debug-level entry/exit events for the read loop — the loop is not itself wrapped in a span (see [Levels](#levels) below); the keepalive loop is likewise uninstrumented as a whole, but logs its own per-condition debug/warn messages rather than a matching entry/exit pair | | `wa.recv.*` | Incoming message parsing and decrypt path | | `wa.send.*` | Outgoing send path (DM, group, peer, encryption) | | `wa.iq` | IQ request/response round-trips | | `wa.appstate.*` | App state sync, patch build/send, key requests | | `wa.pair.*` | QR code and pair code authentication | | `wa.media.*` | Upload, download, history sync, sticker packs, media conn refresh | | `wa.receipt.*` | Receipt processing (delivered, read, played) | | `wa.retry.*` | Retry receipt handling | | `wa.pdo.*` | Peer Data Operations (message recovery via primary device) | | `wa.notif.*` | Notification dispatch (group, devices, chatstate, identity change, privacy token) | | `wa.session.*` | Signal session establishment and crypto | | `wa.usync.*` | usync queries | | `wa.bot.*` | Bot builder, run loop, and `MessageContext` helpers (`send_message`, `react`, `edit_message`, `revoke_message`) | ### Levels Most spans are emitted at `debug` or `trace`. The connection-lifecycle spans (`wa.conn.connect`, `wa.conn.disconnect`, `wa.conn.reconnect`, `wa.conn.logout`) are at `info` so connection state is visible at the default level. Failures surface at `ERROR` via `err(Debug)` on the instrumented function, and the existing `warn!`/`error!` log calls surface through the bridge — with one exception: `wa.conn.connect` surfaces failures at `WARN` instead (its caller already classifies the real failures as `error!`, so the default `ERROR` was double-reporting transient handshake retries). **`wa.conn.read_loop` is no longer a span ([whatsapp-rust#1410](https://github.com/oxidezap/whatsapp-rust/pull/1410)).** The read loop runs for the whole connection — days, on a long-lived session — so a span wrapping it never exported until the disconnect, every per-frame span (`wa.conn.node`, `wa.conn.decrypt_frame`) hung off a parent that never closed, and a duration histogram over it measured uptime rather than work; `keepalive_loop` and `run` already went uninstrumented for the same reason (see the note below). `Client::read_messages_loop` now just logs `debug!` events at entry and at exit — the exit event names how the loop ended (expected disconnect, server-initiated stream recycle, or an error) — and the per-frame spans it drives keep their own timing untouched. There is no span wrapping the client's outer auto-reconnect loop (`Client::run`). A span there would live for the entire client lifetime and only report at shutdown, distorting duration/throughput dashboards the same way a whole-connection keepalive span would — so it is deliberately left uninstrumented. Connection-lifecycle visibility comes from the per-attempt spans above; account identity comes from the per-operation spans below. A downstream binary can statically strip lower levels at compile time with `tracing`'s `release_max_level_info` / `release_max_level_warn` features. ### Account identity in spans The `wa.conn.connect`, `wa.iq`, and `wa.send.message` spans carry `lid` and `pn` fields for your own account, so traces are filterable and groupable per account in multi-account deployments. `pn` is redacted via `Jid::observe()` like any other phone-number field (see [PII handling](#pii-handling) below); `lid` is rendered in full since it is pseudonymous. `wa.conn.read_loop` no longer exists as a span (see the note above) and its replacement debug events carry no identity fields — `wa.conn.connect`, entered once per connection, is where per-account filtering for the connection lifecycle now has to happen. To tag your own spans or error context with the same account identity, call `Client::identity_tags()` (available whenever the `tracing` feature is enabled on the `whatsapp-rust` dependency — no local feature of your own is required): ```rust theme={null} let tags = client.identity_tags(); tracing::info!(lid = tags.lid.as_deref(), pn = tags.pn.as_deref(), "custom event"); ``` `tracing`'s `Value` impl for `Option` records the field only when the value is `Some`, so a missing `lid` or `pn` is left absent on the event instead of printing as `None` or an empty string — matching how `wa.conn.connect` / `wa.iq` / `wa.send.message` behave internally, and keeping both tags on a single event instead of splitting them across two. `identity_tags()` returns an `IdentityTags { lid: Option, pn: Option }` snapshot — a named struct rather than a tuple, so LID/PN cannot be silently transposed at the call site. `pn` is already the redacted `pn#` form produced by `Jid::observe()`, not the raw phone number, so logging it directly — as in the example above — does not leak PII by default; `lid` is rendered in full since it is pseudonymous. This guarantee only holds while the `tracing-pii` feature stays disabled (the default) — with `tracing-pii` enabled, `identity_tags()` inherits `Jid::observe()`'s raw-number behavior like every other redacted value on this page, so `pn` becomes the actual phone number (see [`tracing-pii`](#tracing-pii-local-debugging-only) below). Both fields read from the same device snapshot used internally, so they stay consistent with what the library's own spans record. ### Filtering examples `RUST_LOG` accepts span/event targets the same way it accepts log targets: ```bash theme={null} # Default: info everywhere, debug for whatsapp-rust spans RUST_LOG="info,whatsapp_rust=debug" cargo run --features tracing # Only connection lifecycle and IQ traffic RUST_LOG="warn,whatsapp_rust[wa.conn]=debug,whatsapp_rust[wa.iq]=debug" cargo run --features tracing # Trace the send path RUST_LOG="info,whatsapp_rust[wa.send]=trace" cargo run --features tracing ``` ## PII handling WhatsApp identifiers contain phone numbers, so the library redacts them before they reach a span field or a log line. * **`Jid::observe()`** renders LID, group, broadcast, newsletter, and bot JIDs in full — they are pseudonymous or non-personal, so the same peer or chat still correlates across spans. Phone-number user JIDs are replaced with `pn#`, where the token is a keyed SipHash (the key is a process-lifetime random seed kept only in memory). An unkeyed hash of an E.164 number is reversible by precomputation; the keyed scheme is not. * **Legacy group IDs** of the form `-` keep the timestamp and redact only the numeric prefix. * **`observe_protocol_address()`** applies the same scheme to Signal `ProtocolAddress` names embedded in logs. * The library's own `log!` calls already pipe JIDs and addresses through these helpers, so the bridged log lines carry the same redaction as the span fields. * **Push names.** The library never includes the account's own display name in its log lines, since redaction doesn't apply to freeform text the way it does to JIDs. Pairing, app-state mutations, history sync, presence, and device-load logging report only that a push name changed (or was loaded), not the string itself ([#1345](https://github.com/oxidezap/whatsapp-rust/pull/1345)). Redaction only covers identifiers the library emits. Anything your own application code logs — raw JIDs, phone numbers, message bodies — reaches the exporter unredacted under your own targets. Scrub them with `Jid::observe()` (and `observe_protocol_address()` for Signal addresses) before logging. ### `tracing-pii` (local debugging only) For local debugging where you need to see raw phone numbers, enable the `tracing-pii` feature: ```bash theme={null} cargo run --features "tracing,tracing-pii" ``` This makes `Jid::observe()` and `observe_protocol_address()` render raw numbers instead of the `pn#` placeholder. **Never enable this in production.** ## Overhead | Configuration | Cost | | ----------------------------------- | ------------------------------------------------------------------- | | Feature off (default) | No dependency, attributes vanish at compile time, zero runtime cost | | Feature on, no subscriber installed | Near-zero (tracing's callsite caching) | | Feature on, subscriber installed | Pay per emitted span at your chosen level | Because spans are mostly `debug`/`trace`/`info`, a release build with `release_max_level_info` strips the rest without code changes. ## Related * [Metrics with the metrics facade](/advanced/metrics) — aggregate `wa_*` counters, histograms, and gauges that complement these per-case spans. * [Configuring log targets](/quickstart#configuring-log-targets) — `log`-based filtering that also works with the tracing bridge. * [Installation — feature flags](/installation#feature-flags) — full feature matrix including `tracing`, `tracing-pii`, and `metrics`. # Native plugins Source: https://whatsapp-rust.jlucaso.com/advanced/plugins Build type-safe, capability-scoped extensions for whatsapp-rust behind the opt-in plugins feature, with build-time registration and generation-scoped lifecycle ownership. ## Overview whatsapp-rust supports **native plugins**: build-time, type-safe extensions that get scoped access to core events, tasks, messaging, and IQ execution through a capability model, without touching the client's internals directly. This is a low-level extension seam. If you're writing an application that just sends and receives messages, use [`Bot`](/api/bot) directly — you don't need this page. Reach for native plugins when you're building a **reusable component** (metrics, a search index, a moderation layer) that should install once, observe events non-blockingly, own its own background tasks across reconnects, and expose a typed API to the rest of your application. The plugin host is intentionally native-only today: it is trusted in-process Rust code, not a sandboxed or dynamically-loaded extension model. There is no dynamic (`.so`/`.wasm`) loading and no pre-ack decision hook. Ingress interception — claiming a stanza before the built-in pipeline — is supported via the `PluginCapability::StanzaInterception` capability below. ## Enabling the feature Native plugins live behind the opt-in `plugins` feature, which also enables the lower-level `client-lifecycle` feature. `plugins` ships in the published crate as of **0.7.0** — no git source needed: ```toml Cargo.toml theme={null} [dependencies] whatsapp-rust = { version = "0.7", features = ["plugins"] } ``` A default build has neither plugin fields nor their runtime branches. If you're implementing a plugin directly in your application, enable `plugins` there. If you're publishing a reusable plugin crate, enable `plugins` on its own `whatsapp-rust` dependency instead — Cargo's [feature unification](https://doc.rust-lang.org/cargo/reference/features.html#feature-unification) then activates the host for any application that depends on your crate, so its `Cargo.toml` doesn't need to enable `plugins` itself. See [Installation — feature flags](/installation#feature-flags) for where `plugins` and `client-lifecycle` sit relative to the rest of the feature matrix. ## Defining a plugin A plugin implements `ClientPlugin`, chooses an associated `Api` type, and returns it from an async `install` hook: ```rust theme={null} use std::sync::Arc; use whatsapp_rust::anyhow::Result; use whatsapp_rust::{ClientPlugin, PluginContext, PluginFuture, PluginManifest}; struct SearchPlugin; struct SearchApi { // Clone capability handles or plugin-owned state here. } impl SearchApi { async fn search(&self, query: &str) -> Result> { // Plugin-specific behavior. Ok(vec![query.to_owned()]) } } impl ClientPlugin for SearchPlugin { type Api = SearchApi; fn manifest(&self) -> PluginManifest { PluginManifest::new("example.search", "0.1.0") } fn install(&self, _context: PluginContext) -> PluginFuture<'_, Result>> { Box::pin(async { Ok(Arc::new(SearchApi {})) }) } } ``` `PluginManifest` carries a stable string ID and a semver version. Duplicate IDs, duplicate marker types, malformed versions, and dependency cycles between plugins all fail before the client is assembled — not at first use. ## Registering plugins with `ClientBuilder` `ClientBuilder` is the canonical low-level construction path — `BotBuilder` delegates to the same path and remains the typestate-preserving facade for everyday bot code. Register plugins with `with_plugin` before calling `build`: ```rust theme={null} let client = Client::builder() // platform dependencies: backend, transport, http client, runtime... .with_plugin(SearchPlugin) .build() .await? .into_client(); let search: Arc = client .plugin::() .expect("search plugin is installed"); let matches = search.search("hello").await?; ``` `Client::plugin::

()` returns `Option>` — the registry is keyed by the `TypeId` of the plugin marker `P`, not by the API type, so two different plugins can expose the same `Api` type without colliding. The `Option` reflects that the installed plugin set is decided at runtime by whichever builder call assembled this particular client. `ClientBuilder` follows one publication boundary. It validates dependencies and plugin manifests, then resolves plugin dependencies topologically. It assembles an inert client, installs plugins while staging their APIs, and starts client services. Only then does it atomically activate lifecycle and plugin resources and publish the completed client. Tasks a plugin requests during installation stay parked until that final activation — nothing a plugin schedules can run against a client that hasn't finished construction. Installation is transactional: if a plugin's `install` fails, is cancelled, panics, or races a terminal shutdown, every already-staged plugin resource closes synchronously and any asynchronous shutdown hooks run in reverse installation order. A plugin is install-once for the lifetime of one `Client` — reconnecting never replaces the plugin instance or its exposed API. ### Runtime-defined plugins The typed `with_plugin` / `Client::plugin::

()` path assumes the plugin's Rust type is known at compile time. For an adapter that hosts a dynamic or runtime-defined set of extensions (for example, a future foreign-language bridge), implement `UntypedClientPlugin` instead and register instances with `with_untyped_plugin` (or `with_untyped_plugin_arc` to register a trait object). Untyped instances are keyed only by their manifest ID, may share a single concrete adapter type, and don't appear in `Client::plugin::

()`. ## Capabilities A plugin's manifest requests capabilities, and `PluginContext` only exposes the small handle for each one it was granted: | Capability | Handle | Grants | | -------------------------------------- | --------------------------------------- | -------------------------------------------------------------- | | `PluginCapability::CoreEvents` | `PluginCoreEvents` | Selective, non-blocking observation of sealed core events | | `PluginCapability::Tasks` | `PluginTasks` / `PluginConnectionTasks` | Runtime-agnostic, cancellation-tracked background work | | `PluginCapability::Messaging` | `PluginMessaging` | High-level message sends | | `PluginCapability::Iq` | `PluginIq` | Typed `IqSpec` execution | | `PluginCapability::PluginEvents` | `PluginEvents` | Publishing custom events, scoped to the plugin's own namespace | | `PluginCapability::StanzaInterception` | `PluginStanzaInterception` | Claiming a decoded stanza before the built-in pipeline sees it | This is API shaping, not a sandbox: a native plugin is trusted in-process Rust code and can use any dependency available to your build. It exists so a plugin's `install` signature documents what it actually touches, and so `PluginContext` never hands out the raw backend or Signal stores. Request a capability by chaining `with_capability` on the manifest, then retrieve its handle from the `PluginContext` passed into `install`. Extending `SearchPlugin` above to request `CoreEvents` means giving `SearchApi` a field for the handle and updating `manifest`/`install` to match: ```rust theme={null} use whatsapp_rust::{PluginCapability, PluginCoreEvents}; struct SearchApi { core_events: PluginCoreEvents, } impl ClientPlugin for SearchPlugin { type Api = SearchApi; fn manifest(&self) -> PluginManifest { PluginManifest::new("example.search", "0.1.0") .with_capability(PluginCapability::CoreEvents) } fn install(&self, context: PluginContext) -> PluginFuture<'_, Result>> { Box::pin(async move { let core_events = context .core_events() .cloned() .expect("CoreEvents capability was requested in the manifest above"); Ok(Arc::new(SearchApi { core_events })) }) } } ``` The other capabilities follow the same pattern: `context.tasks()`, `context.messaging()`, `context.iq()`, and `context.plugin_events()` each return `Option<&Plugin*>`, populated only when the matching `PluginCapability` was requested in `manifest()`. Capability handles hold a weak reference to the client internally and reject calls once the client has shut down, so a plugin API that outlives the client (for example, one your application keeps an `Arc` to) fails safely instead of resurrecting it. `PluginCoreEvents::subscribe` returns an RAII token for the registration. Dropping it, or explicitly unsubscribing, removes the handler immediately. `PluginStanzaInterception::register` (below) returns the same shape of token. Both are indexed weakly in the host, so terminal shutdown can invalidate a token a plugin API retained without extending the lifetime of one the plugin already released. ## Lifecycle and task scopes Plugin work is either **install-scoped** (runs once, for the life of the client) or **connection-scoped** (tied to one `connection_generation` — cancelled on every reconnect and every terminal shutdown): ```text theme={null} install once | +-- install-scoped tasks ---------------------------> terminal shutdown | +-- generation N: ready -> cancel -> closed +-- generation N+1: ready -> cancel -> closed ``` * `install` runs once while the client is still inert. * `on_ready` runs, in dependency order, after authentication succeeds for that connection generation. * A reconnect or terminal teardown cancels the current generation's scope synchronously; the host then waits (up to a configurable deadline) for tasks to drain before calling `on_closed`, in reverse dependency order. `PluginTasks::spawn` (install-scoped) and `PluginConnectionTasks::spawn` (generation-scoped) drop the spawned future outright when cancellation wins a race. Use `spawn_cooperative` instead when a task needs to wind down cleanly rather than being dropped mid-flight. A cooperative task must poll `shutdown_signal()` (install-scoped) or `cancellation_signal()` (generation-scoped) and return once it observes cancellation. The host still only waits up to its configured drain deadline for that task to finish. Past that deadline, the host proceeds anyway and marks the plugin degraded if the task hasn't returned. `PluginHostConfig` lets you tune the installation deadline (default 30s), and the per-callback and per-task-drain deadlines (default 5s each; none accept zero). Callbacks are serialized, bounded by their timeout, and isolated from panics — one misbehaving plugin's `on_ready`/`on_closed` panic doesn't suppress another plugin's callback. ## Custom events Plugins can publish and subscribe to their own events, without going through the sealed core `Event` enum. `PluginEventRouter` routes each event by an exact `(plugin_id, topic)` selector. Every subscriber gets its own independently bounded queue. `PluginEventEndpointConfig` sets that queue's overflow policy: either `DropNewest` or `DropOldest`. Each delivered envelope carries six fields: the plugin ID, the topic, a schema version, the payload bytes, the connection generation at publish time, and a route-local monotonic sequence number. A subscriber uses that sequence number to detect gaps left by dropped events. `PluginEventSubscription` is an RAII endpoint — dropping it removes all of that subscriber's selectors from the router at once. ## Stanza interception `PluginCapability::CoreEvents` lets a plugin *watch* the stream. `PluginCapability::StanzaInterception` lets it *act*: a plugin that models a stanza this client does not can claim it before the built-in pipeline sees it, instead of watching it get nacked. The underlying seam — `Client::add_stanza_interceptor`, the `StanzaInterceptor` trait, what is never offered, and the ack a claim still owes the server — is documented on [`add_stanza_interceptor`](/api/client#add_stanza_interceptor); this section covers what the plugin host adds on top of it. ```rust theme={null} use std::sync::Arc; use whatsapp_rust::{PluginCapability, PluginStanzaInterception}; use whatsapp_rust::client::interceptor::{Interception, StanzaInterceptor}; impl ClientPlugin for VendorPlugin { type Api = VendorApi; fn manifest(&self) -> PluginManifest { PluginManifest::new("example.vendor", "0.1.0") .with_capability(PluginCapability::StanzaInterception) } fn install(&self, context: PluginContext) -> PluginFuture<'_, Result>> { Box::pin(async move { let interception = context .stanza_interception() .cloned() .expect("StanzaInterception capability was requested in the manifest above"); let registration = interception.register(Arc::new(VendorInterceptor))?; Ok(Arc::new(VendorApi { registration })) }) } } ``` `PluginStanzaInterception::register` returns a `PluginInterceptorRegistration` — the same ownership-token shape `PluginCoreEvents::subscribe` returns. Dropping it unregisters; `unregister()` is the explicit form; the host indexes it weakly so terminal shutdown invalidates a token a plugin API retained. The host adds two things a directly registered interceptor doesn't get: * **Panic isolation.** `StanzaInterceptor` asks implementations not to panic, and a directly registered interceptor is trusted to honor that — the read loop calls it inline, so an unwind there takes the connection with it. A plugin is not trusted with that: one faulty plugin must not kill the connection. A panicking interceptor is counted (`stanza_interception_panics` in `PluginStats`), logged, and treated as `Interception::Pass` — the outcome of the plugin not being there — which also degrades the plugin's `PluginHealth`. * **Terminal invalidation.** Like every other plugin registration, a stanza interceptor is closed on shutdown, so a client that is shutting down is not still asking a plugin what to do with its stanzas. Interception runs before the built-in pipeline, so a claimed stanza is one the client did no Signal work on at all: nothing decrypted, no session mutated, no prekey consumed. There is no half-advanced state to reconcile — the property that makes claiming safe to reason about without a new durability contract. But it also means the claim is final: the ack that follows tells the server not to redeliver, so a claimed `` stays undecrypted forever and a claimed prekey-bearing `` never tops up prekeys. A plugin claiming stanzas that carry Signal state takes over that responsibility whole — match narrowly, and see [Signal durability](/advanced/signal-protocol) for what the responsibility involves. `Client::memory_report()`'s [`plugin_stanza_interceptors`](/api/client#memory_report) field folds in every plugin's active interceptor count. ## Diagnostics `Client::plugin_stats()` returns a `PluginStats` snapshot per installed plugin: lifecycle state (`PluginState`), sticky health (`PluginHealth`), callback failures, spawned-task panics, drain timeouts, active task scopes, subscription/publisher counters, and — since stanza interception — `stanza_interceptors` and `stanza_interception_panics`. `PluginEventRouter::stats()` and `PluginEvents::stats()` report queue depth and backpressure totals for custom events. `Client::memory_report()` folds in plugin resource accounting. These snapshots are on-demand and approximate under concurrency, and — matching the rest of the client's [observability](/advanced/observability) surface — never include JIDs, phone numbers, or message bodies. ## Reference examples The workspace ships two plugins built only against the same public API documented on this page, with no private access to the main crate — good starting points for structuring your own plugin crate: * `plugins/metrics` — a small conformance example: manifest, capability requests, an `Api` type, and lifecycle hooks. * `plugins/wam` — a larger, real-world one: it requests `CoreEvents`, `Tasks`, and `Iq`, owns a background flush/upload task across reconnects, and defines its own persistence trait rather than asking the host for one. See [WAM telemetry](/advanced/wam-telemetry). ## What's not supported yet The current host deliberately does not support dynamic plugin loading, pre-ack decisions on inbound stanzas, a foreign (non-Rust) wire protocol, or process isolation/sandboxing. These are real design commitments, not just missing features, and would only be added behind a concrete consumer need. Ingress interception (claiming a stanza, above) shipped once its interaction with Signal durability had a design — see the warning in [Stanza interception](#stanza-interception). ## See also * [Observability](/advanced/observability) — the `tracing` instrumentation that plugin diagnostics complement. * [Custom backends](/guides/custom-backends) — the other `ClientBuilder` seam, for swapping storage/transport/runtime instead of adding behavior. # Retry Admission Hook Source: https://whatsapp-rust.jlucaso.com/advanced/retry-admission Opt in to bounding a storm of inbound group/status retry receipts, without changing the SDK's default WhatsApp Web-compliant behavior. ## Overview WhatsApp Web has **no volume-based throttle on inbound retry receipts**. `WAWebHandleRetryRequest` serializes them per chat, refuses past `MAX_RETRY`, and otherwise processes every receipt — its gates are all semantic (`ALREADY_DELIVERED`, `CHANGED_IDENTITY`, `RECORD_MISSING`, `DEVICE_NOT_RECIPIENT`, `HIGH_RETRY_COUNT`, `MESSAGE_EXPIRED`), never "this member is asking too often." This SDK mirrors that: by default, every inbound `` runs the full repair path (`markForgetSenderKey`, key-bundle processing, resend). **The sender-key repair (`markForgetSenderKey`) no longer waits on finding the original message ([#1300](https://github.com/oxidezap/whatsapp-rust/pull/1300)).** A group/status send marks its *entire* distribution list as holding the sender key, including a device whose SKDM failed to encrypt. That makes the retry receipt's cold mark the only path back for that device. Before #1300, the mark ran behind the recent-message lookup, along with everything else downstream in the handler: the group-info fetch, the unknown-participant `rotateKey` block, key-bundle processing, and the resend itself. The lookup carries its own TTL (`sent_message_ttl_secs`, default 2h), and a genuine retry storm can outlive it — a device that did stayed stranded warm forever, even across restarts. #1300 moved only the cold mark ahead of the lookup. The group-info fetch, `rotateKey` block, and resend stayed gated on a lookup hit — and so, at the time, did key-bundle processing. [#1303](https://github.com/oxidezap/whatsapp-rust/pull/1303) later moved part of key-bundle processing ahead of the lookup too; see the next note. **Key-bundle installation now joins the cold mark ahead of the lookup, for every route except a DM ([#1303](https://github.com/oxidezap/whatsapp-rust/pull/1303)).** #1303 splits `update_local_signal_session` into two steps. `install_retry_key_bundle` runs the `processKeyBundle` step: it only reads keys the receipt already carries, with no network call and no deletion. `reconcile_retry_session` runs the reg-ID-mismatch session delete and the base-key bookkeeping; only the gated resend can undo either one. For `Group`, `Status`, and broadcast-list retries, `install_retry_key_bundle` now also runs before the recent-message lookup, immediately ahead of the cold mark: install first, then mark, so a device only reads warm once its session can actually carry the next SKDM. `reconcile_retry_session` still waits for the lookup on every route, since its branches either delete a session nothing else rebuilds or stamp a row keyed by the message ID. A DM's encryption JID stays unsettled until the lookup runs, because an alternate PN/LID hit can still rewrite it — so `Direct` retries keep both steps behind the lookup, unchanged from before #1303. **A primary device for which local SKDM encryption produced no node stopped being part of that "entire distribution list" marking, as of [#1328](https://github.com/oxidezap/whatsapp-rust/pull/1328).** This covers a *local* failure only — a failed pre-key fetch, a session-setup error — not a node that was sent but never reached or was never processed by the recipient; that case has no sender-side signal and still relies on this page's retry-receipt repair, same as before. The whole-set marking described above (`markHasSenderKey(x, M)`, unchanged) still applies to every *external participant's companion* device that was targeted — such a companion whose SKDM failed to encrypt is still marked keyed, and this page's retry-receipt cold mark is still the only path back for it. A **primary** device is now excluded from that marking when local encryption produced no SKDM for it, matching `getKeyDistributionMsg`'s `isPrimaryDevice` gate: WA Web can never reach the marking with a failed primary in the target set at all, because a primary's encryption failure rejects the entire send. This SDK's best-effort send carries on instead of failing the whole group over one member, so it enforces the same guarantee directly, by excluding that device from the marking rather than failing the send. Such a primary is retargeted on the very next send instead of depending on this page's retry-receipt repair — closing what had been a stall that, in the field report motivating this fix, only cleared once the affected member sent traffic of her own. A single-user WA Web client never sends at a volume where this matters. A bot in a large group can, if a cohort of members has pairwise sessions that never establish — each of those members retries on every message, driving the repair path at storm rate. Registering a `RetryAdmission` policy lets you bound that cost **without the SDK diverging from WhatsApp Web by default**. Leaving it unset keeps exact WA Web behavior, at zero cost — the check on the receive path is a single `OnceLock::get()`. This is an opt-in seam for bot-scale deployments. Dropping a retry receipt is a deliberate decision to skip an eligible repair request; the SDK will never do this on its own. ## Scope The policy is only consulted for **group and `status@broadcast`** retry receipts from **other accounts**. It is never consulted for: * Retries from your own companion devices (`is_peer`) — their session must always be able to rebuild. * Any 1:1 (DM) retry. The check runs immediately after the unknown-device (`hasDevice`) gate, before the sender-key repair and the rest of the handler. For an *unknown* device, note that `schedule_unknown_device_sync` (a device-list resync) queues **before** both the `hasDevice` gate and this admission check — a policy cannot bound that resync, only the repair work that follows it. A `false` from `admit` skips all repair work: the sender-key cold mark (`markForgetSenderKey`), key-bundle installation (`install_retry_key_bundle`, pre-lookup for the routes this policy covers), the recent-message lookup, the group-info fetch, the unknown-participant `rotateKey` block, session reconciliation (`reconcile_retry_session`, post-lookup), and the resend. The SDK does not queue a dropped receipt; the requester re-requests it on its own timer, so a policy should refill over time to keep genuine recovery possible. As of [#1300](https://github.com/oxidezap/whatsapp-rust/pull/1300) and [#1303](https://github.com/oxidezap/whatsapp-rust/pull/1303), `install_retry_key_bundle` and `mark_requester_for_fresh_skdm` (`markForgetSenderKey`) both run right after this admission check, ahead of the recent-message lookup, the group-info fetch, and the `rotateKey` block. The install step runs first; the cold mark runs last, so a device only reads warm once its session is actually installed. The admission check still gates all of that repair work — only the position of these two steps relative to the lookup changed. ## The `RetryAdmission` trait ```rust theme={null} pub trait RetryAdmission: wacore::sync_marker::MaybeSendSync { /// Return `true` to admit the retry receipt (WA Web behavior), `false` to /// drop it before any repair work runs. Must return promptly (no I/O, no /// blocking). fn admit(&self, chat: &Jid, requester: &Jid, retry_count: u8) -> bool; } ``` Object-safe and WASM-safe (`MaybeSendSync` is `Send + Sync` on native targets, unbounded on `wasm32`). | Parameter | Description | | ------------- | ------------------------------------------------------------------ | | `chat` | The group or `status@broadcast` JID the retry receipt was sent in. | | `requester` | The retrying participant's device JID. | | `retry_count` | The receipt's attempt number. | `admit` is called **inline on the receive path** and is deliberately synchronous — a slow or awaiting policy would stall retry processing for the pending key, and a gate never needs to wait. Keep it to a fast, local decision (an atomic counter, a token-bucket check); do no I/O or blocking inside it. The device is intentionally part of `requester`, but a policy may key on the user alone: WhatsApp Web re-targets a whole user's sender key when its primary device goes cold, so all devices of one broken account can reasonably share a single budget. `RetryAdmission` is re-exported from the crate root and from the `prelude`. A `RetryAdmission` policy that never admits another retry from a given `(chat, requester)` pair permanently excludes that device from SKDM distribution — this was already true before [#1300](https://github.com/oxidezap/whatsapp-rust/pull/1300). #1300 and [#1303](https://github.com/oxidezap/whatsapp-rust/pull/1303) both improve the policy's odds, not worsen them. Previously, a dropped receipt's eventual repair depended on a *later admitted* retry arriving before the original message aged out of the recent-message cache (`sent_message_ttl_secs`, default 2h). Since #1300, any later admitted retry repairs the sender key regardless of whether that message is still cached. Since #1303, that same admitted retry also installs the device's session from the receipt's own key bundle, not just the cold mark. A policy with a slow refill rate now recovers a fully working session more reliably than before, not less. ## Opting in ```rust theme={null} use whatsapp_rust::RetryAdmission; use whatsapp_rust::prelude::*; use std::sync::Arc; struct AlwaysAdmit; impl RetryAdmission for AlwaysAdmit { fn admit(&self, _chat: &Jid, _requester: &Jid, _retry_count: u8) -> bool { true } } let bot = Bot::builder() .with_backend(SqliteStore::new("whatsapp.db").await?) .build() .await?; // Register before connecting. Without this call the client keeps its // default behavior: every retry receipt admitted, matching WhatsApp Web. bot.client().set_retry_admission(Arc::new(AlwaysAdmit)); let handle = bot.run().await?; handle.await?; ``` `Client::set_retry_admission` returns `false` (and keeps the previously-registered policy) if called more than once — set it once, before connecting. Live tuning belongs inside the policy itself (e.g. atomics), not in re-registration. ## Example: token-bucket quarantine The repository ships `examples/retry_quarantine.rs`: a per-`(chat, requester)` token bucket, burst 2 / refill 2 per day, with a bounded keyspace (fails open — admits — once the tracked-pair cap is reached, rather than growing unbounded or blocking a brand-new pair). ```bash theme={null} cargo run --example retry_quarantine ``` Key points from that example: * **Keyed by user, not device** — `(chat.user, requester.user)`, matching the "one broken account, one budget" rationale above. * **Burst 2** repairs a healthy member immediately (one mark is enough — the next send already carries the SKDM); receipts past the burst from the same pair are dropped before any repair work. * **Refill 2/day** keeps genuine recovery possible for a member whose session is intermittently broken, without allowing a sustained storm. * **`burst = 0` disables the policy outright** (always admits), useful for a kill switch without unregistering. ## Design rationale This hook is a decoupled, WA Web-compliant alternative to embedding a quarantine directly in the SDK's default retry-receipt path: the core stays byte-for-byte WA Web, and any volume-based policy — including the exact mechanism above — lives in operator code via this trait instead of being on by default. ## See also * [Inbound Durability Hook](/advanced/inbound-durability) — the sibling opt-in hook idiom (`OnceLock`, zero overhead unset) that this trait follows. # Signal Protocol Implementation Source: https://whatsapp-rust.jlucaso.com/advanced/signal-protocol Deep dive into end-to-end encryption, Double Ratchet algorithm, and Signal Protocol in whatsapp-rust ## Overview whatsapp-rust implements the Signal Protocol for end-to-end encryption of both one-on-one and group messages. The implementation is based on Signal's libsignal library, adapted for WhatsApp's specific protocol requirements. The Signal Protocol implementation handles cryptographic primitives. Any modifications to this code require expert-level understanding of cryptographic protocols to avoid security vulnerabilities. ## Architecture The Signal Protocol implementation is split across two main locations: * **`wacore/libsignal/`** - Platform-agnostic Signal Protocol core (Rust port of libsignal) * **`src/store/signal*.rs`** - WhatsApp-specific storage integration with Diesel/SQLite ### Key Components ``` wacore/libsignal/src/ ├── protocol/ │ ├── session_cipher.rs # Encryption/decryption for 1:1 messages │ ├── group_cipher.rs # Encryption/decryption for group messages │ ├── ratchet.rs # Double Ratchet implementation │ ├── sender_keys.rs # Sender Key protocol for groups │ └── state/ # Session state management └── crypto/ ├── aes_cbc.rs # AES-256-CBC for message content ├── aes_gcm.rs # AES-GCM for media encryption └── hash.rs # HKDF and HMAC primitives ``` ## Double ratchet protocol The Double Ratchet algorithm provides forward secrecy and post-compromise security for 1:1 messages. ### Session Initialization Two participants initialize a session using Diffie-Hellman key exchange: ```rust theme={null} // Alice initiates the session (sender) pub fn initialize_alice_session( parameters: &AliceSignalProtocolParameters, csprng: &mut R, ) -> Result // Bob receives the session (recipient) pub fn initialize_bob_session( parameters: &BobSignalProtocolParameters ) -> Result ``` **Key Derivation:** 1. Compute shared secrets from ephemeral key exchanges 2. Derive root key and chain key using HKDF-SHA256: ``` HKDF(discontinuity_bytes || DH1 || DH2 || DH3 [|| DH4]) → (RootKey[32], ChainKey[32], PQRKey[32]) ``` 3. Initialize sender and receiver chains Location: `wacore/libsignal/src/protocol/ratchet.rs:41-172` ### Message Encryption Each message advances the sender chain and derives ephemeral message keys: ```rust theme={null} // From wacore/libsignal/src/protocol/session_cipher.rs:65-183 pub async fn message_encrypt( ptext: &[u8], remote_address: &ProtocolAddress, session_store: &mut dyn SessionStore, identity_store: &mut dyn IdentityKeyStore, ) -> Result ``` **Process:** 1. Load current session state 2. Get sender chain key and derive message keys: ```rust theme={null} let (message_keys_gen, next_chain_key) = chain_key.step_with_message_keys(); let message_keys = message_keys_gen.generate_keys(); // message_keys contains: cipher_key, mac_key, iv ``` 3. Encrypt plaintext with AES-256-CBC: ```rust theme={null} aes_256_cbc_encrypt_into(ptext, message_keys.cipher_key(), message_keys.iv(), &mut buf) ``` 4. Create SignalMessage with MAC for authentication 5. Advance chain key and save session state **Message Format:** * **SignalMessage**: Standard encrypted message * **PreKeySignalMessage**: Includes prekey bundle for session establishment **Plaintext padding.** Before encryption, the serialized `wa::Message` is padded with a uniform-random number of bytes in `1..=16` (the pad length is repeated as the byte value, matching WA Web's `rand % 16 + 1` and whatsmeow). v0.6 fixed a prior scheme that masked the length with `& 0x0F`, which skewed the distribution toward 15 and could never emit 16 — a subtle fingerprinting divergence from the official client. The receiver strips the padding by reading the final byte as the length. ### Message Decryption Decryption handles out-of-order delivery and tries multiple session states: ```rust theme={null} // From wacore/libsignal/src/protocol/session_cipher.rs:292-363 pub async fn message_decrypt_signal( ciphertext: &SignalMessage, remote_address: &ProtocolAddress, session_store: &mut dyn SessionStore, identity_store: &mut dyn IdentityKeyStore, csprng: &mut R, ) -> Result> ``` **Process:** 1. Try current session state first 2. If MAC verification fails, try previous (archived) sessions 3. Derive/retrieve message keys for the counter 4. Verify MAC: ```rust theme={null} ciphertext.verify_mac(&their_identity_key, &local_identity_key, message_keys.mac_key()) ``` 5. Decrypt with AES-256-CBC 6. Promote successful session to current if needed The implementation optimizes memory by using take/restore patterns to avoid cloning session states during decryption attempts (see `session_cipher.rs:495-619`). ### Chain key ratcheting Message keys are derived from chain keys, which advance with each message: ```rust theme={null} pub struct ChainKey { key: [u8; 32], index: u32, } impl ChainKey { pub fn step_with_message_keys(self) -> Result<(MessageKeyGenerator, ChainKey)> { let message_key_gen = MessageKeyGenerator::new(self.key, self.index); let next_chain_key = self.next_chain_key()?; Ok((message_key_gen, next_chain_key)) } } ``` Location: `wacore/libsignal/src/protocol/ratchet/keys.rs` ### Chain key overflow protection The chain key index is a `u32` that increments with each message. Without overflow protection, the index could silently wrap past `u32::MAX` (4,294,967,295) back to 0, creating a counter reuse vulnerability that breaks cryptographic guarantees (nonce reuse in message key derivation). Both 1:1 and group chain keys use `checked_add()` to return a typed error instead of wrapping: ```rust theme={null} // 1:1 chain keys (ratchet/keys.rs) pub fn next_chain_key(&self) -> crate::protocol::Result { Ok(Self { key: self.calculate_base_material(Self::CHAIN_KEY_SEED), index: self.index.checked_add(1).ok_or_else(|| { SignalProtocolError::InvalidState( "next_chain_key", "chain key index overflow (u32::MAX)".to_string(), ) })?, }) } // Group sender chain keys (sender_keys.rs) let new_iteration = self.iteration.checked_add(1).ok_or_else(|| { SignalProtocolError::InvalidState( "sender_chain_key_next", "Sender chain is too long".into(), ) })?; ``` A chain key reaching `u32::MAX` iterations indicates an abnormally long-lived session. In practice this should never occur — ratchet key rotations reset the chain counter with each new Diffie-Hellman exchange. Location: `wacore/libsignal/src/protocol/ratchet/keys.rs`, `wacore/libsignal/src/protocol/sender_keys.rs` ### Forward Jumps The protocol tolerates out-of-order messages up to a limit. Peer sessions and group sender-key chains match WhatsApp Web's `signalFutureMessagesMax`; a pairwise session with one of your **own** other devices gets a wider (but still bounded) ceiling, since multi-device app-sync legitimately jumps far ahead and the peer is trusted: ```rust theme={null} // wacore/libsignal/src/protocol/consts.rs pub const MAX_FORWARD_JUMPS: usize = 2_000; // peer sessions + group sender keys pub const MAX_FORWARD_JUMPS_SELF: usize = 25_000; // sessions with your own other devices // wacore/libsignal/src/protocol/session_cipher.rs const fn forward_jump_limit(is_self: bool) -> usize { if is_self { MAX_FORWARD_JUMPS_SELF } else { MAX_FORWARD_JUMPS } } if jump > forward_jump_limit(state.session_with_self()?) { return Err(SignalProtocolError::InvalidMessage( original_message_type, "message from too far into the future", )); } ``` Before this change, self-device sessions were exempt from the limit entirely (jumps beyond `MAX_FORWARD_JUMPS` were logged and allowed through). `MAX_FORWARD_JUMPS_SELF` (25,000) now bounds that path too — still wide enough for legitimate app-sync catch-up, but no longer unbounded. Peer sessions and group sender-key chains dropped from 25,000 to 2,000, matching WA Web's `signalFutureMessagesMax`; a message whose counter is more than 2,000 steps ahead is rejected (driving the retry-receipt path) instead of forcing thousands of KDF derivations per message. Location: `wacore/libsignal/src/protocol/consts.rs`, `wacore/libsignal/src/protocol/session_cipher.rs` ## DM device fanout When sending a direct message, the library resolves all known devices for both the recipient and your own account, then encrypts two different plaintexts for two categories of devices: * **Recipient devices** receive the actual message content * **Own other devices** (your other linked devices) receive a `DeviceSentMessage` wrapper containing the message plus the destination JID, so your other devices can display the sent message in the correct chat **Destination JID encoding via `DsmDestination` ([#1137](https://github.com/oxidezap/whatsapp-rust/pull/1137)).** The `DeviceSentMessage` wrapper writes its destination JID as a length-prefixed protobuf field, which needs the encoded length before the bytes. `wacore::messages::MessageUtils::encode_dm_plaintexts` and `dm_plaintexts_from_encoded` used to take `destination_jid: &str`, so the caller rendered the `Jid` into a `String` purely to measure and copy it. Both now take `impl DsmDestination` instead. `DsmDestination` is implemented directly on `Jid`, so it can measure and write its own wire form without an intermediate `String`. It's also implemented on `str` and the standard string wrappers (`String`, `Box`, `Rc`, `Arc`, `Cow`), carried through references of any depth via two blanket impls. The DM send path now passes `to_jid: &Jid` directly instead of `&to_jid.to_string()`. ### Device resolution The DM send path builds the full device list in a WA Web-compliant manner (matching `WAWebSendUserMsgJob` and `WAWebDBDeviceListFanout`): 1. **Local registry first** — the client checks the local device registry via `get_devices_from_registry()` for both the recipient and own account. A network fetch (`get_user_devices`) is only triggered on a cache miss, avoiding unnecessary LID-migration side effects. 2. **Hosted device filtering** — devices flagged as hosted (via `is_hosted()`) are filtered out, matching WA Web's `DBDeviceListFanout` exclusion. 3. **Sender device exclusion** — the exact sender device is removed from the list so `ensure_e2e_sessions` never creates a self-session. This matches WA Web's `isMeDevice` check in `getFanOutList`. 4. **Self-DM deduplication** — when sending to your own account, the recipient and own device lists overlap. A `HashSet`-based dedup pass (matching WA Web's `Map` keyed by `toString`) removes duplicates. ```rust theme={null} // Build device list — local registry first, network on miss let mut recipient_cached = self.get_devices_from_registry(&recipient_bare).await; if recipient_cached.is_none() { let _ = self.get_user_devices(std::slice::from_ref(&to)).await; recipient_cached = self.get_devices_from_registry(&recipient_bare).await; } // Filter hosted devices, exclude sender, dedup for self-DMs all_dm_jids.retain(|j| !j.is_hosted()); all_dm_jids.retain(|j| !is_sender); // HashSet dedup for self-DM overlap ``` **Per-recipient memoization ([#1118](https://github.com/oxidezap/whatsapp-rust/pull/1118)).** The steps above — the registry lookups, the list rebuild, the partition, and the phash — are memoized per recipient in `dm_devices_memo`, keyed by the resolved wire JID and validated against the device-topology generation and the sending identity (own PN/LID). A warm repeat send to the same chat reuses the stored `ResolvedDmDevices` (an `Arc`, so a hit is a refcount bump — nothing is cloned) instead of redoing this resolution. Any device add/remove/replace, registry invalidation, PN↔LID mapping change, or re-pair invalidates the entry through the same topology tracker the existing `group_devices_memo` already uses; a resolution that had to fall back (e.g. a registry lookup miss) is never memoized. Explicitly requesting network-fresh data bypasses the memo entirely. See `dm_devices_memo` and `group_devices_memo` in [`memory_report()`](/api/client#memory_report). ### Device partitioning The `partition_dm_devices` function classifies all resolved devices into recipient and own groups, and excludes the exact sender device (the current device) entirely. It partitions `all_devices` in place — swapping recipient devices to the front of the passed-in `Vec` — instead of allocating two new device vectors per send: ```rust theme={null} pub(crate) fn partition_dm_devices( all_devices: Vec, own_jid: &Jid, own_lid: Option<&Jid>, ) -> PartitionedDmDevices pub(crate) struct PartitionedDmDevices { devices: Vec, recipient_count: usize, } impl PartitionedDmDevices { // recipient + own, sender excluded pub(crate) fn valid_devices(&self) -> &[Jid] { &self.devices } // recipient devices only pub(crate) fn recipient_devices(&self) -> &[Jid] { &self.devices[..self.recipient_count] } // own non-sender devices only pub(crate) fn own_other_devices(&self) -> &[Jid] { &self.devices[self.recipient_count..] } } ``` Since [#1118](https://github.com/oxidezap/whatsapp-rust/pull/1118), `partition_dm_devices` runs once per `dm_devices_memo` entry — wrapped by `ResolvedDmDevices::new(all_devices, own_jid, own_lid)` — rather than on every send; see [`DmStanzaRequest` and `ResolvedDmDevices`](#dmstanzarequest-and-resolveddmdevices) below. ### Sender device exclusion The exact sender device is identified by matching both the user **and** device ID against your phone number JID (PN) or your Linked Identity JID (LID): ```rust theme={null} fn is_exact_dm_sender_device(device_jid: &Jid, own_jid: &Jid, own_lid: Option<&Jid>) -> bool { (device_jid.is_same_user_as(own_jid) && device_jid.device == own_jid.device) || own_lid.is_some_and(|lid| device_jid.is_same_user_as(lid) && device_jid.device == lid.device ) } ``` ### Own device recognition After excluding the sender device, the remaining devices are classified using `matches_user_or_lid`, which checks if a device JID belongs to the same user as either your PN or LID: ```rust theme={null} pub fn matches_user_or_lid(&self, user: &Jid, lid: Option<&Jid>) -> bool { self.is_same_user_as(user) || lid.is_some_and(|l| self.is_same_user_as(l)) } ``` This ensures that your own devices registered under your LID (common in multi-device setups) are correctly classified as "own" devices and receive the `DeviceSentMessage` plaintext — not the recipient plaintext. Without LID matching, your own LID-based devices would be misclassified as recipient devices, causing them to receive the wrong message format. Both PN-based and LID-based devices must be checked because WhatsApp's multi-device architecture uses both addressing schemes. A user's devices may appear under either their phone number JID (`@s.whatsapp.net`) or their Linked Identity JID (`@lid`), depending on the device type and registration path. ### DmStanzaRequest and ResolvedDmDevices `prepare_dm_stanza` takes a `DmStanzaRequest` whose `devices` field is the already-resolved, already-partitioned fan-out for the recipient — a borrowed `&ResolvedDmDevices` — rather than a raw device list: ```rust theme={null} pub struct DmStanzaRequest<'a> { pub own_jid: &'a Jid, pub account: Option<&'a wa::ADVSignedDeviceIdentity>, pub to: &'a Jid, pub message: &'a wa::Message, pub message_id: &'a str, pub edit: Option<&'a crate::types::message::EditAttribute>, pub extra_nodes: &'a [Node], /// The already-partitioned fan-out. Borrowed, not owned: the caller's /// per-recipient memo hands out the same `Arc` on every repeat send, so /// neither the device list nor its phash is rebuilt here. pub devices: &'a ResolvedDmDevices, pub pre_encoded: Option<&'a [u8]>, } ``` `ResolvedDmDevices` (`wacore::send::ResolvedDmDevices`) wraps the partitioned device set from `partition_dm_devices` together with a lazily memoized phash and, since [#1396](https://github.com/oxidezap/whatsapp-rust/pull/1396), a lazily memoized Signal addressing — both cached per entry in `dm_devices_memo`: ```rust theme={null} pub struct ResolvedDmDevices { /* partitioned devices + OnceLock phash + OnceLock */ } impl ResolvedDmDevices { /// Partitions `all_devices` into recipient devices and own companions, /// dropping the sending device itself. pub fn new(all_devices: Vec, own_jid: &Jid, own_lid: Option<&Jid>) -> Self; /// Every device the stanza encrypts for, in partition order. pub fn devices(&self) -> &[Jid]; /// The DM phash over the sent device set: a memo hit is an inline copy. pub fn phash(&self) -> Option; /// The memoized Signal addressing, if a send has resolved it yet. pub fn signal_addressing(&self) -> Option<&DmSignalAddressing>; /// Memoizes `addressing`, or hands back what an earlier send already /// memoized. Refused (and handed back to the caller) when `addressing` /// doesn't carry exactly one address per `devices()` entry. pub fn signal_addressing_or_init( &self, addressing: DmSignalAddressing, ) -> Result<&DmSignalAddressing, DmSignalAddressing>; } ``` **Memoized Signal addressing ([#1396](https://github.com/oxidezap/whatsapp-rust/pull/1396)).** A warm DM used to resolve each device's PN→LID mapping three separate times on its way out — once in the session preflight, once building the lock keys, once in the encrypt fan-out — each a cache lookup plus a `Jid` clone, with the lock keys re-sorted every send. `DmSignalAddressing` (`wacore::send::DmSignalAddressing`) now holds both results — `encryption()`, the per-device Signal address parallel to `devices()`, and `lock_keys()`, that same list already sorted and deduplicated in lock order — filled on the first send of a `dm_devices_memo` entry and served to every later one via `signal_addressing()`. Invalidation comes for free from the entry's own contract: a mapping learned for any member of the fan-out bumps the topology generation the entry is stamped with, so the entry — and this memo with it — is rebuilt rather than served stale. See "DM per-device locking" under [Single-allocation session lock keys](#single-allocation-session-lock-keys) below for where the resolution and the lock-key sort now happen only once. **Breaking change ([#1118](https://github.com/oxidezap/whatsapp-rust/pull/1118)):** `DmStanzaRequest::own_lid` was removed — the sending identity's LID is now baked into the `ResolvedDmDevices` passed via `devices` at construction time (`ResolvedDmDevices::new`) — and `DmStanzaRequest::devices` changed from `Vec` to `&ResolvedDmDevices`. An out-of-tree caller constructing `DmStanzaRequest` directly needs to build a `ResolvedDmDevices` first (via `ResolvedDmDevices::new(all_devices, own_jid, own_lid)`) and pass it by reference. ### Session preflight and the 406 case Before `prepare_dm_stanza` builds the stanza, `ensure_e2e_sessions` runs as a preflight that fetches prekeys for any resolved device without an established Signal session — one IQ covering up to `SESSION_CHECK_BATCH_SIZE` (50) devices. The server can reject devices two ways: **by name**, returning a `` node whose bundle is replaced with an `` for that one device, or **batch-wide**, failing the IQ itself with a `406` that names nobody. **Skips the PN→LID lookup when the caller already resolved it ([#1396](https://github.com/oxidezap/whatsapp-rust/pull/1396)).** `ensure_sessions_for_devices` (`wacore::send::encrypt`) is now a thin wrapper over `ensure_sessions_for_devices_resolved(..., signal_addresses: Option<&[Jid]>)`. Passing `None` reproduces the old per-device `SendContextResolver::get_lid_for_phone` lookup; the DM path instead passes `Some(addressing.encryption())` from the memoized `DmSignalAddressing` (see [`DmStanzaRequest` and `ResolvedDmDevices`](#dmstanzarequest-and-resolveddmdevices) above), so the preflight indexes straight into the pre-resolved address instead of hitting the mapping cache again. A `signal_addresses` list whose length doesn't match `devices` is treated as not supplied — the fallback is per-device resolution in every build, not a debug-only assertion. The group path, which has no such memo, still calls `ensure_sessions_for_devices` with `None` and is unaffected. **A named `406` rejection now refreshes only that device's user, not the whole batch ([#1153](https://github.com/oxidezap/whatsapp-rust/pull/1153), closes [#1143](https://github.com/oxidezap/whatsapp-rust/issues/1143)).** `PreKeyUtils::parse_prekeys_response` returns a `PreKeyFetchOutcome { bundles, rejected }` instead of a bare bundle map. `rejected` carries the devices the server named directly in the response body, each with the `` code it was rejected with. When one of those codes is `406`, the preflight calls `invalidate_device_caches_for` with the named device JIDs. That call dedupes the JIDs to their distinct users and refreshes each user's whole device-list cache entry, since the cache is keyed per user rather than per individual device. The send proceeds afterward, and every device that did return a bundle is unaffected — so one rejected device no longer costs the rest of the batch. This corrects the premise of [#1139](https://github.com/oxidezap/whatsapp-rust/pull/1139) (closed [#1135](https://github.com/oxidezap/whatsapp-rust/issues/1135)). #1139 assumed the response "doesn't say which one" and so invalidated every distinct user in the batch on any `406`. WA Web's own parser (`FetchKeyBundlesUserError` in `WAWeb/Fetch/PrekeysJob`) already reads this per-device error. whatsapp-rust's parser fed the same node to the bundle parser instead: it failed on the missing fields, logged the failure as a malformed bundle, and discarded the code. The device was skipped, but its stale entry was never refreshed, so the next send resolved the same absent device again. #1139's whole-batch invalidation and propagated `?` error still apply to the one case a named rejection cannot cover: a `406` on the IQ itself. The asymmetry #1139 described between the DM and group paths is gone — DM handled at the preflight, group already covered by the per-device fan-out (`stale_device_users`). `SendContextResolver::fetch_prekeys_for_identity_check` now returns `wacore::prekeys::PreKeyFetchOutcome` instead of `HashMap`. If you implement a custom `SendContextResolver`, update this method's return type to match. The new type carries a named rejection into the group fan-out's `EncryptResult::rejected_devices`, so `stale_users_for` refreshes exactly those users instead of inferring staleness from whichever targets went unencrypted for unrelated reasons (missing bundle, malformed bundle, failed session setup). Both paths now act on the same named-device signal when the server provides it. **"The send proceeds afterward" no longer holds when the named device is a primary ([#1362](https://github.com/oxidezap/whatsapp-rust/pull/1362)).** The description above still applies in full to a named `406` on a *companion* device. When the named device is a primary (device 0) — the recipient's or the sender's own — the preflight now fails the fetch instead of refreshing and continuing with zero established sessions for it: see [`SendError::PrimaryDeviceRejected`](/api/errors#senderror). **Concurrent preflights for the same address now coalesce into one fetch ([#1315](https://github.com/oxidezap/whatsapp-rust/pull/1315)).** The old pre-fetch existence probe in `ensure_e2e_sessions` couldn't deduplicate a burst of concurrent callers for the same protocol address (user + device). It answers before the IQ goes out, so every caller in the burst reads the same "no session" and fetches prekeys independently. Each returned bundle is then installed over the last. A burst could therefore leave several mutually incompatible session states behind — none matching the ratchet key the peer was actually encrypting under — and burn one of the peer's one-time prekeys per redundant fetch. `Client` now holds an `EnsureRegistry` that claims an address synchronously, before the first await. This splits a batch into addresses this call owns and addresses another in-flight call already claimed. The claiming caller (the "leader") fetches and installs as before; every other caller waits on it instead of fetching again. Release happens on `Drop`, so a cancelled or panicking leader still wakes its waiters. A waiter whose leader failed re-probes for itself, rather than reporting a session that was never established. This mirrors WA Web's own `ensureE2ESessions` wid-to-promise map in `WAWeb/Manage/E2ESessionsJob.js` — with one difference: a waiter doesn't inherit its leader's error directly, because `ErrorChainExt` inspects our errors and `anyhow::Error` isn't `Clone`. In-flight addresses are exposed as [`ensure_inflight`](/api/client#memory_report) on `Client::memory_report()`. It's normally zero, and holds an address only for the span of one prekey fetch. ### PreparedDmStanza `prepare_dm_stanza` returns a `PreparedDmStanza` struct containing the stanza node and the locally computed phash for server ACK validation: ```rust theme={null} pub struct PreparedDmStanza { pub node: Node, /// Locally computed phash from the sent device set. Not sent on the /// wire (WA Web only sends phash for groups). Used by the caller to /// compare against the server's ACK phash for device-list drift detection. pub phash: Option, } ``` `phash` changed from `Option` to `Option` (`wacore_binary::CompactString`) in [#1118](https://github.com/oxidezap/whatsapp-rust/pull/1118), matching the type `ResolvedDmDevices::phash()` returns: a warm memo hit clones the cached `CompactString` inline instead of allocating a new `String`. The phash is computed from the actual sent device set (after partitioning, with the sender excluded) using `MessageUtils::participant_list_hash()`. Unlike group messages, the DM phash is **not** sent on the wire — WA Web only includes `phash` in the `DeviceSentMessage` for groups. The DM phash is used purely for local validation against the server's ACK to detect device-list drift. The `DeviceSentMessage.phash` field is set to `None` for DMs, matching WA Web's behavior where only group `DeviceSentMessage` wrappers include a phash. The DM phash is computed and tracked separately by the caller. Location: `wacore/src/send.rs:675-820`, `src/send.rs` ## PN→LID session migration WhatsApp's multi-device architecture uses two addressing schemes: phone number JIDs (PN, `@s.whatsapp.net`) and Linked Identity JIDs (LID, `@lid`). WhatsApp Web always resolves PN→LID before any session operation via `createSignalAddress()`. whatsapp-rust mirrors this behavior — when a LID mapping is discovered for a phone number, any Signal sessions stored under the PN address are automatically migrated to the corresponding LID address. The automatic migration described below is also exposed for manual invocation: [`Signal::migrate_sessions(from, to)`](/api/signal#migrate_sessions) runs the same move for a caller-chosen JID pair, and [`Signal::session_info(jid)`](/api/signal#session_info) inspects a session (migrating a legacy PN-addressed one first if needed) without mutating it further. See the [Signal API reference](/api/signal) for both. ### Signal address resolution `Client::resolve_encryption_jid()` mirrors WA Web's `SignalAddress.toString()` (`WAWeb/Signal/Address.js`). It upgrades the JID's `server` to its LID counterpart when a mapping is known, and otherwise returns the input unchanged: | Input `Server` | Resolved `Server` (mapping known) | No mapping | | -------------- | --------------------------------- | -------------------- | | `Pn` | `Lid` | `Pn` (preserved) | | `Hosted` | `HostedLid` | `Hosted` (preserved) | | Any other | unchanged | unchanged | The `device`, `agent`, and `integrator` fields always round-trip — only the `user` (replaced with the LID user) and `server` change. This keeps Cloud API / Meta Business hosted devices on a hosted-flavored LID address rather than collapsing them into the standard `@lid` server, matching WA Web's per-device session keying. `resolve_encryption_jid()` upgrades PN → LID unconditionally whenever a mapping is known — it governs Signal **session** addressing only, matching WA Web's `SignalAddress.toString()`. The outbound DM **wire** namespace (the stanza `to`, ``, and `DeviceSentMessage` destination) is a separate, account-level decision — see [DM wire namespace vs. Signal session addressing](#dm-wire-namespace-vs-signal-session-addressing) below. ### DM wire namespace vs. Signal session addressing Since v0.6.x (fix for [#941](https://github.com/oxidezap/whatsapp-rust/issues/941)), a DM's outer `` / `` addressing is no longer derived directly from `resolve_encryption_jid()`. Some accounts are not yet **1:1-LID-migrated** on WhatsApp's servers, and those accounts get every LID-addressed DM rejected with `ack error="400"` even though the underlying Signal session is correctly LID-keyed. `Client::resolve_dm_wire_jid()` (`src/client/lid_pn.rs`) makes this account-level decision, mirroring WA Web's `Lid1X1MigrationUtils.isLidMigrated()` / `WAWebMessageDestinationChat`: ```rust theme={null} pub(crate) async fn resolve_dm_wire_jid(&self, to: &Jid) -> Jid { if self.is_lid_migrated().await { return self.resolve_encryption_jid(to).await.into_non_ad(); } let bare = to.to_non_ad(); if bare.is_lid() { self.swap_pn_lid_namespace(&bare).await.unwrap_or(bare) } else { bare } } ``` * **Migrated account** (`Client::is_lid_migrated()` is `true`): behaves exactly like before — the wire namespace upgrades PN → LID whenever a mapping is known. * **Unmigrated account**: DMs stay on PN even with a cached LID mapping. A caller-supplied LID with a known PN mapping is mapped back to the PN chat; a LID with no cached mapping is sent as-is (there is no reverse LID→PN network resolution, matching WA Web). `Client::is_lid_migrated()` is `true` when either is true: 1. The persisted `Device.lid_migrated` flag (see [Storage — DeviceStore](/concepts/storage#devicestore)), set once from the primary's `pair-success` `` (`isChatDbLidMigrated`) or from a `lid_migration_mapping_sync_message` protocol message pushed to the primary's own companions (self-only — see [Authentication — one-to-one LID migration state](/concepts/authentication#one-to-one-lid-migration-state)). 2. The `lid_one_on_one_migration_enabled` ab prop, as a fallback for sessions paired before the flag existed. The first observation of this prop being on also latches the persisted flag, so the account doesn't flap back to PN addressing before the next props fetch. Once set, `lid_migrated` never reverts for the same account — only pairing a *different* account onto the same store resets it. Signal session addressing (`resolve_encryption_jid`) and inbound decrypt are unaffected by any of this; only the outbound DM wire namespace is gated. This gate applies to 1:1 DMs only. Group sends, which already address everything by the group's own `AddressingMode`, are untouched. ### Why migration is needed After pairing, the primary phone may initially establish sessions under a PN address. Once the LID mapping becomes known (from usync, incoming messages, or device notifications), the phone begins sending from the LID address. Without migration, the client holds a session under the PN address but receives messages addressed to the LID — causing `SessionNotFound` decryption failures. ### Proactive migration at LID discovery When a new LID-PN mapping is learned (via `add_lid_pn_mapping`), the client scans devices 0–99 for PN-keyed sessions and migrates them. All reads and writes go through the `SignalStoreCache` rather than the backend directly — this prevents reading stale data when the cache has unflushed mutations (e.g., after SKDM encryption ratcheted the session). The migrated state is flushed to the backend at the end so it survives restarts. ```rust theme={null} // src/client/lid_pn.rs pub(crate) async fn migrate_signal_sessions_on_lid_discovery(&self, pn: &str, lid: &str) { for device_id in 0..=99u16 { // Read from signal_cache (authoritative over backend) // If PN session exists and no LID session → move session to LID via cache // If both exist → delete the stale PN session from cache // Identity keys are migrated independently of sessions } // Flush migrated state to backend so it survives restarts self.signal_cache.flush(backend.as_ref()).await; } ``` **Migration rules per device:** | PN session | LID session | Action | | -------------- | -------------- | ---------------------------------------------- | | Exists | Does not exist | Move session and identity from PN→LID address | | Exists | Exists | Delete stale PN session (LID takes precedence) | | Does not exist | Any | No action | Identity keys are migrated independently of sessions — they can outlive deleted sessions and survive session re-establishment. The migration reads through the cache because the backend may contain stale session data when unflushed cache mutations exist. Reading directly from the backend could skip in-flight ratchet advances, causing the migrated session to decrypt with an outdated chain key. `add_lid_pn_mapping` also has a batch form, `Client::add_lid_pn_mappings(mappings, source)`, which durably records many LID↔PN pairs in one call and runs the same per-mapping migration as the single-entry path. It returns how many mappings were actually written, deduplicated against existing records. ### On-the-fly migration during decryption If a message arrives from a LID address and decryption fails with `SessionNotFound` or `InvalidPreKeyId`, the client attempts PN→LID migration as a fallback before requesting a retry: 1. Look up the PN for the sender's LID 2. Attempt to migrate PN sessions to LID via the signal cache (same cache-first logic as proactive migration) 3. Retry decryption with the migrated session (already in the cache — no reload needed) 4. If `DuplicateMessage` occurs during post-migration retry, it is silently ignored 5. Fall back to retry receipt only if migration does not resolve the issue The `InvalidPreKeyId` case occurs when a `PreKeyMessage` references a consumed one-time prekey, but the session actually exists under a PN address (legacy migration). Migrating the session lets Signal use the existing ratchet state instead of looking up the consumed prekey. This migration is attempted in both the identity-change retry path and the initial decryption path. This ensures existing databases are fixed without requiring re-pairing. ### Login-time session check At login, the client checks the session state of own device 0 (primary phone): * **LID session exists** — no action needed * **PN session only** — logged; migration deferred to first message via on-the-fly path * **No session** — will be established on first message exchange ```rust theme={null} // src/client/sessions.rs pub(crate) async fn establish_primary_phone_session_immediate(&self) -> Result<()> { // Checks LID session → logs PN-only state → defers migration to message path } ``` Both migration paths route through the `SignalStoreCache`, ensuring they see the latest in-memory state. The proactive migration runs when a LID mapping is first discovered and flushes to the backend afterward. The on-the-fly migration handles the case where the database already contains stale PN sessions from before the mapping was known. Location: `src/client/lid_pn.rs`, `src/client/sessions.rs`, `src/message.rs` ## Sender keys (group encryption) Groups use the Sender Key protocol for efficient multi-recipient encryption. ### Sender key address normalization Sender key records are keyed by a composite `SenderKeyName` containing the group JID and a sender protocol address string. WhatsApp delivers group stanzas with **inconsistent sender addressing** — the `pkmsg` (which carries the SKDM) arrives with a device-qualified participant JID (e.g., `100000000000001.1:75@lid`), while the `skmsg` (the actual encrypted group message) arrives with a bare participant JID (e.g., `100000000000001.1@lid`). Without normalization, the sender key would be stored under the device-qualified address during SKDM processing but looked up under the bare address during `skmsg` decryption, causing `NoSenderKeyState` failures. The client normalizes the sender JID to its bare form using `to_non_ad()` (which strips the device component, setting `device = 0, agent = 0`) at every point where a `SenderKeyName` is constructed. The `SenderKeyName::from_jid()` convenience method handles the `to_string()` conversion automatically: ```rust theme={null} // Decryption path (src/message.rs) — normalize before group_decrypt let sender_for_sk = info.source.sender.to_non_ad(); let sender_address = sender_for_sk.to_protocol_address(); let sender_key_name = SenderKeyName::from_jid(&info.source.chat, &sender_address); // SKDM storage path (src/message.rs) — normalize before process_sender_key_distribution_message let sender_bare = sender_jid.to_non_ad(); let sender_address = sender_bare.to_protocol_address(); let sender_key_name = SenderKeyName::from_jid(&group_jid, &sender_address); ``` `SenderKeyName::from_jid()` is equivalent to `SenderKeyName::new(group_jid.to_string(), sender_address.to_string())` but avoids the manual `to_string()` calls and is the preferred constructor. This ensures the cache key is always in the form `"{group}:{bare_user}@{server}.0"`, regardless of whether the original stanza used a device-qualified or bare JID. Custom implementations that construct `SenderKeyName` directly must also normalize the sender JID to its bare form. Failing to do so will cause sender key lookup mismatches and decryption failures for group messages. Location: `src/message.rs`, `wacore/libsignal/src/store/sender_key_name.rs`, `wacore/binary/src/jid.rs` (`to_non_ad()`) ### Sender key distribution Each participant generates and distributes a sender key: ```rust theme={null} // From wacore/libsignal/src/protocol/group_cipher.rs:283-336 pub async fn create_sender_key_distribution_message( sender_key_name: &SenderKeyName, sender_key_store: &mut dyn SenderKeyStore, csprng: &mut R, ) -> Result ``` **Structure:** * **Chain ID**: Random 31-bit identifier for this sender key session * **Iteration**: Message counter (starts at 0) * **Chain Key**: 32-byte seed for deriving message keys * **Signing Key**: Ed25519 public key for message authentication ### Group Encryption Messages are encrypted with the sender's current chain key: ```rust theme={null} // From wacore/libsignal/src/protocol/group_cipher.rs:53-116 pub async fn group_encrypt( sender_key_store: &mut dyn SenderKeyStore, sender_key_name: &SenderKeyName, plaintext: &[u8], csprng: &mut R, ) -> Result ``` **Process:** 1. Load sender key state for the group 2. Derive message keys from current chain key 3. Encrypt with AES-256-CBC 4. Sign message with Ed25519 private key 5. Advance chain key ### Group Decryption Recipients decrypt using the sender's distributed key: ```rust theme={null} // From wacore/libsignal/src/protocol/group_cipher.rs:202-212 pub async fn group_decrypt( skm_bytes: &[u8], sender_key_store: &mut dyn SenderKeyStore, sender_key_name: &SenderKeyName, ) -> Result> ``` `group_decrypt` copies `skm_bytes` into an owned `Bytes` and forwards to `group_decrypt_shared`, which does the actual parsing and decryption: ```rust theme={null} // From wacore/libsignal/src/protocol/group_cipher.rs:218-223 pub async fn group_decrypt_shared( skm_bytes: Bytes, sender_key_store: &mut dyn SenderKeyStore, sender_key_name: &SenderKeyName, ) -> Result> ``` If you already hold the skmsg as `Bytes` — as the receive path does, slicing it straight out of the frame buffer — call `group_decrypt_shared` directly and skip that copy: `SenderKeyMessage` parses in place and keeps a reference-counted slice of your buffer as its `serialized` storage (via `SenderKeyMessage: TryFrom`) instead of allocating its own copy, the same technique `SignalMessage` already uses. **Process:** 1. Parse SenderKeyMessage 2. Look up sender key state by chain ID 3. Verify Ed25519 signature 4. Derive message keys for iteration (handling out-of-order) 5. Decrypt with AES-256-CBC into a plaintext buffer sized exactly to the ciphertext length Group decryption maintains up to MAX\_FORWARD\_JUMPS (2,000) cached message keys per sender. This prevents resource exhaustion attacks but limits tolerance for extreme out-of-order delivery. ### Unknown device detection During group message decryption, the client checks whether the sender's device is present in the local device registry via `is_from_known_device()`. This detection triggers in two places within the group message processing path: 1. **After successful `skmsg` decrypt** — if the sender device is not in the registry, the decrypted message is still **processed and delivered normally**. Signal decryption success already proves the sender holds a valid session key, so discarding the message would only add latency via an unnecessary retry round-trip. A background device sync is triggered to update the local device registry. 2. **After a `NoSenderKeyState` error** — if the sender device is unknown, the retry reason is upgraded from `NoSession` to `UnknownCompanionNoPrekey` In both cases, the client queues a device list synchronization for the sender's user JID. The behavior depends on the connection state: * **Online**: the client immediately invalidates the cached device registry for the user and fires a background usync request to refresh the device list * **Offline** (during offline sync): the unknown device's user JID is batched into a `PendingDeviceSync` set, which is flushed after offline sync completes (see [Deferred device sync](/concepts/architecture#deferred-device-sync)) Primary devices (device ID 0) are always treated as known — the check only applies to companion devices. This mechanism ensures that group messages from newly-paired companion devices are delivered immediately without waiting for a retry round-trip. The background device sync updates the local registry so future messages from the same device are recognized directly. ```rust theme={null} // src/message.rs — simplified flow async fn handle_unknown_device_sync(&self, info: &Arc) { let user_jid = info.source.sender.to_non_ad(); if !self.pending_device_sync.add(&user_jid) { return; // already queued, dedup } if info.is_offline { return; // batched for deferred flush } // Online: immediate sync self.invalidate_device_cache(&user_jid.user).await; self.get_user_devices(&[user_jid]).await.ok(); } ``` Location: `src/message.rs`, `src/client/device_registry.rs`, `src/pending_device_sync.rs` ### Retry receipt from unknown group device When the client receives a retry receipt, `handle_retry_receipt` checks whether the requesting device is present in the local device registry. Previously the handler dropped all retries from unregistered devices — this was safe for WA Web because WA Web keeps participant device lists fresh via a pre-send sync, so any legitimate requester is already known before the send. For a library client, a participant device can legitimately be absent from the local registry: if the device joined between the last device-list sync and the group send, it will have received the `skmsg` from the server but never obtained a sender key, causing it to retry indefinitely. The retry receipt may carry a `` bundle — the ADV-signed `device-identity`, the identity key, a one-time prekey, and the signed prekey — which is everything needed to establish a Signal session and resend. But a newly-linked device that has no bundle still retries forever if the client only drops it: the reconciliation that fires when a prekey fetch returns 406 never triggers for that device because it was never in the send set. Whenever a retry arrives from an unknown device, `handle_retry_receipt` now calls `schedule_unknown_device_sync` **before** consulting `should_drop_unknown_device_retry`. This treats the retry as a staleness signal: the requester's user JID is enqueued for a device-list resync (deduplicated via `PendingDeviceSync`, so a retry storm from a single device cannot fan out into a usync storm). Once the resync completes, the device appears in the registry and future sends include it in the sender-key distribution — the retries stop. This mirrors WA Web's `syncDeviceListJob` trigger on the retry path. The drop predicate still controls whether the *current* retry is recovered or dropped: ```rust theme={null} // wacore/src/protocol/retry.rs pub fn should_drop_unknown_device_retry(keys_present: bool, device_known: bool) -> bool { !keys_present && !device_known } ``` | `keys_present` | `device_known` | Result | | -------------- | -------------- | ----------------------------------------------------------------------------------------------------------------- | | `true` | `false` | **Recover** — build a session from the embedded bundle and resend; resync also triggered | | `false` | `false` | **Drop** — no bundle to recover this message; device-list resync triggered so device is learned for the next send | | any | `true` | **Resend** — device is in registry, proceed normally | When the bundle includes a ``, `process_retry_key_bundle` validates the ADV chain against the requester's account key (using the stored primary identity as a fallback when the server omits `account_signature_key`). A present-but-invalid ADV result is a hard error; the session is not built. If `` is absent from the bundle, or if no account key can be found, the check is skipped with a warning and the session is built anyway — matching the behaviour of the regular prekey-fetch path. The drop predicate only gates on syntactic `` presence, so the ADV guarantee is conditional on the bundle including a well-formed ``. This mirrors whatsmeow's approach of building the prekey session directly from the receipt bundle without a device-registry gate. Location: `src/retry.rs`, `wacore/src/protocol/retry.rs`, `src/pending_device_sync.rs` ### Immutable sender key loading The `SenderKeyStore` trait's `load_sender_key` method takes `&self` (not `&mut self`), allowing sender key lookups to proceed under a **read lock**. This is safe because loading a sender key is a pure read operation — no state is mutated. The `store_sender_key` method still requires `&mut self` since it modifies state. This means concurrent group decryptions for different senders can load sender keys in parallel without contention, while writes (SKDM processing) still serialize correctly. If you implement `SenderKeyStore` for a custom backend, `load_sender_key` must use `&self` (immutable reference). Implementations that previously required `&mut self` for internal caching should use interior mutability (e.g., `Mutex` or `RwLock`) instead. ### Sender key existence check Before distributing sender keys, the group message path checks whether the local sender key already exists. This check uses the `SignalStoreCache` with a **read lock** (`get_sender_key()`), matching the status broadcast path. This avoids acquiring a write lock and prevents unnecessary SKDM re-distribution on every group send. ### Per-device sender key tracking To avoid resending Sender Key Distribution Messages on every group message, the client tracks sender key distribution status **per device** for each group. This uses a unified `sender_key_devices` table (see [Storage - ProtocolStore](/concepts/storage#protocolstore)) that matches WhatsApp Web's `participant.senderKey Map` model — a single boolean per device per group indicating whether that device has a valid sender key (`true`) or needs fresh SKDM distribution (`false`). The tracking update is **deferred until after the server acknowledges** the message stanza. This matches WhatsApp Web's behavior where `markHasSenderKey()` is only called after the server confirms receipt. **Why deferred?** If the tracking were updated immediately after building the stanza (but before sending), a network failure between stanza build and send would leave stale entries — devices would be marked as having the sender key when they never actually received it. Subsequent messages would skip SKDM for those devices, causing decryption failures. **`PreparedGroupStanza` return value:** `prepare_group_stanza` returns a `PreparedGroupStanza` struct containing the stanza `node` and a `skdm_devices: Vec` field: the devices this send reports as holding the sender key, so the post-ACK warm mark below can use it directly. This is the *distribution target set*, not literally "devices that received SKDM" — it marks the whole list a send meant to key (matching WA Web `markHasSenderKey(x, M)`), including an external participant's companion device whose SKDM encryption failed, so a transient per-device failure doesn't force a re-fanout on every later send. (Your own companion devices are a separate case — see the `has_key=true` exclusion under ["Own devices are never marked `has_key=true`"](#per-device-sender-key-tracking) below.) It eliminates the need for callers to re-resolve devices after sending, closing a race window where the device list could change between stanza preparation and post-ACK tracking update. ```rust theme={null} pub struct PreparedGroupStanza { pub node: Node, /// The devices this send reports as holding the sender key — the /// distribution target set, not just the ones SKDM-encryption actually /// succeeded for. Empty when no distribution occurred. pub skdm_devices: Vec, /// The phash on the stanza, so the caller can compare it against the /// one the server echoes back on the ack. pub phash: Option, } ``` **A primary device for which local SKDM encryption produced no node is excluded from `skdm_devices`, as of [#1328](https://github.com/oxidezap/whatsapp-rust/pull/1328).** This covers a *local* failure only — a failed pre-key fetch, a session-setup error — not a node that was sent but never reached or was never processed by the recipient; the sender has no signal for that case, and it still relies on a retry receipt same as before. The whole-target-set marking above is unchanged for an *external participant's companion* device — matching `getKeyDistributionMsg`'s `isPrimaryDevice` gate, WA Web can never reach that marking with a failed **primary** in the set at all, because a primary's encryption failure rejects the entire send outright. This SDK's best-effort send carries on instead of failing the group over one member, so it enforces the same guarantee directly: `retain_reportable_sender_key_devices` filters a primary with no local SKDM node out of `skdm_devices` before it reaches the warm mark. Reporting such a primary as warm would hide that member's whole user behind the incremental-targeting diff above until that member's own retry receipt corrects it — in the field report that motivated this fix, a closed group's low message volume meant that correction could take a long time to arrive. The same PR keeps a companion the usync response cannot validate (no `signedKeyIndexBytes`) from dropping its primary out of the resolved participant set entirely, at the device-list projection layer — see [USync](/api/usync). **Implementation:** * **Group path:** After `send_node()` succeeds, the caller uses the `skdm_devices` list from `PreparedGroupStanza` to call `set_sender_key_status(group, devices, true)`. No re-resolution needed. * **Status path:** A late-init boolean tracks whether full distribution occurred. The sender key tracking is only updated after the status stanza is successfully sent. * **Error recovery:** If `prepare_group_stanza` fails with `NoSenderKeyState`, all sender key device tracking for that group is cleared and the send is retried with full distribution. * **Sender key rotation:** On `rotateKey`, the Signal sender key is also deleted for forward secrecy (matching WhatsApp Web's `deleteGroupSenderKeyInfo`), and all device tracking is cleared via `reset_sender_key_device_tracking` — a DB-first clear with a cold-mark fallback (see below). * **Group `` notification (number/LID migration):** A `w:gp2` `` notification (a participant's number or LID changed) unconditionally force-rotates the own group sender key and invalidates both the persisted and in-memory group metadata cache, matching WhatsApp Web's `modifyParticipantInfo` (`rotateKey: true`). The next send regenerates and redistributes a fresh sender key against the current participant list instead of risking a stale entry for the migrated device. See `Client::force_rotate_own_sender_key`, `src/handlers/notification/groups.rs`. * **Admin revoke:** When you send an admin revoke (`RevokeType::Admin`), the client treats it as an ordinary group message for distribution purposes. It uses the same incremental-targeting diff described above, own-device exception included, instead of a message-type-specific override ([#1278](https://github.com/oxidezap/whatsapp-rust/pull/1278)). Earlier versions forced full redistribution on every admin revoke, on the mistaken assumption that the server required it. On a warm group, this could turn a small revoke payload into one `` node per device. A cold group (no cached key, or mid-rotation) still distributes to every device, same as a cold ordinary send. **Incremental targeting:** Rather than distributing the sender key to all group devices on every message, the client: 1. Loads the per-device sender key map — first checking the in-memory cache, falling back to the database via `get_sender_key_devices` 2. Resolves all current group participant devices 3. Computes the diff — only devices with `has_key=false` or not yet tracked receive the SKDM 4. Passes the targeted device list to `prepare_group_stanza` via the `skdm_target_devices` parameter On the **first** group send (or any send where the cached map is empty), the filter still runs unconditionally — every resolved participant device is treated as `has_key=false` and receives the SKDM. This matches WhatsApp Web, which iterates an empty `senderKey` Map as `false` per participant. There is no early-exit for an empty cache; otherwise the very first message after a fresh start would skip distribution entirely. **Own devices are never marked `has_key=true` ([#999](https://github.com/oxidezap/whatsapp-rust/pull/999)).** The post-ACK warm mark excludes the account's own companion devices, matching WhatsApp Web's `!isMeDevice` guard on `markHasSenderKey`. They therefore never leave the "not yet tracked" bucket above and are re-included as SKDM targets on every send — see the follow-up note under ["Parallelized group encrypt fan-out"](#parallelized-group-encrypt-fan-out) for why. External group members are unaffected: a successful distribution still marks them warm. Location: `src/send.rs`, `src/client/sender_keys.rs`, `wacore/src/send.rs` ### Parallelized group encrypt fan-out The group send path no longer serializes encryption behind a client-level lock. `prepare_group_stanza` and `encrypt_for_devices` now take an explicit `&runtime` handle (`&*self.runtime`) so per-device encryption can run on `runtime::blocking()` tasks concurrently. Combined with the move to `update_device_lists` (batched device-registry writes) and a no-lock `IdentityAdapter::is_trusted_identity` stub, group fan-out scales with the runtime's worker count rather than with a single critical section. This is an internal performance change — no public method on `Client::send_message` was renamed, and the order of `` children in the resulting stanza is unchanged. If you implemented a custom `SignalStore`, note that `update_device_lists(records: Vec)` is now part of the trait so the fan-out can batch its writes. While *per-device* encryption runs concurrently, the sender-key chain is protected by **two separate locks per `(group, sender)` pair**: 1. **Session-setup lock** (`SenderKeyStore::session_setup_lock`) — held only across `ensure_sessions_for_devices` (prekey fetch + X3DH). May span network I/O. Warm sends (no SKDM needed) never take it, so they are never blocked by a cold send's network round-trip. 2. **Chain lock** (`SenderKeyStore::sender_key_lock`) — held across SKDM creation + pairwise encrypt fan-out + `skmsg` encrypt. Pure CPU; never spans network I/O. This is the invariant that prevents two concurrent sends from splitting the key between the SKDM and the `skmsg`. Prior to [#807](https://github.com/oxidezap/whatsapp-rust/pull/807), a single chain lock covered both phases, causing concurrent group sends to serialize behind a server round-trip whenever a new session needed to be established. Now only the CPU phase is in the critical section. Different groups (or different senders) encrypt fully in parallel, unchanged. `encrypt_for_devices` is composed of two public halves: `ensure_sessions_for_devices` (network, returns `SessionPlan`) and `encrypt_for_devices_with_sessions` (CPU, consumes `SessionPlan`). The DM path calls `encrypt_for_devices` unchanged; the group path calls them separately with the chain lock taken only around the second. **Per-device session lock around the SKDM fan-out (v0.6).** The chain lock above only serializes the sender-key chain — it does not cover the *pairwise* Signal sessions that `encrypt_for_devices_with_sessions` mutates for each SKDM target device. Those are the same pairwise sessions the DM path locks (see "DM per-device locking" under [Single-allocation session lock keys](#single-allocation-session-lock-keys) below) via `session_lock_for()` / `session_guards_for()`. Before [#990](https://github.com/oxidezap/whatsapp-rust/pull/990), the group fan-out held only the chain lock, a disjoint key, so a concurrent DM (or another group send) sharing a device could race that device's pairwise ratchet — both sides load chain index *N* and both store *N+1*, silently dropping one advance. When the lost advance carried the SKDM, that member never received the sender key and every subsequent `skmsg` stayed undecryptable for it until a retry re-distributed. `prepare_group_stanza` now acquires the SKDM targets' per-device session locks through `SendContextResolver::lock_device_sessions()` before taking the chain lock, and releases them right after the fan-out — the `skmsg` chain encrypt that follows only touches the sender-key chain, never a pairwise session. The `Client` implementation of this hook reuses `build_session_lock_keys()` + `session_guards_for()`, so the group and DM paths serialize on the exact same mutexes, in the same sorted order, and always acquire session locks before the chain lock — no path takes the reverse order, so this cannot deadlock. The hook defaults to a no-op, so a custom `SendContextResolver` (as used in tests and benches) is unaffected unless it opts in. **Session-setup failures are isolated per device (v0.6).** `ensure_sessions_for_devices` used to abort with `Err` the moment `process_prekey_bundle` failed for *any* one target device. Since `prepare_group_stanza` gates the entire SKDM fan-out on `session_plan.is_some()`, one device's X3DH failure nulled the plan and **every** device in the cohort — not just the failing one — got no SKDM, even though the `skmsg` still shipped and the phash covered the full set. An external member recovers via a retry receipt, but an own companion's retry hits `mark_forget_sender_key` with `exclude_own_devices=true`, which filters own-user JIDs and returns early — so that companion stayed `has_key=true` forever and couldn't decrypt the group from that device until an unrelated full rotation (participant removal or PN↔LID migration). As of [#996](https://github.com/oxidezap/whatsapp-rust/pull/996), a device whose session setup fails is logged and skipped rather than aborting the plan — matching WhatsApp Web's `GroupKeyDistributionMsg`, which wraps each device's `ensureE2ESessions` in its own try/catch and drops only the failing one. The sessionless device is then naturally excluded by the encrypt fan-out (which already skips devices without a session), so every other device still receives its pairwise SKDM. [#996](https://github.com/oxidezap/whatsapp-rust/pull/996) closed the primary harm — an *unrelated* device's setup failure no longer suppresses the whole cohort's SKDM. A narrower window remained: the **warm mark** (`update_sender_key_devices`, called after the server ACK) recorded the *full* distribution target as `has_key=true`, including our own companion devices, regardless of whether each one's pairwise SKDM encryption actually succeeded. Since the forget path (`mark_forget_sender_key`) excludes own devices for the reason above, an own companion whose one SKDM encryption failed — or that was warm-marked without ever receiving a node — was marked warm and could **never** be un-marked: a permanent orphan until an unrelated full rotation. External devices didn't have this problem; they recover through the retry-receipt forget path. [#999](https://github.com/oxidezap/whatsapp-rust/pull/999) closes this residual by excluding own devices from the warm mark too (`exclude_own_devices=true`), mirroring WhatsApp Web's `ParticipantStore` helper, which guards *both* `markHasSenderKey` and `markForgetSenderKey` with the same `!isMeDevice` check. Own companions are therefore never memoized as `has_key=true` — `filter_skdm_targets` (["Per-device sender key tracking → Incremental targeting"](#per-device-sender-key-tracking) above) always re-includes them, so they get a fresh SKDM on every group send. This is a deliberate trade-off (a few extra pairwise SKDM nodes per send when the account has companions) in exchange for making the orphan impossible. External devices are unaffected: a successful distribution still marks them warm, and the retry-receipt path still repairs any that go stale. **The group distribution lane now guards the full audit-reset-redistribute sequence, not just the SKDM fan-out ([#1043](https://github.com/oxidezap/whatsapp-rust/pull/1043)).** Previously `Client::group_distribution_lock()` (see ["Parallelized group encrypt fan-out"](#parallelized-group-encrypt-fan-out) above) was taken only around the cold SKDM send itself. Sender-key deletion (participant-removal rotation, forced own-key rotation), per-device tracker resets, and the status-broadcast distribution path could run concurrently with that lock held elsewhere, letting an encrypt racing a rotation restore a retired key after deletion, or a tracker reset race stale delivery marks back onto a new chain. `rotate_sender_key_on_participant_remove`, `force_rotate_own_sender_key` (now taking `&Jid` instead of a pre-stringified group ID), warm group sends, status sends, phash-mismatch recovery, and periodic sender-key rotation all now hold the same per-group lane across their own-key delete/reset and the following redistribution. A rotation that arrives while a send is mid-fan-out waits for the lane instead of deleting the chain state out from under it; a send that arrives mid-rotation waits for the rotation to finish before re-auditing device state. Held lanes are never capacity-evicted, so a live rotation or fan-out cannot be silently dropped from the map mid-operation (see `group_distribution_locks_capacity` in the [Cache Configuration reference](/api/bot#cache-configuration-reference)). **Sender-key tracker resets are DB-first ([#1043](https://github.com/oxidezap/whatsapp-rust/pull/1043)).** `reset_sender_key_device_tracking` replaces the old direct `clear_sender_key_devices` + cache-invalidate call at every rotation and redistribution site. It clears the per-device tracking row-by-row in the database first, and only invalidates the in-memory `SenderKeyDeviceCache` after that durable clear succeeds. If the DB clear fails, every existing tracked row is instead marked cold (`has_key=false`) as a fallback so the next send still re-distributes; if that fallback write also fails, the operation returns an error and the send stays fail-closed rather than risking a stale `has_key=true` row surviving onto a freshly rotated chain. The unknown-participant rotation in [retry receipt handling](/advanced/retry-admission) is a special case: `handle_retry_receipt` deletes the own sender key and resets tracking for a `` from an unrecognized group participant, then must still fall through to the per-chat resend rate limiter and other throttles further down the same function. The signal cache is now explicitly flushed right after the rotation — before any later throttle can return early — so a rotation is never left un-persisted by an unrelated early exit later in the same call. **Observability: distribution-lane pressure is exposed on `memory_report()` ([#1043](https://github.com/oxidezap/whatsapp-rust/pull/1043)).** `Client::memory_report()` now reports `group_distribution_locks` (live lane count), `group_distribution_lock_evictions` (cumulative cold evictions), and `group_distribution_lock_eviction_blocks` (cumulative evictions skipped because the lane was live) — see [`memory_report()`](/api/client#memory_report). These update only under capacity pressure and add no allocation or per-message cost below the soft cap. ### In-memory sender key device cache The `SenderKeyDeviceCache` provides an in-memory caching layer over the per-device sender key tracking data stored in the database. Without this cache, every group send would require a database round-trip to load the sender key device map — the cache eliminates that overhead after the first load for each group. ```rust theme={null} pub(crate) struct SenderKeyDeviceCache { inner: Cache>, } ``` **Key design decisions:** * **Time-to-idle eviction:** The cache uses TTI semantics (default: 1 hour, 500 entries), so entries for inactive groups are automatically evicted while frequently-used groups stay cached * **Pre-parsed, pre-indexed maps:** Database rows are parsed into a `SenderKeyDeviceMap` struct that provides O(1) lookups by user and device ID, avoiding per-query string parsing * **Single-flight initialization:** The `get_or_init` method uses `PortableCache`'s single-flight `get_with` — if multiple concurrent group sends for the same group trigger a cache miss simultaneously, only one database read executes and all callers share the result * **Explicit invalidation:** The cache is invalidated when sender key state changes (rotation, error recovery, retry failures) so stale data is never served ```rust theme={null} // Atomic get-or-init: concurrent callers for the same group // share the single database read result let cached_map = self .sender_key_device_cache .get_or_init(group_jid, async { let db_rows = pm.get_sender_key_devices(group_jid).await.unwrap_or_default(); Arc::new(SenderKeyDeviceMap::from_db_rows(&db_rows)) }) .await; ``` **`SenderKeyDeviceMap` structure:** The `SenderKeyDeviceMap` pre-parses JID strings from the database into a user-to-devices HashMap for efficient lookup: ```rust theme={null} pub(crate) struct SenderKeyDeviceMap { /// user → (device_id → has_key) devices: HashMap, HashMap>, /// Users with at least one has_key=false device forgotten_users: HashSet>, } ``` **Cache invalidation points:** | Event | Action | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Sender key rotation (`rotateKey`) | Invalidate group entry | | `NoSenderKeyState` error during send | Invalidate group entry | | Retry failure for a group message | Invalidate group entry | | Server rejects group stanza | Invalidate group entry | | New device added (`patch_device_add`) | Invalidate all entries | | Device removed (`patch_device_remove`) | Invalidate all entries | | Identity change (`clear_device_record`) | No global tracker wipe — per-device SKDM redistribution is driven by retry receipts (`markForgetSenderKey`), matching WhatsApp Web's `WAWebUpdateLocalSignalSession`. The `status@broadcast` sender key is still deleted for forward secrecy. | You can tune the cache capacity and TTI via the `sender_key_devices_cache` field in [`CacheConfig`](/api/bot#cache-configuration-reference). Location: `src/sender_key_device_cache.rs`, `src/send.rs` ### Phash validation for stale device list detection When sending group, status, or DM messages, the library validates the participant hash (`phash`) returned in the server's acknowledgment against the locally computed `phash`. A mismatch indicates that the server's view of participant devices differs from the client's — meaning the local device list is stale. **How it works:** 1. Before sending, the client obtains the locally computed `phash` — from the stanza `phash` attribute for group/status messages, or from `PreparedDmStanza.phash` for DMs 2. A `PhashWaiter` (expected hash, target JID, whether to also invalidate the group cache) is registered for the message ID via `register_phash_waiter` — a map entry, not a channel or a task 3. The message stanza is sent to the server 4. When the server's ack for that message ID arrives, the read loop compares its `phash` attribute against the expected value inline, with no task involved 5. On a match the entry is just dropped; on a mismatch the client spawns a task to invalidate caches — so a send only pays for a task in the uncommon case, not on every send ([#1116](https://github.com/oxidezap/whatsapp-rust/pull/1116)) **Group sends did not register a phash waiter at all until [#1328](https://github.com/oxidezap/whatsapp-rust/pull/1328).** `prepare_group_stanza` always computed and attached the phash to the outgoing stanza, but the field the send path used to carry a phash back out for ack comparison was hardcoded to `None` on the group branch — so step 4 above only ever ran for DM and status sends. A group whose participant device set diverged from the server's had no way to detect it short of a member's own retry receipt, which in a closed group never arrives on its own: nothing else there generates inbound traffic. `PreparedGroupStanza` now carries the stanza's `phash`, and the group send path forwards it the same way the DM branch already did. **On mismatch, the following happens:** | Send path | Sender key device tracking | Group/status metadata cache | Device registry | | --------------- | --------------------------------------- | ------------------------------------------------------ | -------------------------------------- | | Group messages | Not reset | Invalidated (participants re-queried on the next send) | — | | Status messages | Reset (forces full SKDM redistribution) | — | — | | DM messages | — | — | Recipient + own PN devices invalidated | **A disagreeing group phash no longer resets sender-key tracking ([#1328](https://github.com/oxidezap/whatsapp-rust/pull/1328)).** Status broadcasts still clear their sender-key tracking on mismatch, falling back to deleting the bot's own sender key if the clear fails — but a group takes neither arm, dropping only its cached metadata. This mirrors WA Web's `resendGroupMsg`, whose group branch is `sendQueryGroup` alone: no `markForgetSenderKey`, no device-table write. Resetting a whole group's sender-key tracker on every mismatch would cost a full SKDM fan-out per message for as long as the divergence lasted — a bigger regression than the mismatch itself. The re-queried metadata is what lets the next send resolve participants fresh instead. For DM messages, the phash covers both recipient and own devices (matching WA Web's `syncDeviceListJob([recipient, me])`). On mismatch, the client invalidates the device registry cache for both the recipient's user JID and your own phone number (PN) JID, ensuring the next send re-fetches the current device list for both parties. **A DM phash mismatch now repairs the message itself, not just the caches ([#1362](https://github.com/oxidezap/whatsapp-rust/pull/1362)).** After invalidating the device registry, the client re-resolves the recipient's device list with a forced refresh. It compares the refreshed list against the set the original stanza actually covered (`PhashWaiter::dm_devices`, an `Arc` shared with the send's own memo entry — a refcount bump, not a copy). Any device the refreshed list holds and the original send did not is retransmitted the message pairwise, under the *original* message id — the same shape as WA Web's `resendUserMsg` job with an `excludeList` of devices that already have a copy. Each of these devices receives the message for the first time; a device the original stanza already covered is left untouched, so this never delivers a duplicate to the same device. This only ever adds devices: a device the refresh *dropped* already received its copy and needs nothing further. The retransmission is direct (no group-metadata lookup) and registers no phash waiter of its own, so it cannot loop. ```rust theme={null} // src/send.rs — simplified phash validation flow // Each branch populates SendBranchOutput::ack_phash from its own prepared // stanza — group and DM both read it off `prepared.phash` (the group phash // is also on the wire; the DM phash is computed locally only). This is what // the ack is later compared against. let branch_output = SendBranchOutput { ack_phash: prepared.phash, // ...other fields }; // On DM phash mismatch: if !jid.is_group() && !jid.is_status_broadcast() { client.invalidate_device_cache(&jid.user).await; if let Some(own_pn) = &client.persistence_manager.get_device_snapshot().pn { client.invalidate_device_cache(&own_pn.user).await; } } ``` The phash check never blocks the send path. If the server's ack never arrives, nothing polls the waiter directly — it is swept out on the keepalive tick, and is guaranteed to survive the sweep immediately following its registration (so a waiter is never dropped mid-flight) but is removed on the sweep after that. Since each keepalive tick lands 15–30 seconds after the last, the actual time-to-live is roughly one to two tick intervals — about 15 to 60 seconds, depending on where registration falls relative to the sweep cycle — rather than the old fixed 10-second timeout. The sweep runs before keepalive's own idle early-return, so a connection with steady inbound traffic — which skips sending pings — still gets its stale waiters cleared; a stranded waiter would otherwise read as an outstanding IQ and suppress pings for the life of the connection. This matches WhatsApp Web's approach of using phash as a best-effort staleness detector rather than a hard requirement. #### WA Web phash parity (v0.6) Two corrections aligned the group phash with WA Web's `phashV2`: * **Full device set, every send.** The group phash is now computed over the *complete* resolved participant device set plus the sending device on every send — not just the devices that received an SKDM in that stanza. Warm sends (which distribute no new SKDM) now pass the full resolved set via the `all_devices_for_phash` parameter to `prepare_group_stanza`, so the phash matches the server's view even when the SKDM target set is empty. Status broadcasts keep their prior phash behavior. * **Standard base64 alphabet.** The phash now encodes with the standard base64 alphabet (`+` / `/`) instead of URL-safe (`-` / `_`), matching WA Web and whatsmeow. The client also now **persists group metadata** locally after a query and sends the stored participant phash on the next group query, letting the server answer `not-modified` (304) when membership is unchanged — saving a full metadata round-trip. Location: `src/send.rs`, `src/client.rs` ## Cryptographic Primitives ### AES-256-CBC (message content) Used for encrypting message bodies in both 1:1 and group messages: ```rust theme={null} pub fn aes_256_cbc_encrypt_into( plaintext: &[u8], key: &[u8], // 32 bytes iv: &[u8], // 16 bytes output: &mut Vec, ) -> Result<()> ``` Location: `wacore/libsignal/src/crypto/aes_cbc.rs` ### Thread-Local Buffers The implementation uses thread-local buffers to reduce allocations: ```rust theme={null} thread_local! { static ENCRYPTION_BUFFER: RefCell = ...; static DECRYPTION_BUFFER: RefCell = ...; } // Usage in session_cipher.rs:99-111 let ctext = ENCRYPTION_BUFFER.with(|buffer| { let mut buf_wrapper = buffer.borrow_mut(); let buf = buf_wrapper.get_buffer(); aes_256_cbc_encrypt_into(ptext, message_keys.cipher_key(), message_keys.iv(), buf)?; let result = std::mem::take(buf); buf.reserve(EncryptionBuffer::INITIAL_CAPACITY); Ok::, SignalProtocolError>(result) })?; ``` Location: `wacore/libsignal/src/protocol/session_cipher.rs:14-54` ### HKDF-SHA256 Used for key derivation in session initialization: ```rust theme={null} pub fn derive_keys(secret_input: &[u8]) -> (RootKey, ChainKey, InitialPQRKey) { let mut secrets = [0; 96]; hkdf::Hkdf::::new(None, secret_input) .expand(b"WhisperText", &mut secrets) .expect("valid length"); // Split into RootKey[32], ChainKey[32], PQRKey[32] } ``` Location: `wacore/libsignal/src/protocol/ratchet.rs:18-39` ### X25519 key agreement `calculate_agreement` runs during session setup, and again whenever the session performs a DH ratchet step — when an incoming message carries a remote ratchet key the local session hasn't chained on yet. `get_or_create_chain_key` calls `RootKey::create_chain` once to derive the new receiving chain; `DeferredSenderRatchet::apply` then calls it a second time, with a freshly generated local key, to derive the new sending chain. Each `create_chain` call performs exactly one agreement, so a single DH ratchet step costs two. Messages within an already-open chain advance via `ChainKey::step_with_message_keys` instead, which costs none. ```rust theme={null} pub fn calculate_agreement(&self, their_key: &PublicKey) -> Result<[u8; 32], CurveError> ``` Location: `wacore/libsignal/src/core/curve.rs` As of PR #1218, `calculate_agreement` routes through `SignalCryptoProvider::x25519_agreement`. AES-256-CBC above uses the same pluggable crypto-provider hook; HKDF-SHA256 does not — it always calls `hkdf::Hkdf` directly. Override the hook with `set_crypto_provider` (`wacore/libsignal/src/crypto/provider.rs`) to run the agreement on another backend. The default is this crate's own implementation, and it cannot fail. If you install a backend that can refuse the operation, you see the refusal as `CurveError::AgreementFailed`. Through `SignalProtocolError`, it reaches you as `KeyAgreementFailed`. The decrypt path treats a refusal as a local failure, not message corruption: it takes priority over the MAC-based verdicts (`InvalidMessage`, `BadMac`). Call `set_crypto_provider` before any crypto call, key agreement included. The provider installs once — a call made after the default provider has already initialized returns an error instead of replacing it. ## PreKey Management Pre-keys enable asynchronous session establishment in the Signal Protocol. whatsapp-rust manages pre-key generation and upload to match WhatsApp Web's behavior. ### Configuration The per-batch upload count is configurable through the builder/factory API (default `812`, matching WhatsApp Web's `UPLOAD_KEYS_COUNT`). The upload-trigger threshold is a private constant. | Setting | Default | Description | | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------- | | [`BotBuilder::with_wanted_pre_key_count`](/api/bot#with-wanted-pre-key-count) / [`Client::set_wanted_pre_key_count`](/api/client#set-wanted-pre-key-count) | 812 | Number of pre-keys generated and uploaded per batch. Clamped to `5..=65_535` at upload time. | | `MIN_PRE_KEY_COUNT` (private const) | 5 | Minimum server-side pre-key count before triggering an upload. | ```rust theme={null} // Via the Bot builder Bot::builder() .with_wanted_pre_key_count(256) // smaller batches for embedded consumers // ... // Or directly on a Client constructed by hand (before connect) client.set_wanted_pre_key_count(256); ``` Values outside `[5, 65_535]` are clamped at upload time (an out-of-range value logs a `warn!`). The floor avoids an empty-but-flagged pool or a re-upload loop. The ceiling is the wire-format limit: the upload IQ encodes the pre-key list length as a `u16`, so a larger batch would generate keys locally and then fail to encode. Per-key X25519 generation and protobuf encoding for the batch are offloaded via `wacore::runtime::blocking` (runtime-agnostic; runs inline on wasm) since the caller-controlled batch size can be large. ### Pre-key ID counter and wrap-around Pre-key IDs use a persistent monotonic counter (`Device::next_pre_key_id`) that only increases, matching WhatsApp Web's `NEXT_PK_ID` pattern: ```rust theme={null} // Determine starting ID using both the persistent counter AND the store max let max_id = backend.get_max_prekey_id().await?; let start_id = if device_snapshot.next_pre_key_id > 0 { std::cmp::max(device_snapshot.next_pre_key_id, max_id + 1) } else { // Migration: start from MAX(key_id) + 1 max_id + 1 }; ``` This approach prevents ID collisions when pre-keys are consumed non-sequentially from the store. **24-bit wrap-around:** WhatsApp Web uses 24-bit pre-key IDs on the wire (3-byte big-endian), so valid IDs range from 1 to 16,777,215 (2^24 − 1). When the persistent counter grows past this boundary, modular arithmetic wraps IDs back into the valid range: ```rust theme={null} const MAX_PREKEY_ID: u32 = 16777215; // 2^24 - 1 // Wrap start ID into valid [1, MAX_PREKEY_ID] range let start_id = ((raw_start as u64 - 1) % MAX_PREKEY_ID as u64) as u32 + 1; // Each key ID in the batch is also wrapped let pre_key_id = (((start_id as u64 - 1) + i as u64) % (MAX_PREKEY_ID as u64)) as u32 + 1; // After upload, the persisted next_pre_key_id wraps too let next_id = (((start_id as u64 - 1) + key_pairs_to_upload.len() as u64) % (MAX_PREKEY_ID as u64)) as u32 + 1; ``` If the counter wraps while unconsumed high-ID pre-keys still exist in the store, the database upsert (`ON CONFLICT DO UPDATE`) silently overwrites them. This is an accepted trade-off because the server consumes keys well before a full 16M cycle completes. Location: `src/prekeys.rs` ### Retry-receipt prekey marking When building a retry receipt that includes keys (`should_include_keys`), the one-time prekey handed directly to the peer is now also marked uploaded via `mark_single_prekey_uploaded`, matching WhatsApp Web's `markKeyAsUploaded`. Without this, the same prekey ID could be re-offered to the server pool in the next batch upload — a third party fetching the bundle could then consume the identical one-time ID and fail to decrypt. `mark_single_prekey_uploaded` requires a held `prekey_upload_lock` guard (a compile-time proof, not just a runtime convention) so the get-or-gen and the watermark write are atomic against the batch upload path. It only advances `first_unupload_pre_key_id` when the id being marked is still the current window head (idempotent no-op otherwise), and collapses `next_pre_key_id` onto the wrapped low watermark only when marking the terminal id at the 24-bit edge — a non-terminal high-end head keeps its surviving window key. The device account is validated **before** the prekey is reserved/marked, so a missing account fails the retry-receipt build without silently abandoning a one-time prekey from the upload window. Location: `src/prekeys.rs`, `src/retry.rs` ### Force-refreshing pre-keys for device migration When migrating a device from an external source (e.g., a Baileys session into an `InMemoryBackend`), the server may still hold pre-key IDs whose private key material you cannot reconstruct. Any `pkmsg` referencing those IDs will fail permanently with `InvalidPreKeyId`. The public `refresh_pre_keys()` method force-uploads a fresh batch of `Client::wanted_pre_key_count()` pre-keys (default 812; tunable via [`with_wanted_pre_key_count`](/api/bot#with-wanted-pre-key-count) / [`set_wanted_pre_key_count`](/api/client#set-wanted-pre-key-count)), giving the server new IDs the caller has locally. Old unmatched IDs drain naturally as peers consume them. ```rust theme={null} // After restoring a session from another library client.refresh_pre_keys().await?; ``` Internally, this acquires `prekey_upload_lock` to prevent races with the count-based and digest-repair upload paths, then calls `upload_pre_keys_with_retry(force: true)` which uses Fibonacci backoff (1s, 2s, 3s, 5s, 8s, ... capped at 610s). Two related public methods build on the same `prekey_upload_lock`-guarded path: * `Client::refresh_pre_keys_with_count(count)` — same force-upload as `refresh_pre_keys()`, but with a caller-chosen batch size instead of the configured [`wanted_pre_key_count`](#configuration). * `Client::ensure_pre_keys()` — a non-forced check-and-top-up: uploads only if the server-side pool is below the low-water mark, rather than unconditionally replacing it. Location: `src/prekeys.rs:263-266` ### Digest key validation After connection, the client validates that the server's copy of the key bundle matches local keys. This matches WhatsApp Web's `WAWebDigestKeyJob.digestKey()` flow. **Wire format:** ```xml theme={null} [4-byte BE registration ID] [1-byte: 5] [32-byte identity public key] [3-byte BE signed pre-key ID] [32-byte signed pre-key public] [64-byte signature] [3-byte BE prekey ID] ... [20-byte SHA-1 hash] ``` **Validation process:** 1. Query the server for the key bundle digest via `DigestKeyBundleSpec` 2. If the server returns **404** (no record), trigger a full pre-key re-upload 3. If the server returns **406/503** or other errors, log and skip 4. On success, compare registration IDs 5. Load each pre-key referenced by the server and extract its public key 6. Compute a local SHA-1 digest over: identity public key + signed pre-key public + signed pre-key signature + all pre-key public keys 7. Compare the local hash against the server-provided hash The `` node contains `` children (not `` children). The parser iterates all children of `` without tag filtering, matching WhatsApp Web's `mapChildren` behavior which does not filter by tag name. Hash mismatches or missing local pre-keys are logged but do **not** trigger a re-upload. Only a 404 response (server has no record) triggers re-upload. This matches WhatsApp Web's behavior where `validateLocalKeyBundle` exceptions are caught without re-uploading — the normal [`RotateKeyJob`](#signed-pre-key-rotation-rotatekeyjob) eventually refreshes the signed pre-key. `Client::validate_digest_key()` is a public method — callers can trigger this validation pass on demand instead of only relying on the automatic post-connection check. Location: `src/prekeys.rs:218-344`, `wacore/src/iq/prekeys.rs:170-302` ### Signed pre-key rotation (RotateKeyJob) The signed pre-key minted at pairing was otherwise **permanent** — a forward-secrecy gap. whatsapp-rust mirrors WhatsApp Web's `RotateKeyJob`: on a cadence, generate a fresh signed pre-key, upload it, and retain the previous keys so prekey messages already in flight against a rotated-out key still decrypt. **Cadence:** * Checked once per connection. Spawned right after the startup pre-key upload during post-login init, so a slow or failing rotation IQ never delays the rest of login. * Checked again on a \~6-hour keepalive maintenance tick for as long as the connection stays up ([whatsapp-rust#1411](https://github.com/oxidezap/whatsapp-rust/pull/1411)). Before this, the connect-time check was the rotation's only caller, so a process that paired once and never reconnected could hold a connection open past the 27-day cadence and never rotate at all. The keepalive pass re-checks `connection_generation` before rotating — a pass still queued against a connection that has since been retired (a reconnect landed first) no-ops instead of uploading against stale session state. * Rotates once `now - last_signed_pre_key_rotation_ms >= SIGNED_PRE_KEY_ROTATION_INTERVAL_MS` (27 days). This matches the interval WA Web's `ROTATE_KEY` task returns to its scheduler (`WAWebTasksDefinitions`, confirmed against a fresh captured WA Web bundle) and is **not** configurable — there is no A/B property behind the value, so a per-client override would only widen the public API for a number the official client hardcodes identically. `rotate_signed_pre_key()` remains the escape hatch for forcing a rotation out of band. * A device upgraded in with the field at `0` gets a one-time baseline stamp (`DeviceCommand::SetSignedPreKeyRotationBaseline`) instead of rotating immediately, so its first rotation lands a full interval out. * Single-flighted via `Client::signed_pre_key_rotation_lock` so overlapping post-login tasks (from reconnect churn) can't run the rotate/upload/prune sequence concurrently; a losing task just no-ops for that check. Prior to [whatsapp-rust#1237](https://github.com/oxidezap/whatsapp-rust/pull/1237) the interval was 7 days. Production logs showed every observed `InvalidSignedPreKeyId` decrypt failure falling inside the 21-day decrypt window (`SIGNED_PRE_KEY_RETENTION * interval`) that cadence produced — the shortest window of WA Web, zapo, and whatsapp-rust despite whatsapp-rust retaining three keys where zapo retains one. Widening the interval to 27 days quadruples the window to 81 days at unchanged storage cost (the three retained records cost 555 bytes regardless of cadence) and a quarter of the rotation IQs, device-state writes, and keypair generations per year. **Retention:** `SIGNED_PRE_KEY_RETENTION` stays at 3 (the current key plus the 2 most recent rotated-out keys). WA Web never prunes signed pre-keys at all — the private key stays on disk forever, which buys interop at the cost of the exact forward-secrecy property rotation exists to provide. whatsapp-rust keeps the bound: a key remains addressable for `SIGNED_PRE_KEY_RETENTION * SIGNED_PRE_KEY_ROTATION_INTERVAL_MS` (81 days) counted from when it was minted — that is, for the 2 rotation cycles (54 days) after it is first rotated out — after which its private key is destroyed and any prekey message still naming it gets a retry rather than a decrypt. **Wire format** (`RotateSignedPreKeySpec`, reuses the upload path's `` encoder so the two can never drift): ```xml theme={null} [3-byte BE signed pre-key ID] [32-byte signed pre-key public] [64-byte signature] ``` **Rotation sequence** (`Client::rotate_signed_pre_key`): 1. Compute `new_id = current_id + 1`, wrapping to `1` at the 24-bit border (same scheme as one-time pre-key IDs). 2. Stage the new key pair in the `signed_prekeys` backend table **before** upload — an already-staged candidate for that id is reused verbatim, so a retry after an ambiguous failure re-uploads the exact key the server may have already accepted instead of minting a different one under the same id. 3. Retain the outgoing (current) key in the backend table **before** upload, so once the server accepts the new key the old id's decrypt window is already durable — no post-acceptance write can strand it. 4. Upload via the `` IQ above. 5. On success, `DeviceCommand::SetSignedPreKey` atomically installs the new key pair, id, signature, and rotation timestamp. The now-redundant staged copy is dropped, and retained signed pre-keys are pruned (newest-id-first) to `SIGNED_PRE_KEY_RETENTION` (3 total: the current key + the 2 most recent rotated-out keys). **Error handling** (mirrors WA Web's `RotateKeyJob` ladder; a rotation failure never fails login): | Response | Candidate | Next attempt | | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `406` / `409` (deterministic rejection of this key) | Dropped — reusing it would wedge rotation forever, so a fresh candidate is minted on the next attempt. | Full cadence (27 days): the server rejects the same way again on the next reconnect, so the rejection consumes the cadence instead of retrying on every connection. | | Other `4xx` (e.g. `429`, rate limits) | Kept, retried as-is. | Full cadence — same reasoning as `406`/`409`. | | `5xx` (transient server error) | Kept, retried as-is. | 24 hours (`SIGNED_PRE_KEY_SERVER_ERROR_BACKOFF_MS`), capped at the cadence — a transient fault gets a short retry instead of waiting out the full interval. | | Transport failure (ambiguous — the server may have accepted it) | Kept, retried as-is. | Next connect — the cadence is left untouched, since the upload may have already succeeded. | The retry delay is expressed as a backdated cadence timestamp (`rotation_timestamp_after_failed_upload`, persisted via the same `DeviceCommand::SetSignedPreKeyRotationBaseline` command used for baseline seeding) rather than a second field, so `should_rotate_signed_pre_key` still reads a single timestamp. Only the cadence-driven caller (`maybe_rotate_signed_pre_key`) writes this schedule on failure — a manual `rotate_signed_pre_key()` call leaves the cadence untouched on failure either way, so a forced rotation can't drag an unrelated automatic rotation forward or backward. Regression versus the pre-[#1237](https://github.com/oxidezap/whatsapp-rust/pull/1237) behavior: a `4xx` response that is neither `406` nor `409` (for example a `429` rate-limit) now waits the full 27-day cadence instead of retrying on every reconnect. This matches both WA Web and zapo, and is considered acceptable because the current signed pre-key keeps working throughout — a deferred rotation costs window, not liveness. **Backend fallback for rotated-out ids:** Before this feature, `Device::load_signed_prekey` (`src/store/signal.rs`) returned a record only when the requested id matched the *current* `signed_pre_key_id` field — the `signed_prekeys` backend table (which already existed, with full CRUD) was never consulted for other ids. Rotating the key in place would therefore make any in-flight prekey message naming the old id fail with `InvalidSignedPreKeyId`. `load_signed_prekey` and `contains_signed_prekey` now fall back to the backend table for non-current ids, which is what makes rotation safe to ship. **Retry, not NACK, once the id ages past retention:** A sender's `PreKeySignalMessage` can still name a signed pre-key id that has since aged past `SIGNED_PRE_KEY_RETENTION` (3 total: current + 2 rotated-out) — the backend fallback above has nothing left to return, and `InvalidSignedPreKeyId` is the correct, permanent answer. On the 1:1 decrypt path (`src/message/receive.rs`), this now routes to a retry receipt (`RetryReason::InvalidKeyId`) carrying the current bundle, mirroring the sibling `InvalidPreKeyId` arm — instead of falling through to the catch-all `UnhandledError` nack, which would drop the stanza from the offline queue and lose the 1:1 message permanently and silently. `SignedPreKeyAdapter::get_signed_pre_key` (`src/store/signal_adapter.rs`) logs a warning naming both the requested id and the current device id whenever neither the device field nor the backend fallback resolves an id, so the two ways this can happen — a peer holding a bundle older than the retention window, versus a peer naming an id we never minted — stay distinguishable from the log alone. `InvalidSignedPreKeyId` itself carries no payload, so this is the only place either id is recorded. `Client::rotate_signed_pre_key()` is a public method — callers can force an out-of-cadence rotation directly instead of waiting for the 27-day check. It shares `signed_pre_key_rotation_lock` with the automatic path (so a manual call can't race a background rotation) and propagates upload failures to the caller rather than swallowing them. Location: `src/features/rotate_key.rs`, `src/store/signal.rs`, `src/store/signal_adapter.rs`, `src/message/receive.rs`, `wacore/src/iq/prekeys.rs`, `wacore/src/store/commands.rs` ### Re-pair pre-key healing (v0.6) If the user re-pairs the device (for example by re-scanning the QR code), the server discards its copy of our pre-key bundle even though `Device::server_has_prekeys` may still read `true` from the previous pairing. v0.6 resets `server_has_prekeys = false` immediately after a successful re-pair so the next connect uploads a fresh batch instead of trusting the stale flag. The lock-acquisition for the digest-key validator also moved into `validate_digest_key` itself. Previously the caller held `prekey_upload_lock` before calling the validator, which would deadlock when validation hit a 404 and tried to acquire the same lock to perform the re-upload. The lock now wraps only the re-upload path, so the 404→re-upload transition completes without contention. Location: `src/handlers/notification.rs`, `src/pair.rs`, `src/prekeys.rs` ### ADV companion identity validation When fetching a pre-key bundle for a contact's companion device (WhatsApp Web / Desktop), the bundle's `` element is validated to confirm that the fetched identity key is cryptographically bound to the account. This guards against a relay substituting a forged identity key, matching WA Web's `SessionApi.createSignalSession`. **Account key resolution** mirrors WA Web's `validateADVwithIdentityKey` (`e.accountSignatureKey || t`): 1. **In-blob key**: If `ADVSignedDeviceIdentity.account_signature_key` is present and non-empty, it is used directly. 2. **Stored identity fallback**: The server legitimately omits this field for a contact's companion because the client already holds the contact's primary (device 0) identity in the Signal identity store. When the field is absent, `Client::load_account_identity` loads it — reading through the `SignalStoreCache` so any unflushed mutations from the current session are visible. `PreKeyFetchSpec::with_account_identities` threads the pre-loaded map into `wacore`'s stateless prekey parser, keeping store access in the `whatsapp-rust` crate. **Validation results** (`wacore::adv::AdvValidation`): | Variant | Condition | Action | | -------------- | --------------------------------------------------------------- | --------------------------------------------------------------------- | | `Valid` | Both account and device signatures verified | Session is established normally | | `Invalid` | Blob is malformed, or signatures fail against the available key | Bundle is rejected — a relay swapping in a forged identity lands here | | `NoAccountKey` | Neither the blob nor the store has the key | Bundle is kept, ADV check skipped (logged as `warn!`) | `NoAccountKey` does not weaken security beyond the pre-existing "device-identity absent" path: a relay could already strip the entire `` element to bypass the check. It exists so brand-new contacts whose primary identity has never been seen are not silently dropped. The same three-state validation applies in the retry-receipt handler (`src/retry.rs`) when a companion device requests a re-send. Location: `wacore/src/adv.rs`, `wacore/src/iq/prekeys.rs`, `src/prekeys.rs` ## Storage Integration whatsapp-rust integrates Signal Protocol storage through a layered architecture: ``` src/store/ ├── signal.rs # SignalStore trait impl for Device (identity, session, prekey, sender key) ├── signal_adapter.rs # SignalProtocolStoreAdapter — cache-backed adapter bridging wacore traits to libsignal traits └── signal_cache.rs # Re-export of wacore::store::signal_cache::SignalStoreCache ``` The `Device` struct implements the libsignal `SessionStore`, `IdentityKeyStore`, and other traits. These are wrapped by `SignalProtocolStoreAdapter`, which adds the `SignalStoreCache` layer — sessions are cached as `SessionRecord` objects (not bytes), with serialization deferred to `flush()`. Each store (sessions, identities, sender keys) is flushed **independently** under its own lock. Only one store is locked during its I/O — the other two remain free for concurrent encrypt/decrypt operations. The lock is held from snapshot through write through clear, so mutations to the same store are blocked until flush completes, preventing dirty-set races: ```rust theme={null} // SignalProtocolStoreAdapter reads/writes through the cache #[async_trait] impl SessionStore for SessionAdapter { async fn load_session( &self, address: &ProtocolAddress, ) -> Result, SignalProtocolError> { // Returns cached SessionRecord object directly (no deserialization) // Cold load deserializes from backend bytes once and caches the object self.cache.get_session(&addr_str, &*device.backend).await } async fn store_session( &mut self, address: &ProtocolAddress, record: SessionRecord, // Takes ownership — zero-cost move ) -> Result<(), SignalProtocolError> { // Stores the SessionRecord object in cache, marks dirty // Serialization happens only during flush() self.cache.put_session(&addr_str, record).await; Ok(()) } } ``` ### Flush scheduling: send vs. receive *When* the dirty Signal cache reaches the backend differs by direction, because the two directions have different recovery properties: * **Send (DM/1:1 sessions)** persists through a batched **counter lease**. `SessionRecord` reserves its outbound sender-chain counter `SENDER_CHAIN_RESERVATION_BATCH` (64) values at a time, via `SessionRecord::reserve_sender_chain_counters`. A send covered by an unexhausted lease is already durable — it only schedules the same coalesced write-behind as the receive path below. The send that exhausts the lease, roughly 1 in 64, raises the ceiling and flushes **synchronously, before the stanza reaches the wire**. If that flush fails, the send aborts instead of transmitting an advance it couldn't save. Reusing an outbound counter reuses its message key and IV, so no counter can ever be used before its lease is durable. The lease field is local-only: it's field 100 in the encoded `SessionRecord`, outside the vendored `whatsapp.proto`. By default (`SessionRecord::deserialize`), every load fast-forwards the sender chain to the lease ceiling, so a crash mid-lease can never re-derive a possibly-spent counter — the store-backed load path relaxes this only for a *trusted* reload, via the incarnation marker described below. * **Send (group and status sends)** follows the same lease pattern as DMs. `SenderKeyRecord` reserves its outbound chain iteration `SENDER_CHAIN_RESERVATION_BATCH` (64) at a time via `SenderKeyRecord::reserve_iterations`, using the same field-100 local-only encoding and the same fast-forward-on-load recovery as `SessionRecord`. A send within an unexhausted lease rides the coalesced write-behind; only the send that raises the ceiling, roughly 1 in 64, flushes synchronously before the stanza reaches the wire. Status posts go through the same group-encrypt path and inherit this behavior; status *reactions* are a DM-branch send and always followed the DM lease instead. Production encryption (`wacore::send::encrypt_group_message`) delegates to the same `group_encrypt` primitive these guarantees live in, so there is exactly one sender-key encrypt/advance/store implementation — earlier, a second unguarded copy on the production path meant most warm group sends skipped the pre-wire flush entirely. * **Receive** (live traffic, outside the offline-drain batcher) routes through a single-flight coalescing scheduler (`src/signal_flush.rs`) instead of flushing per stanza: a burst of receives folds into one flush per \~25ms window, retried with exponential backoff (up to a 5s cap) on backend failure. This is safe because a lost receive-side advance simply re-derives forward on the next message (the receiving chain derives `CK_n → CK_n+1`), and a consumed one-time prekey stays buffered until its session is durable — a crash inside the window is recoverable. **Every direct Signal-mutating call site now shares the same gate ([#1048](https://github.com/oxidezap/whatsapp-rust/pull/1048)).** The public [`Signal::encrypt_group_message` and `Signal::create_participant_nodes`](/api/signal) accessors, plus VoIP's outbound call-key fanout (`place_call` in `src/voip/facade.rs`), used to call `Client::flush_signal_cache_batch_safe()` — an unconditional flush — after releasing their session or sender-key chain locks, regardless of whether the mutation actually raised a lease's durable ceiling. They now call the same `Client::persist_signal_state_pre_wire()` gate the primary `wacore::send` path uses: a warm call already covered by an existing lease rides the coalesced write-behind and returns immediately, while only the roughly-1-in-64 call that raises the ceiling still flushes synchronously before its ciphertext is used or sent. Measured against the same real-SQLite harness: warm group encryption dropped from 84.69 ms to 23.98 ms per 1,024-call sample (-71.7%), and warm 4-recipient participant fanout dropped from 49.16 ms to 8.69 ms per 256-call sample (-82.3%). Safety is unchanged — the per-entry durability check that guards what gets written is identical in both cases; the difference is that `flush_signal_cache_batch_safe()` always entered the flush path even on a warm lease, while `persist_signal_state_pre_wire()` skips it entirely once the raised lease is already durable. This removes redundant synchronous I/O, not any safety guarantee. Downgrading to a version that predates the counter lease after running a leased version: the older version ignores the lease field(s) and could reuse counters/iterations that were only reserved (not yet actually sent) by the lease. This applies to `SenderKeyRecord` (group/status sends) as well as `SessionRecord` (DM sends). Avoid downgrading a device's local state across this boundary. The pre-wire gate is a point-in-time check, not a lock held across the flush, and this specifically affects **DM sessions**: `needs_pre_wire_flush()` inspects pending reservations once, and `flush()` skips any session entry that is currently checked out by a concurrent operation (`SessionEntry::CheckedOut`), still returning `Ok` for the entries it did persist. If another task checks out the same session between this send's lock release and its flush, that session's reservation can remain pending even though the flush "succeeded" — the caller proceeds to write its stanza regardless. This is a property of `SignalStoreCache::flush` itself, not specific to retries; it affects any pre-wire-gated DM send that races a concurrent, *still in-progress* operation on the same session. Sender-key entries have no analogous checked-out state — `get_sender_key` clones an `Arc` without removing the cached record, so every dirty sender-key entry is included in a flush's batch — so group/status sends are not exposed to this race. This is unrelated to what happens if that concurrent operation is then *cancelled*: see [Cancellation-safe session checkouts](#cancellation-safe-session-checkouts) — a dropped checkout restores its entry synchronously rather than leaving it stranded, but the deferred entry still isn't included in a flush that already ran before the restore. Deleting a session or sender-key record — an identity change, a session reset, a rotated sender key — creates a tombstone rather than clearing the record's pending gate immediately. If a durability gate was open on the record at delete time, `SignalStoreCache` keeps it open until the backend's `delete_session`/`delete_sender_key` call actually succeeds; a failed delete leaves `needs_pre_wire_flush()` returning `true` and is retried on the next flush. Earlier, the gate was released as soon as the tombstone was applied to the in-memory cache, so a failed delete or a crash in that window could let ciphertext reach the wire while the pre-delete chain state was still loadable from the backend, re-deriving already-used key material on reload. A lossy `clear()` still drops a pending tombstone gate — the tombstone is discarded from the in-memory cache without issuing the backend delete, so the old chain state may remain in the backend. ### Clean reload vs. crash recovery Fast-forwarding past a lease's reserved ceiling on every reload is the safe default, but it's also overly conservative for the common case: a clean reconnect or a same-process store re-creation never actually risked losing an in-flight send, yet unconditionally fast-forwarding still burned a full unused batch every time. 32 clean reconnects could push a sender-key chain 2,048 iterations ahead and get rejected once a peer who missed the intervening messages hit `MAX_FORWARD_JUMPS` (2,000). `SignalStoreCache`, and the direct, non-cached `Device` store, now tag a durably-reserved record with a random 128-bit **store incarnation**, carried in a second local-only field (101, alongside the lease's field 100) on both `SessionRecord` and `SenderKeyRecord`: * A live `SignalStoreCache` generates one incarnation marker when it's constructed. A reload that observes a matching marker proves the record came from *this same live cache* and was never lost to a crash, so it's exact: the chain resumes at the next iteration instead of fast-forwarding. * A reload with no marker, a different marker, or a malformed/duplicated marker is untrusted and keeps the conservative fast-forward — this covers process restarts, a freshly constructed cache, and any genuinely lossy discard. * [`SignalStoreCache::clear_after_flush()`](/concepts/architecture#disconnect-cleanup), used by connection teardown, only evicts a store once it's fully settled — no dirty, deleted, checked-out, or pending-wire-gate entries. Anything a concurrent write installs after the flush stays resident (and the incarnation stays put) until a later flush settles it; only an actual lossy discard rotates the marker. * Direct `Device` stores hold one process-level incarnation in an `OnceLock` instead of a per-cache one. Since these stores synchronously await the backend write before returning ciphertext, a new `Device` wrapping the same backend in the same process is a clean, trusted reload; a process restart gets a fresh marker and stays conservative. This adds no field to the public `Device` struct and no synchronous I/O — a marker is generated only when a cache or process starts, or when a lossy boundary invalidates trust. The scheduler is generation-scoped (embeds the connection generation in its atomic state), so a reconnect during an in-flight flush needs no explicit reset: a stale worker from the previous connection cannot mutate the new generation's state, and stands down when it observes a foreign generation. The offline drain, identity-change recovery, and teardown all keep their own **synchronous** flushes — they gate acks, receipts, or follow-up reads on durability and are not routed through the receive coalescer. (Teardown's flush is itself conditional: `teardown_inbound_commits_bounded` only flushes on a durable drain with no outstanding batch entries; on a timeout or non-durable/pending entries, it clears the cache without flushing instead, relying on server redelivery rather than risking a persisted-but-incomplete advance.) See [Inbound Durability Hook](/advanced/inbound-durability) for the drain-batch commit ordering, which this coalescing does not change. Retry-receipt recovery (`handle_retry_receipt` resending to a DM or group requester, `src/retry.rs`) instead shares the DM/group send durability rule above through one helper, `send_retry_stanza`: the session lock is released first, then `persist_signal_state_pre_wire()` runs — flushing synchronously if the retry's Signal advance crossed an unpersisted lease boundary — before the stanza is written to the wire. An `Err` from that flush aborts the retry instead of transmitting an advance it couldn't save; for a DM retry specifically, see the point-in-time caveat above for the narrower case where the flush reports success without having actually persisted this retry's own checked-out session (group retries are not exposed to that race — see the same caveat). This replaced an earlier unconditional full flush that ran only *after* the retry stanza had already reached the wire — a crash or persistence failure in that window could reload the old chain state after the ciphertext was already sent. Call [`Client::flush_pending_signal_state()`](/api/client#flush_pending_signal_state) to force a deterministic settle — e.g. before reading persisted Signal state directly, or ahead of a non-graceful shutdown. Never call it from inside an `InboundDurabilityHook` or a synchronous, inline `EventHandler::handle_event` implementation, since settling re-enters the processing permit those run under and would deadlock during an offline-sync drain. Ordinary `Bot` closure handlers are unaffected — both default delivery modes run the callback in a detached task off the permit. ### DH ratchet resets rebase the lease A DH ratchet doesn't extend the current sender chain — it replaces it in place. The ratchet installs fresh key material from a new random ephemeral at counter zero and drops the retired chain instead of archiving it. The counter lease described above is a **record-level** ceiling, but the chain it bounds is **per-ratchet-epoch**. Without a matching lease rebase, the ceiling keeps describing a chain that no longer exists. For ping-pong traffic, that gap is one batch and you'd never notice it. It's different for a peer you only ever monologue at — say, your own other device, which gets a copy of every message you send but rarely replies. The chain climbs past `MAX_RESERVATION_FAST_FORWARD` before one reply triggers the ratchet, and the ceiling ends up stranded thousands of counters above a chain that just restarted at zero. A live reload never surfaces this, since a trusted-incarnation reload (above) skips the fast-forward entirely. The gap only shows up on **recovery** — a restart, or any lossy cache reset. There, the reload has to fast-forward across a span no send ever created. It refuses past `MAX_RESERVATION_FAST_FORWARD` and fails the whole record load. From that point the address is stranded: every path that could repair the session — inbound decrypt, the group-send fan-out, the retry-receipt handler — has to load the unloadable record first. `SessionRecord::rebase_lease_after_sender_chain_reset()` closes this gap. As part of the same mutation that swaps in the fresh chain, it lowers the ceiling to at most one `SENDER_CHAIN_RESERVATION_BATCH`. It only ever lowers, never raises, so you can never publish a counter under a ceiling that isn't yet durable. The lowering happens atomically with the chain swap, so no snapshot can pair the retired chain with a ceiling rebased for it, or vice versa. Rebasing to one batch instead of zero keeps the fresh chain's first counters lease-covered, so steady-state ping-pong keeps its write-behind send path instead of paying a synchronous flush on the very next send. A chain that's *archived* rather than discarded keeps its claim on the lease instead: `promote_fresh_state` burns the outgoing state to the ceiling before resetting it. If a record already has a stranded ceiling — written by a build that predates this fix — it recovers on its own the next time you use that address; you don't need to delete the row by hand. See [undecodable session rows](/concepts/storage#signalstorecache). ### Waiving the counter lease The batched lease above assumes the durable snapshot `SessionRecord`/`SenderKeyRecord` serialize to is the store of record. Some consumers don't fit that assumption: their own persistence is already synchronous and durable before the ciphertext reaches the wire, so the lease gives them nothing. The [component export](#record-components) case below is the motivating one. Such a consumer still pays for the lease, because `into_components()` has to materialize the full reservation on *every* export — nothing else in the projection could re-derive it later. Four consecutive DM sends through such a store land on the wire at counters `0, 64, 128, 192` instead of `0, 1, 2, 3`, and the peer buffers 63 skipped message keys per gap. `SessionRecord::waive_counter_lease()` and `SenderKeyRecord::waive_counter_lease()` let that consumer say so, per record, once it's loaded: ```rust theme={null} let mut record = SessionRecord::from_components(components)?; record.waive_counter_lease(); ``` ```rust theme={null} let mut record = SenderKeyRecord::from_components(components)?; record.waive_counter_lease()?; ``` * **The policy is the consumer's, never inferred.** Nothing about a record's stored representation says whether a lease is in force — the same components can come from a consumer that wants the lease and one that does not — so this call has to happen on every load that should waive it, not just once. There is no build feature or store-shape heuristic that does it implicitly. * **This gives up a real guarantee.** Message keys and IVs derive deterministically from the counter, so without the lease a crash between the encrypt and the write can reissue a counter and, with it, its (key, IV) pair. Only make this trade if persistence is synchronous and durable before the wire — the same property a direct `Device` store already has for itself, since it awaits the backend write before returning ciphertext (see [store incarnation](#clean-reload-vs-crash-recovery)). `SignalStoreCache`'s trusted reload relies on a different, weaker guarantee — a matching live-cache incarnation, not synchronous durability, since its warm sends ride the coalesced write-behind — so don't read the two as the same requirement. * **A reservation the record already carries still burns once.** A record loaded from a snapshot written while the lease was in force may already have published counters below its ceiling; waiving doesn't make that untrue. The call materializes that ceiling into the chain — archived states included, the same way `into_components()` already would — and then the lease is gone, so it only pays that cost once instead of on every subsequent export. * **An unadvanceable ceiling is handled differently per record**, matching how their exports already differ: `SessionRecord::waive_counter_lease()` has no failure path — a chain too stale to fast-forward is dropped fail-closed, per session, without discarding the rest of the record. `SenderKeyRecord` has one shared chain rather than per-peer sessions, so dropping it isn't a safe partial failure; `waive_counter_lease()` returns `Err` instead and leaves the record on its lease rather than dropping the ceiling and risking reissue. * **The default is unchanged.** A record that never calls this keeps the lease, the wire gate, and the fast-forward-on-load exactly as documented above. Location: `wacore/libsignal/src/protocol/counter_lease.rs`, `wacore/libsignal/src/protocol/state/session.rs`, `wacore/libsignal/src/protocol/sender_keys.rs` ## Record components `wacore-libsignal` exposes owned, validated projections of `SessionRecord` and `SenderKeyRecord` called **components**. Use them when you need to interchange or inspect session and sender-key record state without depending on the generated protobuf schema directly — for example in custom store implementations, migration tooling, or offline debugging. This API is purely additive: the protobuf-backed `serialize()`/`deserialize()` path is unchanged. A record does not round-trip through `into_components()` → `from_components()` → `serialize()` byte-for-byte — the conversion applies the validated, normalized export rules described below (counter-lease advancement, stale-chain removal, and bounded truncation), so treat it as a safe normalized re-encoding rather than a lossless copy. A store built on components alone re-materializes the lease's reservation on every export; see [Waiving the counter lease](#waiving-the-counter-lease) above if that store's own persistence is already durable before the wire. ```rust theme={null} // wacore/libsignal/src/protocol/record_components.rs, re-exported from // wacore/libsignal/src/protocol/mod.rs pub use record_components::{ PendingKeyExchangeComponents, PendingPreKeyComponents, SenderChainKeyComponents, SenderKeyRecordComponents, SenderKeyStateComponents, SenderMessageKeyComponents, SenderSigningKeyComponents, SessionChainComponents, SessionChainKeyComponents, SessionComponents, SessionMessageKeyComponents, SessionMessageKeyMaterial, SessionRecordComponents, }; ``` ### Session and sender-key shapes `SessionRecordComponents` mirrors the `current_session` / archived `previous_sessions` split already described in [Arc previous sessions](#arc-previous-sessions); `SenderKeyRecordComponents` mirrors a `SenderKeyRecord`'s state list: ```rust theme={null} pub struct SessionRecordComponents { pub current_session: Option, pub previous_sessions: Vec, } pub struct SessionComponents { pub session_version: Option, pub local_identity_public: Option>, pub remote_identity_public: Option>, pub root_key: Option>, pub previous_counter: Option, pub sender_chain: Option, pub receiver_chains: Vec, pub pending_key_exchange: Option, pub pending_pre_key: Option, pub remote_registration_id: Option, pub local_registration_id: Option, pub needs_refresh: Option, pub alice_base_key: Option>, } pub struct SessionChainComponents { pub sender_ratchet_key: Option>, pub sender_ratchet_key_private: Option>, pub chain_key: Option, pub message_keys: Vec, } pub struct SenderKeyRecordComponents { pub states: Vec, } pub struct SenderKeyStateComponents { pub key_id: u32, pub chain_key: SenderChainKeyComponents, // { iteration: u32, seed: Vec } pub signing_key: SenderSigningKeyComponents, // { public: Vec, private: Option> } pub message_keys: Vec, // { iteration: u32, seed: Vec } } ``` `SessionMessageKeyComponents` and `SenderMessageKeyComponents` hold skipped out-of-order message keys, keyed by chain index/iteration. A session message key's secret material is `SessionMessageKeyMaterial`: ```rust theme={null} #[derive(Clone, Copy, PartialEq, Eq)] pub enum SessionMessageKeyMaterial { Seed([u8; 32]), Derived { cipher_key: [u8; 32], mac_key: [u8; 32], iv: [u8; 16], }, } ``` As of [#1210](https://github.com/oxidezap/whatsapp-rust/pull/1210), the seed is persisted alongside the keys it derives, not just accepted on import. Previously `Seed` was only a compact import form, expanded through the same canonical derivation used elsewhere (see [`MessageKeyGenerator`](#chain-key-ratcheting)). Now a skipped key's seed round-trips back out on export too. `session_structure::chain::MessageKey` carries an additive `seed` field (local field 100) alongside the pre-existing `cipher_key`/`mac_key`/`iv`. `from_structure` prefers the seed when present: it re-derives from the seed and rejects the record if the result disagrees with the stored triple, since the seed supersedes the triple on export and a mismatch means a corrupt record. `Derived` now comes back only for a key persisted *before* this change — one that never wrote a seed. Both variants are fixed-width and `Copy`, so `into_structure()`, the reverse direction, is now infallible; it used to validate a `Vec` length. **Breaking for direct consumers of these types.** `SessionMessageKeyMaterial::Seed` and `Derived` switched from `Vec` fields to fixed-width arrays. Match both variants — don't assume `Derived` is the only exported form. `MessageKeyGenerator::Keys(MessageKeys)` was also removed; nothing in the workspace constructed it. Build a seeded key with `MessageKeyGenerator::new_from_seed` instead. If you downgrade to a build predating #1210 and write a record back out, the seed drops silently — decrypt is unaffected, but the key becomes unexportable again. Conversions: ```rust theme={null} impl SessionRecord { pub fn from_components(value: SessionRecordComponents) -> Result; pub fn into_components(mut self) -> Result; } impl SenderKeyRecord { pub fn from_components(value: SenderKeyRecordComponents) -> Result; pub fn into_components(mut self) -> Result; } ``` ### Import validation `from_components` enforces the same structural invariants the canonical protobuf reader relies on elsewhere in this codebase, rather than accepting whatever shape the caller hands it: * A **sender chain** (the local sending ratchet chain within a pairwise session — not to be confused with a group sender-key chain) must be structurally complete: ratchet public key present, a 32-byte ratchet private key, and a chain key with both its index and 32-byte secret set. An incomplete sender chain fails with `SignalProtocolError::InvalidArgument`; a session with *no* sender chain at all (e.g. one just received and not yet replied to) is fine and imports as `sender_chain: None`. * A **receiver chain** must never carry `sender_ratchet_key_private` — receiver chains never own the remote party's private key, so import fails if one is set. Symmetrically, projecting a persisted record into components always reports a receiver chain's `sender_ratchet_key_private` as `None`, silently dropping any non-canonical private material a legacy record might contain, matching how the canonical reader already treats that field. * Raw 32-byte public keys and canonically-serialized public keys (type byte + 32 bytes) are both accepted on import; whichever form was imported, `into_components()` always exports canonical serialization. ### Export normalization `into_components()` never hands back a session or sender-key chain whose counter could be replayed on re-import: * Any durably reserved sender-chain counter range — see the counter-lease mechanics in [Flush scheduling: send vs. receive](#flush-scheduling-send-vs-receive) — is advanced to its exclusive ceiling before export. Re-importing the exported components can't reuse a counter value that was only reserved, not yet actually sent. * A sender chain too stale to fast-forward past its reservation is dropped from the exported chain rather than aborting the whole export — the rest of the record (receiver chains, other archived sessions) still exports normally. * `SessionRecordComponents.previous_sessions` is truncated to `ARCHIVED_STATES_MAX_LENGTH` (40) and `SenderKeyRecordComponents.states` is truncated to `MAX_SENDER_KEY_STATES` (5) — the same bounds the records themselves already enforce. See [Protocol safety limits](#protocol-safety-limits). ### `has_usable_sender_chain` ```rust theme={null} impl SessionState { pub fn has_usable_sender_chain(&self) -> Result; } impl SessionRecord { pub fn has_usable_sender_chain(&self) -> Result; } ``` Checks whether a session has a sender chain it could actually encrypt with, rather than assuming one exists. Previously this was effectively best-effort/always-true; the current implementation returns `Ok(false)` (not an error) when no sender chain is set at all, and otherwise structurally validates the ratchet public key, the ratchet private key, and the chain key are all present before returning `Ok(true)` — the same completeness check `from_components` applies on import. `SessionRecord::has_usable_sender_chain` delegates to the current session's check, returning `Ok(false)` when there is no current session. Location: `wacore/libsignal/src/protocol/state/session.rs` ### Debug output redacts secrets `Debug` on every `*Components` type — `SessionComponents`, `SessionChainComponents`, `SessionChainKeyComponents`, `PendingKeyExchangeComponents`, `PendingPreKeyComponents`, `SenderChainKeyComponents`, `SenderSigningKeyComponents`, `SenderMessageKeyComponents`, and `SessionMessageKeyMaterial` — prints private keys, chain/root keys, seeds, and cipher/mac/IV material as ``, while structural fields (indices, counters, iteration numbers, key presence) print plainly: ```rust theme={null} println!("{chain:?}"); // SessionChainKeyComponents { index: Some(7), key: } ``` This makes it safe to log or assert against a `*Components` value in application code without writing a custom `Debug` impl to avoid leaking key material. **Example — inspecting whether a session can currently send, without touching protobuf types:** ```rust theme={null} // `record: SessionRecord` loaded from your store if record.has_usable_sender_chain()? { let components = record.into_components()?; println!("{:?}", components.current_session); // secrets redacted } ``` Location: `wacore/libsignal/src/protocol/record_components.rs` ### Legacy session v1 interop Behind the opt-in `legacy-session-interop` Cargo feature (default off, forwarded through `wacore` and the root `whatsapp-rust` crate), `wacore-libsignal` exposes a typed, transport-agnostic model of the **decoded** legacy libsignal `SessionRecord` v1 layout — the format this project's stores used before the canonical shapes above. It exists for migration tooling importing an externally produced v1 store into the canonical `SessionRecord`, or projecting a canonical record back into v1 terms; ordinary clients never enable it, so the model compiles out of native builds entirely. Container decoding — turning a legacy store's transport bytes into these typed fields — is explicitly out of scope; callers own that step and hand this module owned values (`Bytes`, integers, enums). The module owns everything downstream: chain-role selection, counter translation, lifecycle ordering, pruning, ratchet reconstruction, and skipped-key derivation. ```rust theme={null} // wacore/libsignal/src/protocol/legacy_session.rs, re-exported from // wacore/libsignal/src/protocol/mod.rs behind `legacy-session-interop` pub use legacy_session::{ LegacyIndexedSessionV1, LegacySessionBaseKeyRoleV1, LegacySessionChainCounterV1, LegacySessionChainKeyV1, LegacySessionChainRoleV1, LegacySessionChainV1, LegacySessionDispositionV1, LegacySessionFieldV1, LegacySessionIndexV1, LegacySessionInteropError, LegacySessionKeyPairV1, LegacySessionLocalContext, LegacySessionMessageKeyV1, LegacySessionPendingPreKeyV1, LegacySessionRatchetV1, LegacySessionRecordV1, LegacySessionUnrepresentableFieldV1, LegacySessionV1, }; ``` **Import — v1 into canonical:** ```rust theme={null} impl LegacySessionRecordV1 { pub fn from_indexed_sessions( sessions: Vec, ) -> Result; pub fn into_session_record( self, context: LegacySessionLocalContext, ) -> Result; } ``` `from_indexed_sessions` validates that each entry's outer map key matches its own session's base key and rejects duplicate base keys or more than one `Current` session. `into_session_record` then validates every session — chain roles, key lengths, counters, skipped-key indexes, pending pre-keys, and the same canonical limits enforced in [Import validation](#import-validation) — retains archived sessions by close time, reorders them by last use to match the v1 decrypt search, truncates to `ARCHIVED_STATES_MAX_LENGTH`, and reuses `SessionRecord::from_components` to build the canonical record. `LegacySessionLocalContext` supplies the local identity key and registration ID, since v1 sessions never persisted them. A v1 sending chain seeds `previous_counter` at `-1` for a ratchet step over a chain that has never sent; `LegacySessionChainCounterV1` floors that at zero on import instead of treating it as an error. Every other out-of-range counter fails typed as `InvalidChainCounter`. **Export — canonical into v1 (operational, not byte-exact):** ```rust theme={null} impl SessionRecord { pub fn into_legacy_session_v1_operational( self, ) -> Result; } ``` This is a deterministic operational projection, not a round trip: v1 lifecycle timestamps and base-key lookup roles are reconstructed from canonical search/eviction order rather than recovered verbatim, since the canonical record never persisted them in the first place. State the v1 format genuinely cannot represent is rejected with a typed error instead of being silently dropped or inferred — a session with no sender chain, a non-current `session_version`, a pending key exchange, or a `needs_refresh` flag all fail as `NotRepresentable`; a pending pre-key whose base key doesn't match the session's own base key fails as `PendingPreKeyBaseMismatch` rather than producing v1 state the importer would reject on the way back in. A receiver chain holding a *derived* (seedless) skipped-message key fails as `ChainNotRepresentable::DerivedMessageKey` — it has no inverse to a v1 seed. Before [#1210](https://github.com/oxidezap/whatsapp-rust/pull/1210), this was every skipped key without exception. Import expanded a v1 seed into derived keys, and export could never recover it, so a v1 record with a skipped key failed to round-trip even on the very first cycle. Now that the seed rides along with the keys it derives (see [`SessionMessageKeyMaterial`](#session-and-sender-key-shapes) above), only a key persisted *before* that change still hits this error — one that never had a seed to retain. A skipped key imported or received after #1210 carries its seed through export and projects normally. That holds permanently, until the key is consumed or evicted. CI runs a dedicated `cargo nextest run --features legacy-session-interop` job as of #1210. The feature was previously off in every job. This entire module's test suite — including the regression test for the bug above — compiled away and never ran. Every type in the module redacts key material from `Debug` — root keys, chain keys, ratchet key pairs, skipped-message seeds, and identity keys all print as ``; only structural fields (roles, counters, indexes, session/chain counts) print plainly, the same convention as the canonical [`*Components` types](#debug-output-redacts-secrets). Location: `wacore/libsignal/src/protocol/legacy_session.rs` ## Security Considerations ### Identity key trust The implementation verifies identity keys before encryption/decryption: ```rust theme={null} if !crate::protocol::storage::is_trusted_identity( identity_store, remote_address, &their_identity_key, Direction::Sending, ) .await? { return Err(SignalProtocolError::UntrustedIdentity( remote_address.clone(), )); } ``` As of [#1124](https://github.com/oxidezap/whatsapp-rust/pull/1124), this calls the free `is_trusted_identity` resolver (see [Unboxed identity and session hooks](#unboxed-identity-and-session-hooks)) rather than the trait method directly, so a store's `try_is_trusted_identity` hook gets a chance to answer first. Location: `wacore/libsignal/src/protocol/session_cipher.rs:309-325` ### Self-only protocol message gating `app_state_sync_key_share`, `app_state_sync_key_request`, and `history_sync_notification` are protocol messages WhatsApp Web treats as "self-only": they only carry meaning when delivered from your own account to another of your linked devices. v0.6 hardens `handle_decrypted_plaintext` so that incoming copies of these messages are dropped unless `MessageInfo.source.is_from_me` is `true`, matching WA Web's `WAWebKeyManagementHandleKeyShareApi` and whatsmeow's gating. The consequences if the gate is missing: * A spoofed `app_state_sync_key_share` from a peer would let an attacker inject an app-state encryption key, leading to attacker-controlled mutations of your contacts, blocklist, archive state, etc. * A spoofed `app_state_sync_key_request` from a peer would make the client share its app-state encryption keys with an attacker instead of only with the account's own companion devices. * A spoofed `history_sync_notification` would point the client at attacker-supplied media for ingestion as your own history. If you implement a custom message dispatcher, replicate this `is_from_me` check before honoring any of these protocol messages. Other protocol-message types (`REVOKE`, `EPHEMERAL_SETTING`, `MESSAGE_EDIT`, …) keep their existing semantics. Location: `src/message.rs` (`handle_decrypted_plaintext`) ### Duplicate message detection The protocol detects and rejects duplicate messages: ```rust theme={null} if chain_index > counter { return match state.get_message_keys(their_ephemeral, counter)? { Some(keys) => Ok(keys), None => Err(SignalProtocolError::DuplicateMessage(chain_index, counter)), }; } ``` Location: `wacore/libsignal/src/protocol/session_cipher.rs:822-827` As of [#1072](https://github.com/oxidezap/whatsapp-rust/pull/1072), a `DuplicatedMessage` from one candidate session is not terminal — the search keeps trying the remaining current and archived sessions, including a **closed** receiver chain (one with no chain-key seed left, only leftover skipped keys). This covers re-initiations, which reuse the peer's signed pre-key as a ratchet key: a delayed message whose skipped key survives only in an archived session still decrypts, and a closed chain still recognizes a replay of an already-consumed counter instead of falling through to a generic failure. Once every session has been tried, a recognized `DuplicatedMessage` outranks `BadMac` in the final classification: a sibling session that happens to share the same ratchet key derives different message keys for the same counter and fails its MAC as expected noise, but the decrypting client already knows this counter was consumed elsewhere. Classifying that as `BadMac` would trigger a retry receipt for a message the peer already delivered; the duplicate verdict wins instead, so the replay is acknowledged and silently dropped. ### Log level discipline The protocol layer follows strict rules about what cryptographic material appears in logs and at which level: * **No private keys or secrets are ever logged** — `ChainKey`, `MessageKeys`, and `RootKey` types do not expose their key bytes through logging * **Public keys appear only at `warn`/`error` levels** — and only when something has gone wrong (untrusted identity, MAC failure) * **MAC key fingerprints are truncated** — only the first 4 bytes (8 hex chars) are logged during MAC verification failures, not the full key: ```rust theme={null} let mac_key_fingerprint: String = hex::encode(mac_key_bytes).chars().take(8).collect(); ``` * **Ratchet keys in debug logs** — successful decryptions log the sender ratchet public key (never private) at `debug` level for diagnostics * **Pre-key operations** use `debug` for routine operations and `warn`/`info` for exceptional conditions The Signal protocol layer (`wacore/libsignal/src/protocol/`) uses no `trace!`-level logging. Sensitive operations stay at `debug` or above to avoid leaking material in verbose log configurations. ### Session state corruption Detailed logging helps diagnose crypto failures: ```rust theme={null} fn create_decryption_failure_log( remote_address: &ProtocolAddress, errs: &[SignalProtocolError], record: &SessionRecord, ciphertext: &SignalMessage, ) -> Result ``` This generates comprehensive error logs showing: * All attempted session states * Receiver chain information * Message metadata (sender ratchet key, counter) Location: `wacore/libsignal/src/protocol/session_cipher.rs:365-454` ### Protocol safety limits The implementation enforces several hard limits to prevent resource exhaustion and cryptographic failures: | Constant | Value | Purpose | | ----------------------------- | --------------------- | ------------------------------------------------------------------------------------- | | `MAX_PREKEY_ID` | 16,777,215 (2^24 − 1) | Maximum valid pre-key ID (24-bit wire format) | | `MAX_FORWARD_JUMPS` | 2,000 | Maximum message skip in a ratchet chain — peer sessions and group sender keys | | `MAX_FORWARD_JUMPS_SELF` | 25,000 | Maximum message skip for a session with your own other devices (wider, still bounded) | | `MAX_MESSAGE_KEYS` | 2,000 | Maximum cached out-of-order message keys per chain | | `MAX_RECEIVER_CHAINS` | 5 | Maximum receiver chains per session | | `ARCHIVED_STATES_MAX_LENGTH` | 40 | Maximum archived session states | | `MAX_SENDER_KEY_STATES` | 5 | Maximum sender key states per group | | `MESSAGE_KEY_PRUNE_THRESHOLD` | 50 | Amortized eviction trigger for old message keys | | Chain key index | u32::MAX | Overflow returns `InvalidState` error (not silent wrap) | Location: `wacore/libsignal/src/protocol/consts.rs` ### Self-DM / sibling decryption recovery When a message from your own primary phone or another linked companion fails to decrypt, the v0.6 client distinguishes the failure mode and applies the matching recovery strategy: | Internal `RetryReason` | Trigger | Recovery | | ---------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `NoSession` | `SessionNotFound` (no Signal session yet for the device) | Request a fresh prekey bundle via retry receipt; install the new session before retrying decryption. | | `BadMac` | Ratchet desync (`InvalidMessage` / mac failure) on an existing session | Mark the session for re-creation, throttled per peer via the `session_recreate_history` cache so repeated BadMacs don't loop, and re-send via a peer-addressed `pkmsg` carrying our identity. | The throttle is a per-peer cooldown (1-hour TTL after the last recreate). In v0.6 the implementation moved from a `Mutex>` to a bounded TTL cache (`PortableCache`, \~256 entries): the per-peer check-and-stamp is now atomic (serialized by the existing per-peer session lock) and lock-free at the map level, so concurrent retry-receipt spawns from the same peer can't trigger duplicate recreates. The behavior is unchanged — if a peer is already in cooldown, the client skips re-creation and falls back to a normal retry receipt rather than thrashing the session. Under more than \~256 distinct peers retrying within the window, the cache may evict a recent entry, costing at most one extra recreate (bounded and self-healing). Peer-addressed `pkmsg` carries the protocol identity so the receiver can verify ownership before installing the new session, blocking spoofed sibling recoveries. This closed a deadlock where self-DM fan-out to a sibling device produced repeated BadMac decrypt failures: the recipient would request a retry, the sender would re-encrypt against the same broken session, and the cycle would continue until the user manually relogged. With the throttled re-creation plus identity-validated pkmsg, the second receipt installs a fresh session and decryption resumes. Self-DM fan-out also gained WA Web parity for the BadMac case: when our own primary phone reports BadMac, the client now treats it as a session-level recovery rather than dropping the message, matching `WAWebDecryptOrThrow`'s branch on session divergence. Location: `src/client.rs`, `src/retry.rs`, `wacore/libsignal/src/protocol/session_cipher.rs`, `wacore/src/send.rs` ## Performance optimizations ### Session object cache The `SignalStoreCache` stores sessions and sender keys as deserialized objects (`SessionRecord` and `SenderKeyRecord`) rather than serialized bytes, matching WhatsApp Web's architecture where the JS object IS the cache. Serialization only happens during `flush()` to the database — not on every `store_session` or `put_sender_key` call. ```rust theme={null} // wacore/src/store/signal_cache.rs enum SessionEntry { /// Arc so peek_session (retry / LID-migration checks) bumps a refcount /// instead of deep-cloning the record (KBs with archived states). Present(Arc), Absent, /// Taken by a destructive-update load; has_session treats as present. /// Carries enough identity to reject a stale owner — see /// "Cancellation-safe session checkouts" below. CheckedOut { had_session: bool, token: NonZeroU64, }, } struct SessionStoreState { cache: HashMap, SessionEntry>, // Objects, not bytes dirty: HashSet>, deleted: HashSet>, incarnation: StoreIncarnation, checkout_generation: u64, next_checkout_token: u64, } // Sender keys use the same object-caching pattern struct SenderKeyStoreState { cache: HashMap, Option>, // Objects, not bytes dirty: HashSet>, } ``` This eliminates protobuf encode (on store) and decode (on load) from the per-message hot path for both 1:1 and group messages. The `store_session` method takes `SessionRecord` by value, enabling zero-cost moves from the protocol layer: ```rust theme={null} // wacore/libsignal/src/protocol/storage/traits.rs pub trait SessionStore { async fn store_session( &mut self, address: &ProtocolAddress, record: SessionRecord, // Owned — zero-cost move, no clone ) -> Result<()>; } ``` All four protocol-layer call sites (`message_encrypt`, `message_decrypt_signal`, `message_decrypt_prekey`, `process_prekey_bundle`) — plus PreKey setup and group preflight mutations — take ownership of the record and drop it immediately after storing. Taking ownership eliminates the `.clone()` in the adapter and the compiler enforces no use-after-store. As of [#1044](https://github.com/oxidezap/whatsapp-rust/pull/1044), these call sites obtain the record through the `SessionCheckout` guard (below) rather than a bare `load_session`/`store_session` pair, so the zero-cost move still applies but the checkout is now cancellation-safe. **Per-message hot path impact:** | Operation | Before | After | | --------------------------- | ---------------------------------- | ------------------------------------------------------------- | | `store_session` | clone all fields + protobuf encode | move (zero-cost) | | `load_session` | protobuf decode + construct | clone current session only (`previous_sessions` O(1) via Arc) | | `peek_session` | deep-clone record (1–2 KB) | `Arc` refcount bump; returns `Option>` | | `store_sender_key` | serialize to bytes + store bytes | store `SenderKeyRecord` object directly | | `load_sender_key` (`&self`) | load bytes + deserialize | return cached `SenderKeyRecord` object (read lock only) | | `flush` (batched) | write bytes to DB | serialize sessions + sender keys + write bytes to DB | ### Cancellation-safe session checkouts As of [#1044](https://github.com/oxidezap/whatsapp-rust/pull/1044), the record backing a `CheckedOut` entry is owned by a `SessionCheckout<'a>` guard (`wacore/libsignal/src/protocol/storage/traits.rs`) instead of being a bare marker. This closes a gap where cancelling the future mid-mutation — a Tokio task abort, a `select!` losing a race, a timeout — could leave only the marker behind: the ratchet advance the future was computing was lost, and a decrypt's ratchet advance could persist without its matching identity write, or vice versa. `SessionCheckout` is obtained via `SessionCheckout::load` (existing session) or `load_or_create` (synthesizes `SessionRecord::new_fresh()` if absent, tracked via `had_session`), exposes `record()`/`record_mut()` for the protocol layer to mutate in place, and is consumed by either: * `commit(self)` — stores the mutated record back through `try_store_session_from_checkout`, or * `discard(self)` — releases a deliberately-rejected *fresh* (never-had-a-session) checkout without storing. If neither runs because the guard is dropped first (the cancellation case), `Drop` synchronously puts the record back itself — no caller code has to remember to handle cancellation. If the sessions mutex is uncontended, restoration is immediate; if a concurrent `flush()` holds the lock, the restore is queued as a `PendingSessionRestore` and replayed the next time any operation acquires the sessions lock, so an aborted task's state is never dropped even though it can't wait for the lock itself. The queue itself (`SyncMutex>`) is unbounded — every lock acquisition drains it first, so it only grows if cancellations under lock contention arrive faster than anything else touches the session store, which does not happen under normal load. Every checkout carries a `SessionCheckoutKey { generation, token }`: `checkout_generation` increments on every lossy `clear()`, and `token` is assigned monotonically per checkout of a given address. A restore is rejected (rather than silently applied) unless both match the cache's current state — this stops a checkout issued before a session reset from resurrecting stale state, and stops a stale owner from clobbering a newer checkout of the same address. `flush()` and cache eviction (`clear_after_flush`) now preserve live `CheckedOut` entries — and any PreKey deletion buffered against that address — instead of dropping them; the deferred prekey delete and the session's own persistence both wait for a later flush once the checkout completes, rather than being lost. `SessionStore` gained five `#[doc(hidden)]` methods with pass-through default bodies (`load_session_for_update`, `try_load_session_for_update`, `try_store_session_from_checkout`, `cancel_session_checkout`, `complete_session_checkout`), so existing custom `SessionStore` implementations keep compiling unchanged — only a backend that wants the destructive-update fast path needs to implement them. ### Unboxed identity and session hooks As of [#1124](https://github.com/oxidezap/whatsapp-rust/pull/1124), the same "sync hook first, boxed async fallback second" pattern above extends to three more per-message checks: `IdentityKeyStore::try_is_trusted_identity`, `IdentityKeyStore::try_save_identity`, and `SessionStore::try_has_session`. `#[async_trait]` boxes every store method into a `Pin>`, regardless of whether the answer is already known. The bundled `IdentityAdapter`'s async `is_trusted_identity` body is an unconditional `Ok(true)` — WA Web `isTrustedIdentity` parity, since identity *changes* surface through `save_identity` rather than through trust checks. That box was therefore pure overhead, paid once per encrypt and once per decrypt. Each hook defaults to `None`, and `None` means "I cannot answer synchronously" — never "the answer is the default": * `try_save_identity` declines when nothing is cached for the address, so the caller reads the backend. Answering from an empty cache would report every identity as new. * `try_has_session` declines when the cache cannot answer, rather than reporting "no session" and forcing a needless session rebuild. * `try_is_trusted_identity` can always answer **for the bundled adapter specifically**, because its async `is_trusted_identity` is already the unconditional `Ok(true)` above. This is not a general license to short-circuit trust checks: a custom `IdentityKeyStore` whose async `is_trusted_identity` enforces a real policy must not copy this hook verbatim — returning `Some(Ok(true))` unconditionally would let it bypass that policy. Such a store should return `None` unless it can decide synchronously without skipping any of its own trust logic. Three free functions in `wacore::libsignal::protocol` — `is_trusted_identity`, `save_identity`, `has_session` — express "ask the hook, then await the fallback" once instead of at each of the eight call sites that previously called the trait methods directly: ```rust theme={null} // wacore/libsignal/src/protocol/storage/traits.rs pub async fn is_trusted_identity( store: &S, address: &ProtocolAddress, identity: &IdentityKey, direction: Direction, ) -> Result { match store.try_is_trusted_identity(address, identity, direction) { Some(answer) => answer, None => store.is_trusted_identity(address, identity, direction).await, } } ``` `message_encrypt`, `message_decrypt_signal`, and `ensure_sessions_for_devices` now call through these resolvers instead of the trait methods directly. The hooks are purely additive: a custom `IdentityKeyStore`/`SessionStore` that implements only the async methods keeps compiling and behaving unchanged, since declining both hooks falls through to exactly the async path it always ran. ### Arc previous sessions `SessionRecord.previous_sessions` is wrapped in `Arc>`, making clone O(1) for the \~40 archived previous sessions that previously accounted for \~40% of the serialize cost: ```rust theme={null} // wacore/libsignal/src/protocol/state/session.rs pub struct SessionRecord { current_session: Option, /// Wrapped in Arc so cloning is O(1). Only mutated on rare paths via Arc::make_mut. previous_sessions: Arc>, } ``` Only rare operations (archive current session, promote previous session, take/restore during session setup) trigger `Arc::make_mut` and a deep copy. ### Chain key buffer reuse As of [#1137](https://github.com/oxidezap/whatsapp-rust/pull/1137), advancing a chain key no longer allocates a fresh buffer for its persisted 32-byte key material on every step. `SessionState` stores each chain key's bytes as `Option` on the underlying protobuf `ChainKey` field. `Bytes` is immutable, so writing the ratcheted key used to be an unconditional `Bytes::copy_from_slice(..)` on every send or receive that advances a session's chain key. In the harness benchmark that motivated this change — a single-device 1:1 pingpong session — a full message round trip advances chain keys three times (twice sending, once receiving); a real send can touch more sessions than that, since [DM device fanout](#dm-device-fanout) encrypts separately for every resolved recipient and own-device session. `write_chain_key` (`wacore/libsignal/src/protocol/state/session.rs`) instead reuses the existing buffer in place when it safely can: ```rust theme={null} fn write_chain_key(field: &mut Option, key: &[u8]) { if let Some(existing) = field.take() && existing.len() == key.len() && let Ok(mut owned) = existing.try_into_mut() { owned.copy_from_slice(key); *field = Some(owned.freeze()); return; } *field = Some(bytes::Bytes::copy_from_slice(key)); } ``` Reuse only happens when both guards pass: `try_into_mut()` succeeds solely when the `Bytes` is uniquely owned (no other clone observing the old key), and the length check keeps a differently-sized buffer (e.g. from a legacy record) from reaching `copy_from_slice` at all — like the slice method it resolves to via `DerefMut`, a length mismatch there panics rather than writing anything. Either guard failing falls back to the original allocating behavior. In steady state a checked-out session record is uniquely owned — the cache takes it out of its `Arc` via `try_unwrap` (see [Session object cache](#session-object-cache) above) — so the fallback is rare. ### Redundant signal store write elimination The `SignalStoreCache` uses targeted deduplication strategies per store type. For identities (which rarely change), `put_dedup()` compares incoming bytes against the cached value and skips if identical: ```rust theme={null} // wacore/src/store/signal_cache.rs — ByteStoreState fn put_dedup(&mut self, address: &str, data: &[u8]) { if let Some(Some(existing)) = self.cache.get(address) && existing.as_ref() == data { return; // Skip — data unchanged, no dirty mark } self.put(address, data); } ``` Sessions and sender keys use unconditional `put()` since they change with every message — dedup would always fail and waste CPU cycles. This split avoids unnecessary database writes during `flush()` while not adding overhead where it provides no benefit. ### Key reuse in cache The `key_for()` method on `SessionStoreState`, `SenderKeyStoreState`, and `ByteStoreState` reuses existing `Arc` keys from the HashMap via `get_key_value()`, avoiding a heap allocation on every cache operation: ```rust theme={null} fn key_for(&self, address: &str) -> Arc { match self.cache.get_key_value(address) { Some((existing, _)) => existing.clone(), // O(1) refcount bump None => Arc::from(address), // Only on first insert } } ``` On the hot path (put/delete for addresses already in the cache), this is always a refcount bump instead of a heap allocation. ### Single-allocation session lock keys Session lock keys use the full Signal protocol address string (e.g., `5511999887766@c.us.0`). The `JidExt` trait provides methods for generating these strings, defined in `wacore/src/types/jid.rs`: ```rust theme={null} pub trait JidExt { /// Construct a fresh ProtocolAddress for this JID. fn to_protocol_address(&self) -> ProtocolAddress; /// Signal address string: `{user}[:device]@{server}` /// Device part only included when device != 0. fn to_signal_address_string(&self) -> String; /// Full protocol address string: `{signal_address_string}.0` /// Equivalent to `to_protocol_address().to_string()` but avoids the /// intermediate ProtocolAddress allocation — one String instead of two. fn to_protocol_address_string(&self) -> String; /// Rewrite a reusable ProtocolAddress in place for this JID. /// See [Reusable hot-loop address construction](#reusable-hot-loop-address-construction). fn reset_protocol_address(&self, addr: &mut ProtocolAddress); } ``` `to_protocol_address_string()` is used on hot paths (message encryption and decryption) as the key for `session_locks`. It pre-sizes the output buffer and builds the `String` in a single allocation. Constructing a `ProtocolAddress` itself no longer allocates for addresses that fit inline (see [Single-buffer ProtocolAddress](#single-buffer-protocoladdress) below), but `.to_string()` on top of it still does, so `to_protocol_address_string()` remains the cheaper path when only the string is needed. The `write_protocol_address_to()` free function provides the same formatting but writes into a caller-supplied `&mut String` buffer, enabling buffer reuse across multiple JIDs. **Format examples:** | JID | Signal address | Protocol address string | | --------------------------------- | ----------------------- | ------------------------- | | `5511999887766@s.whatsapp.net` | `5511999887766@c.us` | `5511999887766@c.us.0` | | `5511999887766:33@s.whatsapp.net` | `5511999887766:33@c.us` | `5511999887766:33@c.us.0` | | `123456789@lid` | `123456789@lid` | `123456789@lid.0` | | `123456789:33@lid` | `123456789:33@lid` | `123456789:33@lid.0` | The server `s.whatsapp.net` is mapped to `c.us` in address strings, matching WhatsApp Web's internal format. The trailing `.0` is the Signal device\_id (always 0 in WhatsApp's usage). **Usage in message processing:** ```rust theme={null} // In message decryption (src/message.rs) — single lock per sender device let signal_addr_str = sender_encryption_jid.to_protocol_address_string(); let session_mutex = self.session_locks .get_with(signal_addr_str.clone(), async { Arc::new(async_lock::Mutex::new(())) }).await; let _session_guard = session_mutex.lock().await; // In peer message encryption (src/send.rs) — single lock let signal_addr_str = encryption_jid.to_protocol_address_string(); // In DM message encryption (src/send.rs) — per-device locks for all devices, // resolved once per dm_devices_memo entry and served to every later send // (see "DM per-device locking" below for the resolve-and-memoize step) let session_guards = self.session_guards_for(addressing.lock_keys()).await; // Each lock taken as its mutex is resolved, in sorted order, to prevent deadlocks ``` **DM multi-device fanout:** The DM send path resolves all known recipient devices and own companion devices, encrypting per-device for each. This matches WA Web's `WAWebSendUserMsgJob` behavior where the local device table is read on the send path, and `WAWebDBDeviceListFanout` filters out hosted devices. The client checks the local device registry first (via `get_devices_from_registry()`); a network fetch is only triggered on a cache miss to avoid unnecessary LID-migration side effects from `get_user_devices`. The sender device is excluded (matching WA Web's `isMeDevice` in `getFanOutList`), and for self-DMs, overlapping device lists are deduplicated using a `HashSet` (matching WA Web's `Map` keyed by `toString`). **Own-device namespace alignment (v0.6):** When the recipient is addressed in the LID namespace (`@lid`), the client converts its own companion devices from the PN namespace to LID before fanning out. Without this alignment, a `` mix of `@lid` and `@s.whatsapp.net` participants caused the server to reject the stanza for LID-addressed DMs. Outgoing messages to PN-addressed recipients are unaffected. The recipient's namespace here is decided by [`resolve_dm_wire_jid()`](#dm-wire-namespace-vs-signal-session-addressing), not a raw mapping lookup — on an account that isn't 1:1-LID-migrated, the recipient (and therefore the whole fanout) stays PN even when a LID mapping is cached. Own companion devices (your other linked devices) receive per-device encryption for multi-device self-sync via `DeviceSentMessage`. WA Web has a bare-`` fast path for single primary device (`WAWebSendMsgCreateFanoutStanza`). This is not implemented in whatsapp-rust because `encrypt_for_devices` always wraps in `` nodes. The `` form is accepted by the server regardless. **Fail-fast on total encrypt failure (v0.6).** If per-device encryption fails for *every* recipient device, the DM send now returns an error instead of emitting a stanza with an empty participant list (which the server would silently swallow, making the message look sent when it wasn't). A partial failure — some devices encrypt, some don't — still sends to the devices that succeeded. **Recipient and own-device results are checked separately (PR [#1299](https://github.com/oxidezap/whatsapp-rust/pull/1299)).** The recipient half and the own-companion half of the fan-out write into the same `` list. The emptiness check above only ever caught the case where *neither* half produced a node. It missed a stanza built from own companions alone: every recipient device failed, one own companion still encrypted, and the list was non-empty. The server acked that stanza, and the recipient never received it. The recipient half is now checked immediately after it runs, before the own-device half is attempted at all. An empty recipient half (for a non-self destination) or an all-failed recipient half returns `SendError::NoRecipientDevice` — see [`NoRecipientDeviceError`](/api/errors#norecipientdeviceerror). No own-companion sender chain advances for a stanza that isn't going out. The residual empty-participants guard above is now reachable only for a self chat where every own device fails. **DM per-device locking:** To prevent ratchet desync when concurrent sends and receives operate on the same Signal session, the DM path acquires session locks for all devices involved — the bare recipient plus own companion devices. `Client::build_session_lock_keys()` resolves encryption JIDs and sorts them for deadlock-free lock acquisition: 1. Resolves the recipient to its bare encryption JID via `resolve_encryption_jid().to_non_ad()` (stripping device component) 2. Resolves own companion device JIDs 3. Sorts by `(server, user, device)` using `cmp_for_lock_order()` and deduplicates 4. Returns sorted `Vec` — no intermediate `String` allocations needed for sorting Since [#1396](https://github.com/oxidezap/whatsapp-rust/pull/1396), `build_session_lock_keys()` is a thin composition of two pieces the DM path also calls directly: `Client::resolve_encryption_jids()` (step 1+2 above, `resolve_encryption_jid()` over the device list, in order) and a private `sort_session_lock_keys()` (step 3). The group path still calls `build_session_lock_keys()` as one unit through `SendContextResolver::lock_device_sessions()`; the DM path calls the two pieces separately so it can memoize the result on the `dm_devices_memo` entry instead of resolving fresh on every send: ```rust theme={null} // In DM message encryption (src/send.rs) — resolved once per dm_devices_memo // entry, on the first send that needs it let resolved_here; let addressing = match dm_devices.signal_addressing() { Some(addressing) => addressing, None => { let encryption = self.resolve_encryption_jids(dm_devices.devices()).await; let mut lock_keys = encryption.clone(); sort_session_lock_keys(&mut lock_keys); let built = DmSignalAddressing::new(encryption, lock_keys); // A concurrent send may have raced this one to the memo; either way // `addressing` ends up valid for this send. match dm_devices.signal_addressing_or_init(built) { Ok(memoized) => memoized, Err(refused) => { resolved_here = refused; &resolved_here } } } }; self.ensure_e2e_sessions_resolved(addressing.encryption()).await?; // ... stanza building, tctoken scheduling ... let session_guards = self.session_guards_for(addressing.lock_keys()).await; // Each lock taken as its mutex is resolved, in sorted order, to prevent deadlocks ``` A repeat DM to the same chat therefore resolves the recipient's Signal addresses and sorts the lock keys once, not on every send; a mapping change for any device in the fan-out invalidates the whole `dm_devices_memo` entry (see the [memoized Signal addressing](#dmstanzarequest-and-resolveddmdevices) note above), so the next send re-resolves rather than serving a stale address. Resolving every mutex first and then locking each in a second pass (the previous implementation) is still available as `session_mutexes_for()`, but only under `#[cfg(test)]` — production code no longer builds the intermediate `Vec` of mutex handles. The recipient lock key is always the bare form (e.g., `100000012345678@lid.0`), matching the decrypt path's lock format. This ensures send and receive paths serialize on the exact same lock key. Location: `wacore/src/types/jid.rs:4-51`, `src/send.rs:1481-1507` ### Single-buffer ProtocolAddress The `ProtocolAddress` struct stores the full address string `"{name}.{device_id}"` in a single `AddressBuf` buffer, with a `name_len` marker to split name from suffix. Since [#1131](https://github.com/oxidezap/whatsapp-rust/pull/1131), `AddressBuf` is inline-first: addresses up to 47 bytes (`INLINE_CAPACITY`) live inside the value itself with no heap allocation at all — every real WhatsApp address fits, e.g. `"5511987650001:5@c.us.0"` is 22 bytes. A longer address spills to a `String`. Which arm holds the bytes is not part of the value: `Eq`, `Ord`, and `Hash` all read the rendered string via `as_str()`, so an inline-built key finds a heap-spilled entry (and vice versa) in the session cache. That equivalence is what makes the optimization safe rather than a silent cache-miss generator. ```rust theme={null} // wacore/libsignal/src/core/address.rs const INLINE_CAPACITY: usize = 47; pub struct ProtocolAddress { buf: AddressBuf, // inline up to 47 bytes, spills to a `String` beyond that name_len: usize, // marks where the name ends device_id: DeviceId, } impl ProtocolAddress { /// One-shot construction: writes `name` into the buffer and appends the suffix. pub fn new(name: &str, device_id: DeviceId) -> Self; /// An address with no name yet, ready for `reset_with()`. No capacity argument — /// the buffer starts inline and only allocates if an address ever exceeds it. pub fn empty(device_id: DeviceId) -> Self; /// Rewrite the address in place via closure. Single write pass — no intermediate copy. pub fn reset_with(&mut self, write_name: impl FnOnce(&mut AddressBuf)); /// Zero-cost slice of the name portion. pub fn name(&self) -> &str; /// Zero-cost slice of the full buffer ("{name}.{device_id}"). pub fn as_str(&self) -> &str; } ``` Both `name()` and `as_str()` are zero-cost slices into the same buffer — no allocations on access, whether inline or spilled. **API change (PR [#1131](https://github.com/oxidezap/whatsapp-rust/pull/1131)):** `ProtocolAddress::new` now takes `name: &str` instead of an owned `String`. `with_capacity(capacity, device_id)` was removed in favor of `empty(device_id)` — the buffer no longer needs a capacity hint, since it starts inline and only allocates on overflow. `reset_with()`'s closure now receives `&mut AddressBuf` instead of `&mut String`; `AddressBuf` implements `push_str`, `push`, and `std::fmt::Write`, which covers existing call sites. `Debug` on `AddressBuf` (and `ProtocolAddress`) must format `as_str()`, never the backing byte array: clearing an inline buffer only rewinds its length, so the unused tail still holds whichever address occupied it before. A derived `Debug` would print the whole array and could leak an unrelated peer's JID into a log line or error that formats a reused address. ### Reusable hot-loop address construction When iterating over many devices (e.g., during group stanza preparation or session resolution), allocating a fresh `ProtocolAddress` per device is wasteful. The `JidExt` trait provides `reset_protocol_address()` to rewrite a pre-allocated address in place, and `make_reusable_protocol_address()` creates the initial buffer: ```rust theme={null} // wacore/src/types/jid.rs pub fn make_reusable_protocol_address() -> ProtocolAddress { ProtocolAddress::empty(SIGNAL_DEVICE_ID) } pub trait JidExt { /// Rewrite a reusable ProtocolAddress in place for this JID. fn reset_protocol_address(&self, addr: &mut ProtocolAddress); } ``` **Usage in group stanza preparation:** ```rust theme={null} // wacore/src/send.rs — session resolution loop let mut reusable_addr = make_reusable_protocol_address(); for device_jid in devices { // Rewrite the same buffer — no new allocation per device device_jid.reset_protocol_address(&mut reusable_addr); if stores.session_store.load_session(&reusable_addr).await?.is_some() { // Session exists — use it } } ``` Since [#1131](https://github.com/oxidezap/whatsapp-rust/pull/1131), a fresh `ProtocolAddress` for a typical WhatsApp address (up to 47 bytes) is already allocation-free — inline storage covers it. Reuse still matters for addresses that spill to the heap: the first spill on a reused buffer still allocates its backing `String`, but every reset after that keeps the existing allocation instead of dropping and reallocating one per device — for a group with 100 participant devices past the inline limit sharing a reused buffer, that saves up to 99 heap allocations on the send path. Use `to_protocol_address()` for one-shot address construction (e.g., cache keys, single lookups). Use `make_reusable_protocol_address()` + `reset_protocol_address()` when iterating over multiple JIDs in a tight loop. Location: `wacore/libsignal/src/core/address.rs`, `wacore/src/types/jid.rs`, `wacore/src/send.rs` ### Zero-Allocation JID Deduplication Group stanza preparation needs to deduplicate participant JIDs at two stages: before device resolution (by user identity) and after LID conversion (by device identity). Two utility functions in `wacore/src/types/jid.rs` handle this with in-place sorted dedup instead of `HashSet` allocations: ```rust theme={null} /// Sort and deduplicate by user identity (user + server). pub fn sort_dedup_by_user(jids: &mut Vec); /// Sort and deduplicate by device identity (user + server + device + integrator + identity_agent). pub fn sort_dedup_by_device(jids: &mut Vec); ``` `sort_dedup_by_device` keys on `Jid::identity_agent()` rather than the raw `agent` field, so its notion of "same device" matches exactly what `Jid`'s `PartialEq`/`Hash` already treat as equal (see [Binary Protocol](/advanced/binary-protocol#jid-encoding) for why the two fields differ). That has to hold in both directions: keying on the raw `agent` would let two JIDs that are actually one device — an inert agent byte on `Pn`/`Lid`/`Hosted`/`HostedLid`, same AD-JID, same Signal address — both survive the dedup and pick up two concurrent encryption jobs against one session; dropping `agent` from the key entirely would go too far the other way and silently collapse two genuinely distinct `@bot`/`@interop` devices, which *do* render it, losing a fan-out destination. Both use `sort_unstable_by` followed by `dedup_by`, comparing JID fields directly without allocating intermediate strings or hash sets. This is more efficient than the `HashSet<(String, String)>` approach because: * No per-JID `String::clone()` for hash keys * No `HashSet` allocation or hashing overhead * Stable dedup order (sorted) instead of hash-dependent iteration **Usage in group sends (`wacore/src/send.rs`):** ```rust theme={null} // Before device resolution — dedup participants by user identity sort_dedup_by_user(&mut jids_to_resolve); // After LID conversion — dedup devices by full device identity // Catches duplicates where both phone and LID queries resolve // to the same device (e.g., 559980000003:33 and 100000037037034:33@lid) sort_dedup_by_device(&mut resolved_list); ``` Location: `wacore/src/types/jid.rs:33-51` ### Take/Restore Pattern Avoids cloning session states during decryption attempts: ```rust theme={null} // Take ownership instead of cloning if let Some(mut current_state) = record.take_session_state() { let result = decrypt_message_with_state(&mut current_state, ...); match result { Ok(ptext) => { record.set_session_state(current_state); return Ok(ptext); } Err(e) => { record.set_session_state(current_state); // Restore } } } ``` Location: `wacore/libsignal/src/protocol/session_cipher.rs:495-564` ### Buffer Reuse Thread-local buffers eliminate per-message allocations: ```rust theme={null} struct EncryptionBuffer { buffer: Vec, usage_count: usize, } const INITIAL_CAPACITY: usize = 1024; const MAX_CAPACITY: usize = 16 * 1024; const SHRINK_THRESHOLD: usize = 100; fn get_buffer(&mut self) -> &mut Vec { self.usage_count += 1; if self.usage_count.is_multiple_of(SHRINK_THRESHOLD) { if self.buffer.capacity() > MAX_CAPACITY { self.buffer = Vec::with_capacity(INITIAL_CAPACITY); } } &mut self.buffer } ``` Location: `wacore/libsignal/src/protocol/session_cipher.rs:20-54` ### Prewarming sender-key derivations `SenderKeyState` memoizes two curve derivations behind `OnceLock`s: the signing key's Edwards conversion (`PrivateKey::precompute_signing_cache`) and the verifier's Edwards entries (`PreparedVerifyingKey::precompute`). Both are expensive relative to the rest of an encrypt/decrypt. Both are cheap to reuse, as long as something keeps the memo warm across operations. If you keep the loaded `SenderKeyRecord` between operations, you get that for free. If you discard it instead — for example, if you persist through [`into_components()`](#record-components) and reconstruct the record on every load — you previously had no way to keep the warm derivation either. The memo lives on the record, and the `OnceLock`s only fill from inside. As of [#1213](https://github.com/oxidezap/whatsapp-rust/pull/1213), `SenderKeyState` exposes two setters. Use them to hand back a derivation you already computed, instead of letting the state re-derive it: ```rust theme={null} impl SenderKeyState { pub fn prewarm_signing_key(&self, key: PrivateKey) -> Result<(), InvalidSenderKeySessionError>; pub fn prewarm_verifying_key( &self, verifier: PreparedVerifyingKey, ) -> Result<(), InvalidSenderKeySessionError>; } ``` Both take `&self` — `OnceLock::set` doesn't need `&mut`. Reach them through `SenderKeyRecord::sender_key_state()` right after a fresh load: ```rust theme={null} let record = SenderKeyRecord::from_components(components)?; let state = record.sender_key_state()?; state.prewarm_signing_key(cached_signing_key.clone())?; // send side state.prewarm_verifying_key(cached_verifier.clone())?; // receive side ``` * **Correspondence is checked, not assumed.** If you pass a key or verifier that doesn't belong to this state's signing key, it's rejected with `InvalidSenderKeySessionError`, and the state is left free to derive for itself. Accepting foreign material without checking would sign or verify under the wrong key. The check is cheap on both sides: `PreparedVerifyingKey` already holds the Montgomery bytes, so `PreparedVerifyingKey::is_for(&PublicKey)` is a 32-byte comparison. A private key compares by its clamped serialized form. Neither redoes the derivation you're trying to skip. * **Already-warm is a no-op, not an error.** If you prewarm a memo that's already populated — by a prior prewarm, or by lazy use — it's left alone. The value in place is the same derivation, so surfacing an error would force you to track state that changes nothing. * **Hold the key warm before caching it, or you lose the payoff.** Every read of the signing-key memo hands out a clone, and `PrivateKey`'s Edwards cache is a per-instance `OnceLock` — a clone of a cold key re-derives on its own. `prewarm_signing_key` warms whatever you give it before storing it, so a single cold call still leaves the memo warm. But if you repeatedly hand over cold clones, you pay the derivation every time. Call `PrivateKey::precompute_signing_cache()` once when the key first enters your own cache, then clone the warm copy into each `prewarm_signing_key` call. You don't need that care on the verifying-key side: `PreparedVerifyingKey`'s entries sit behind a shared handle, so warming one instance warms every clone of it, including the copy in your cache. * **You take on the cache's lifetime.** Injecting a derivation makes you responsible for how long it — and any private material in it — lives, which the state would otherwise own only for its own lifetime. If you call neither setter, you're unaffected: the lazy derivation behaves exactly as before. Benchmarked in `wacore/benches/sender_key_derivation_benchmark.rs` against a record kept warm the whole time and one rebuilt from components with no prewarming. CI reads these benchmarks by instruction count rather than wall-clock time — the figures below are simulated microseconds modeled from that count, stable across runs, and comparable to each other but not to a real-time measurement: | | rebuilt, no prewarm | prewarmed | kept warm | | ------------- | ------------------- | ------------------ | ------------------ | | group encrypt | 239.6 simulated us | 158.7 simulated us | 149.6 simulated us | | group decrypt | 230.6 simulated us | 189.6 simulated us | 180.9 simulated us | Prewarming recovers roughly 90% of the gap between rebuilding cold and keeping the record warm for group encrypt, and 82% for group decrypt; what's left is the correspondence check plus the caller-side cache lookup — real work a record that's never discarded doesn't pay. Location: `wacore/libsignal/src/protocol/sender_keys.rs`, `wacore/libsignal/src/core/curve.rs` ### Stack-backed buffers for addon crypto, ADV validation, and prekeys As of [#1324](https://github.com/oxidezap/whatsapp-rust/pull/1324), more short-lived allocation sources — addon crypto, ADV validation, `PreKeyBundle`, the prekey store bridges, and session serialization — move to stack-backed storage, without changing the wire format, the crypto, or any existing call site. **Addon crypto** (reactions, poll votes, message edits, comments): `derive_use_case_secret`'s HKDF `info` string (`stanzaId || parentSender || modificationSender || `) and `build_aad`'s AAD buffer move from `Vec` to `SmallVec` aliases: ```rust theme={null} // wacore/src/secret_enc_addon.rs type InfoBuffer = SmallVec<[u8; 128]>; pub(crate) type AadBuffer = SmallVec<[u8; 96]>; ``` `InfoBuffer` spills to the heap once `stanza_id.len() + parent_sender.len() + modification_sender.len() + use_case.len()` exceeds 128 bytes. `AadBuffer` spills once `stanza_id.len() + 1 + modification_sender.len()` exceeds 96 bytes. Both limits cover WhatsApp's real addressing shapes with room to spare; neither is enforced by protocol validation, so an unusually long stanza id or JID still works — it just falls back to a heap allocation instead of staying inline. **ADV identity validation** ([above](#adv-companion-identity-validation)): `validate_adv_with_identity_key` decodes through `ADVSignedDeviceIdentityView` instead of an owned `ADVSignedDeviceIdentity`, and the two signed messages verified per prefix family are assembled once into a stack buffer and re-prefixed per candidate, instead of rebuilt with `.concat()` on every attempt: ```rust theme={null} // wacore/src/adv.rs type AdvSigBuffer = SmallVec<[u8; 256]>; ``` The device message is `prefix(2) || details || identity(32) || accountKey(32)`, so this buffer spills once `details.len() + 66` exceeds 256 bytes — comfortably above the couple dozen bytes a real `ADVDeviceIdentity` encodes to. **`PreKeyBundle`**: the signed pre-key signature is a fixed-length XEdDSA signature, so it is now stored inline as `[u8; 64]` instead of behind a `Vec`: ```rust theme={null} // wacore/libsignal/src/protocol/state/bundle.rs pub const SIGNED_PRE_KEY_SIGNATURE_LEN: usize = 64; pub fn new( // ... signed_pre_key_signature: impl TryInto<[u8; SIGNED_PRE_KEY_SIGNATURE_LEN]>, // ... ) -> Result ``` `signed_pre_key_signature` accepts anything that converts into `[u8; 64]`: a `[u8; 64]` fresh off `calculate_signature` passes through with no allocation, while a `Vec` from a store row is checked and rejected with `InvalidArgument` if it is not exactly 64 bytes. Existing call sites passing `Vec` keep compiling unchanged, since `TryFrom> for [T; N]` covers the conversion. **Stored record bridges**: `prekey_structure_to_record` and `signed_prekey_structure_to_record` (`wacore/libsignal/src/store/record_helpers.rs`) used to parse a stored structure's keys just to validate them. They then rebuilt the structure from those same parsed keys. That reallocated both key fields — plus the signature, for the signed variant — on every prekey read. They now validate the caller's structure and adopt it directly, via the new `PreKeyRecord::from_storage` and `GenericSignedPreKey::from_stored_structure`. Both normalize the stored public key exactly as `deserialize` always did, so a pre-0.7 33-byte row still heals on its next write. **`SessionRecord::serialize_into`**: the scratch `Vec` carrying archived states' encoded lengths between the sizing pass and the writing pass is now an 8-slot stack array, with a heap fallback for an archive deeper than that (`previous_sessions` holds 0–3 states in the common case). The source PR measured these with `divan::AllocProfiler`: `PreKeyBundle::new`/`clone` and `SessionRecord::serialize_into` dropped to 0 allocations each (from 1), and the addon/ADV paths lost 1–8 allocations per call depending on the message type and prefix family. That measurement used temporary benches for the addon and ADV paths, since neither had bench coverage before; those benches were removed before merging and are not part of the repository. `SessionRecord::serialize_into` and `PreKeyBundle` are covered by the existing `wacore/libsignal/benches/libsignal_benchmark.rs`, which shows wall time flat within noise — expected, since AES-GCM, HKDF, and XEdDSA verification already dominate each call. Location: `wacore/src/secret_enc_addon.rs`, `wacore/src/adv.rs`, `wacore/libsignal/src/protocol/state/bundle.rs`, `wacore/libsignal/src/store/record_helpers.rs`, `wacore/libsignal/src/protocol/state/session.rs` ## Public API The `client.signal()` accessor exposes low-level Signal protocol operations for direct use. This includes 1:1 and group encryption/decryption, session validation, session deletion, participant node creation, and device resolution. See [Signal API reference](/api/signal) for full method documentation and examples. ## Related Components * [Binary Protocol](/advanced/binary-protocol) - How encrypted messages are serialized * [State Management](/advanced/state-management) - How session state is persisted * [WebSocket Handling](/advanced/websocket-handling) - Transport layer for encrypted messages * [Signal API](/api/signal) - Public API for Signal protocol operations ## References * [Signal Protocol Specification](https://signal.org/docs/) * [libsignal Repository](https://github.com/signalapp/libsignal) * Source: `wacore/libsignal/src/protocol/` * Storage: `src/store/signal.rs`, `src/store/signal_adapter.rs`, `wacore/src/store/signal_cache.rs` # State Management & Persistence Source: https://whatsapp-rust.jlucaso.com/advanced/state-management Device state management, PersistenceManager, and the DeviceCommand pattern in whatsapp-rust ## Overview whatsapp-rust uses a strict state management architecture to ensure consistency and prevent race conditions. All device state modifications must go through the `PersistenceManager` using the `DeviceCommand` pattern. **Critical**: Never modify `Device` state directly. Always use `DeviceCommand` + `PersistenceManager::process_command()` for writes, or `get_device_snapshot()` for reads. ## Architecture The state management system has three main components: ``` src/store/ ├── persistence_manager.rs # Central state coordinator ├── commands.rs # DeviceCommand pattern ├── device.rs # Device state structure └── backend/ # Storage backend (SQLite) ``` ## Device State The `Device` struct holds all client state (defined in `wacore::store::device`): ```rust theme={null} #[derive(Clone, Serialize, Deserialize)] pub struct Device { pub pn: Option, // Phone number JID pub lid: Option, // Linked Identity JID pub registration_id: u32, #[serde(with = "key_pair_serde")] pub noise_key: KeyPair, // Noise protocol keypair #[serde(with = "key_pair_serde")] pub identity_key: KeyPair, // Signal protocol identity #[serde(with = "key_pair_serde")] pub signed_pre_key: KeyPair, pub signed_pre_key_id: u32, #[serde(with = "BigArray")] pub signed_pre_key_signature: [u8; 64], pub adv_secret_key: [u8; 32], #[serde(with = "account_serde", default)] pub account: Option>, pub push_name: String, pub app_version_primary: u32, pub app_version_secondary: u32, pub app_version_tertiary: u32, pub app_version_last_fetched_ms: i64, #[serde(skip)] pub device_props: wa::DeviceProps, // Not persisted #[serde(skip)] pub client_profile: ClientProfile, // Not persisted; noise-handshake identity #[serde(default)] pub edge_routing_info: Option>, #[serde(default)] pub props_hash: Option, #[serde(default)] pub next_pre_key_id: u32, #[serde(default)] pub server_cert_chain: Option, } ``` Location: `wacore/src/store/device.rs` `server_cert_chain` caches the verified server cert chain returned by a successful XX (or XXfallback) handshake. `Device` exposes the leaf key on the next connect so `do_handshake` can attempt Noise IK and skip a server round trip. See [WebSocket & Noise Protocol — Noise Protocol Handshake](/advanced/websocket-handling#noise-protocol-handshake). ### Serialization details The `Device` struct uses several custom serde strategies: | Field | Strategy | Notes | | ------------------------------------------------------------------------- | ------------------- | ------------------------------------------------- | | `noise_key`, `identity_key`, `signed_pre_key` | `key_pair_serde` | Custom serde for Signal `KeyPair` types | | `signed_pre_key_signature` | `BigArray` | Handles fixed-size arrays larger than 32 bytes | | `account` | `account_serde` | Bridges buffa protobuf types to serde (see below) | | `device_props` | `#[serde(skip)]` | Transient, not persisted | | `client_profile` | `#[serde(skip)]` | Transient noise-handshake identity, not persisted | | `edge_routing_info`, `props_hash`, `next_pre_key_id`, `server_cert_chain` | `#[serde(default)]` | Backward-compatible optional fields | The `account` field holds an `ADVSignedDeviceIdentity` (a buffa-generated protobuf type that lacks `serde::Deserialize`). The `account_serde` module bridges this gap by encoding the protobuf struct to bytes on serialization and decoding on deserialization: ```rust theme={null} pub mod account_serde { pub fn serialize( val: &Option>, s: S, ) -> Result { // Encodes to protobuf bytes, then wraps as Option> } pub fn deserialize<'de, D: Deserializer<'de>>( d: D, ) -> Result>, D::Error> { // Deserializes Option>, then decodes protobuf bytes } } ``` The `#[serde(default)]` attribute on `account` ensures backward compatibility — data serialized before this field existed will deserialize with `account: None`. ## PersistenceManager The `PersistenceManager` is the gatekeeper for all state changes. ### Architecture ```rust theme={null} pub struct PersistenceManager { device: Arc>, device_snapshot: std::sync::RwLock>, backend: Arc, dirty: Arc, save_notify: Arc, } ``` `device_snapshot` is rebuilt under the `device` write guard inside `modify_device` — the single mutation funnel — so it is always coherent with committed state. Location: `src/store/persistence_manager.rs` ### Key Methods #### Read-Only Access ```rust theme={null} // Returns the cached Arc snapshot — sync, no clone, no contention against writers pub fn get_device_snapshot(&self) -> Arc { self.device_snapshot.read().unwrap().clone() } ``` Location: `src/store/persistence_manager.rs` #### State Modification ```rust theme={null} // Modify device state with a closure; rebuilds device_snapshot under the write guard pub async fn modify_device(&self, modifier: F) -> R where F: FnOnce(&mut Device) -> R, { let mut device_guard = self.device.write().await; let result = modifier(&mut device_guard); // Rebuild the cached snapshot before releasing the write lock *self.device_snapshot.write().unwrap() = Arc::new(device_guard.clone()); // Mark dirty and notify background saver self.dirty.store(true, Ordering::Relaxed); self.save_notify.notify_one(); result } ``` Location: `src/store/persistence_manager.rs` #### Command Processing ```rust theme={null} // Process a device command (preferred for state changes) pub async fn process_command(&self, command: DeviceCommand) { self.modify_device(|device| { apply_command_to_device(device, command); }).await; } ``` Location: `src/store/persistence_manager.rs:145-150` ### Background Saver The persistence manager runs a background task that periodically saves dirty state: ```rust theme={null} pub fn run_background_saver(self: Arc, interval: Duration) { tokio::spawn(async move { loop { tokio::select! { _ = self.save_notify.notified() => { debug!("Save notification received."); } _ = sleep(interval) => {} } if let Err(e) = self.save_to_disk().await { error!("Error saving device state: {e}"); } } }); } ``` **How it works:** 1. Wakes up when notified OR every `interval` (typically 30s) 2. Checks if state is dirty (`dirty` flag) 3. If dirty, serializes device state and saves to database 4. Clears dirty flag Location: `src/store/persistence_manager.rs:123-140` ### Initialization ```rust theme={null} pub async fn new(backend: Arc) -> Result { // Ensure device row exists in database let exists = backend.exists().await?; if !exists { let id = backend.create().await?; debug!("Created device row with id={id}"); } // Load existing state or create new let device = if let Some(serializable_device) = backend.load().await? { let mut dev = Device::new(backend.clone()); dev.load_from_serializable(serializable_device); dev } else { Device::new(backend.clone()) }; let snapshot = Arc::new(device.clone()); Ok(Self { device: Arc::new(tokio::sync::RwLock::new(device)), device_snapshot: std::sync::RwLock::new(snapshot), backend, dirty: Arc::new(AtomicBool::new(false)), save_notify: Arc::new(Notify::new()), }) } ``` Location: `src/store/persistence_manager.rs:23-55` ## DeviceCommand Pattern The `DeviceCommand` enum defines all possible state mutations: ```rust theme={null} pub enum DeviceCommand { SetId(Option), SetLid(Option), SetPushName(String), SetAccount(Option), SetAppVersion((u32, u32, u32)), SetDeviceProps(DevicePropsOverride), SetClientProfile(ClientProfile), SetPropsHash(Option), SetNextPreKeyId(u32), SetLidMigrated(bool), /// Install a freshly rotated signed pre-key (WA Web `RotateKeyJob`). Sets /// the key trio and stamps the rotation cadence clock in one command so /// they can never be observed split. SetSignedPreKey { key_pair: KeyPair, id: u32, signature: [u8; 64], rotation_ms: i64, }, /// Write the rotation cadence clock without touching the key. Two callers: /// a one-time baseline seed for devices upgraded in with /// `last_signed_pre_key_rotation_ms == 0` (so the first rotation lands a /// full interval out instead of firing immediately on the next connect), /// and a backdated timestamp after a failed upload — see /// `rotation_timestamp_after_failed_upload` — so a transient server error /// buys a short retry and a definitive rejection consumes the cadence /// instead of re-running the upload on every reconnect. SetSignedPreKeyRotationBaseline(i64), } ``` Location: `wacore/src/store/commands.rs` `DeviceCommand` cannot derive `Debug` once a variant holds a `KeyPair` — `KeyPair` deliberately omits `Debug` so private key material never formats into logs. The enum has a hand-written `Debug` impl instead; `SetSignedPreKey`'s logs only `id` and `rotation_ms`, redacting the key pair and signature via `finish_non_exhaustive()`. ### Why Commands? The command pattern provides: 1. **Type safety**: All state changes are explicitly defined 2. **Auditability**: Easy to log/trace state mutations 3. **Testability**: Commands can be tested in isolation 4. **Consistency**: Single code path for all modifications 5. **Future compatibility**: Easy to add undo/redo or migration logic ### Applying Commands Commands are applied via pattern matching: ```rust theme={null} pub fn apply_command_to_device(device: &mut Device, command: DeviceCommand) { match command { DeviceCommand::SetId(id) => { device.pn = id; } DeviceCommand::SetLid(lid) => { device.lid = lid; } DeviceCommand::SetPushName(name) => { device.push_name = name; } DeviceCommand::SetAccount(account) => { device.account = account.map(std::sync::Arc::new); } DeviceCommand::SetAppVersion((p, s, t)) => { device.app_version_primary = p; device.app_version_secondary = s; device.app_version_tertiary = t; } DeviceCommand::SetLidMigrated(migrated) => { device.lid_migrated = migrated; } DeviceCommand::SetSignedPreKey { key_pair, id, signature, rotation_ms } => { device.signed_pre_key = key_pair; device.signed_pre_key_id = id; device.signed_pre_key_signature = signature; device.last_signed_pre_key_rotation_ms = rotation_ms; } DeviceCommand::SetSignedPreKeyRotationBaseline(rotation_ms) => { device.last_signed_pre_key_rotation_ms = rotation_ms; } // ... handle all variants } } ``` `SetLidMigrated` gates outbound DM wire addressing (LID vs. PN) on whether the account is 1:1-LID-migrated. Runtime paths only ever set `true` — `false` is reserved for pair-success, where a fresh pairing of a *different* account must not inherit the previous account's migration state. See [Authentication — one-to-one LID migration state](/concepts/authentication#one-to-one-lid-migration-state). Location: `wacore/src/store/commands.rs` ## Usage Patterns ### Reading device state ```rust theme={null} // sync — no .await needed let snapshot = persistence_manager.get_device_snapshot(); println!("Device JID: {:?}", snapshot.pn); println!("Push name: {}", snapshot.push_name); ``` `get_device_snapshot()` is a plain `fn` (not `async`) and returns `Arc` — a refcount bump, no `Device` clone. Borrow fields directly from the held `Arc`; clone individual fields only when ownership escapes the scope. Don't hold the snapshot across an await point — doing so pins an old allocation while the live state may have advanced. ### Modifying device state (simple) For simple state changes, use commands: ```rust theme={null} use wacore::store::commands::DeviceCommand; // Update push name persistence_manager.process_command( DeviceCommand::SetPushName("My New Name".to_string()) ).await; // Update props hash persistence_manager.process_command( DeviceCommand::SetPropsHash(Some("new_hash".to_string())) ).await; ``` ### Modifying device state (complex) For complex logic involving multiple fields or conditionals: ```rust theme={null} persistence_manager.modify_device(|device| { // Complex mutation logic device.push_name = "Updated Name".to_string(); device.edge_routing_info = Some(new_routing_data); }).await; ``` Keep the closure passed to `modify_device` as short as possible. It holds a write lock on the device state, blocking all other modifications. ## Concurrency Patterns ### RwLock Semantics Device state uses two separate locks with different roles: * **`device_snapshot` (`std::sync::RwLock>`)**: read by `get_device_snapshot()`. Readers never contend with writers — they take a brief `std::sync` read lock to clone the `Arc`, then release immediately. The snapshot is updated inside the `tokio::sync::RwLock` write guard in `modify_device`, so readers always see fully committed state. * **`device` (`tokio::sync::RwLock`)**: write-locked inside `modify_device()`. As of [whatsapp-rust#1226](https://github.com/oxidezap/whatsapp-rust/pull/1226), the Signal store adapters (`SignalProtocolStoreAdapter`, `SenderKeyAdapter`) no longer take a read lock here on every operation — they hold `Arc` and call `get_device_snapshot()` instead. The old read-lock approach let concurrent Signal reads coexist, but `tokio::sync::RwLock` is write-preferring. A `process_command` write arriving mid-round-trip queued behind a held read guard, and every later reader then queued behind that writer. So one slow backend round-trip could delay a device mutation and, through it, every other Signal operation waiting on the lock. Adapters no longer hold this lock at all, so that cascade can no longer start. `get_device_arc()` still returns a handle to this lock for any caller that needs `&mut Device` trait access directly; store adapters are no longer among them. In practice: `get_device_snapshot()` is contention-free for readers, and the tokio write lock is only held during actual mutations (rare). ### Session locks and message queues The `Client` uses two cache-based lock mechanisms for per-chat and per-device serialization: ```rust theme={null} pub struct Client { /// Per-device session locks for Signal protocol operations. /// Prevents race conditions when multiple messages from the same sender /// are processed concurrently across different chats. /// Keys are Signal protocol address strings (e.g., "user@s.whatsapp.net:0") pub(crate) session_locks: Cache>>, /// Per-chat lane combining enqueue lock + message queue into a single cached entry. /// One cache lookup instead of two per incoming message. pub(crate) chat_lanes: Cache, // ... } ``` Both use `PortableCache` with capacity-based eviction (configurable via `CacheConfig`), so stale entries are automatically cleaned up. `ChatLane` itself (`src/client.rs`) carries a third field, `worker_running: Arc>`, alongside `enqueue_lock` and `queue_tx`: ```rust theme={null} pub(crate) struct ChatLane { pub enqueue_lock: Arc>, pub queue_tx: async_channel::Sender, /// Held by the lane's worker for as long as it runs; a replacement /// worker takes it before its first message. pub worker_running: Arc>, } ``` A lane's worker awaits the inbound-message future inline rather than boxing it per message, so the task holds that future's whole state machine (\~9 KiB) for as long as the worker lives — message or no message. Left running for the connection's lifetime, that adds up to one such future per chat that ever spoke, bounded only by `chat_lanes_capacity` (5,000 by default): if your client stays active in a few thousand groups, idle workers alone could park tens of MiB. To bound this, a worker now exits after 60 seconds without a new message (`LANE_IDLE_TIMEOUT` in `src/handlers/message.rs`), closing its queue on the way out. The next message for that chat sees the closed queue, and the handler transparently replaces the lane with a fresh one — `enqueue_lock` and `worker_running` carry over from the predecessor rather than being re-minted, so enqueue order for the chat stays a single total order across the swap, and the new worker waits on `worker_running` for the old one to finish draining before it starts processing. A chat that keeps receiving never pays this cost: the worker arms the idle timer only once its queue is empty. On disconnect, `chat_lanes` is cleared via the async `clear()` to drop per-chat queue senders. This causes worker tasks from the old connection to exit via channel close, preventing them from surviving reconnects with outdated Signal session state that would cause decryption failures. See [disconnect cleanup](/concepts/architecture#disconnect-cleanup) for the full list of resources reset on disconnect. Location: `src/client.rs`, `src/handlers/message.rs` ### Blocking Operations CPU-heavy or blocking operations must use `spawn_blocking` to avoid stalling the async runtime: ```rust theme={null} use tokio::task::spawn_blocking; // Bad: Blocks async runtime let encrypted = expensive_crypto_operation(&data); // Good: Offloads to thread pool let encrypted = spawn_blocking(move || { expensive_crypto_operation(&data) }).await?; ``` For more details on async patterns, see the [Architecture](/concepts/architecture) guide. ## Storage Backend ### Backend Trait The `Backend` trait is a combination of four domain-specific traits: ```rust theme={null} pub trait Backend: SignalStore + AppSyncStore + ProtocolStore + DeviceStore + Send + Sync {} impl Backend for T where T: SignalStore + AppSyncStore + ProtocolStore + DeviceStore + Send + Sync {} ``` See [Storage Traits](/api/store) for the full trait definitions. Location: `wacore/src/store/traits.rs` ### SQLite Implementation The default storage backend uses SQLite with the Diesel ORM. See [Storage Traits](/api/store#sqlitestore-implementation) for details on `SqliteStore`, including connection pooling, WAL mode, and multi-device support. Location: `storages/sqlite-storage/` ### Serialization The `Device` struct derives `Serialize` and `Deserialize` (from serde) for persistence. The `PersistenceManager` handles serializing device state to the backend via the `DeviceStore` trait's `save()` and `load()` methods. ## Debugging State ### Database Snapshots The `debug-snapshots` feature enables database snapshots for debugging: ```rust theme={null} // In error handler if let Err(e) = decrypt_message(...) { persistence_manager.create_snapshot( "decrypt_error", Some(error_details.as_bytes()) ).await?; return Err(e); } ``` This creates a timestamped copy of the database: ``` chats.db chats_snapshot_decrypt_error_20260228_143022.db chats_snapshot_decrypt_error_20260228_143022.txt (metadata) ``` Location: `src/store/persistence_manager.rs:99-121` ### Logging State changes are logged at debug level: ```rust theme={null} RUST_LOG=whatsapp_rust::store=debug cargo run ``` Output: ``` [DEBUG] PersistenceManager: Ensuring device row exists. [DEBUG] PersistenceManager: Loaded existing device data (PushName: 'Alice') [DEBUG] Device state is dirty, saving to disk. [DEBUG] Device state saved successfully. ``` ## Best Practices ### 1. Always use commands for state changes ```rust theme={null} // Bad: Direct modification persistence_manager.modify_device(|device| { device.push_name = "New Name".to_string(); }).await; // Good: Use command persistence_manager.process_command( DeviceCommand::SetPushName("New Name".to_string()) ).await; ``` ### 2. Minimize lock duration ```rust theme={null} // Bad: Long lock duration persistence_manager.modify_device(|device| { let data = expensive_calculation(&device.pn); // Blocks all access! device.push_name = data; }).await; // Good: Release lock during expensive operation let snapshot = persistence_manager.get_device_snapshot(); let data = expensive_calculation(&snapshot.pn); persistence_manager.process_command( DeviceCommand::SetPushName(data) ).await; ``` ### 3. Use chat locks for chat-specific operations ```rust theme={null} // Per-chat locks serialize operations on the same chat // The Client uses session_locks and chat_lanes internally // to prevent race conditions during message processing. ``` ### 4. Offload heavy operations ```rust theme={null} use tokio::task::spawn_blocking; // Crypto operations should use spawn_blocking let ciphertext = spawn_blocking(move || { encrypt_message(&plaintext, &key) }).await??; ``` ## Cache patching strategy Beyond device state, whatsapp-rust maintains several in-memory caches (device registry, group metadata, LID-PN mappings) that require real-time updates when server notifications arrive. ### Granular patching vs. invalidation The client uses **granular cache patching** rather than the simpler invalidate-and-refetch approach. When a notification indicates a change (for example, a new device added or a group participant removed), the client reads the cached value, applies the diff in memory, and writes the updated value back — all without making any network requests. This avoids an extra IQ round-trip per update. If no cache entry exists when the notification arrives, the patch is silently skipped, and the next read fetches authoritative state from the server. ```rust theme={null} // Internal patching pattern (not a public API) if let Some(mut cached) = cache.get(&key).await { cached.apply_change(notification_data); cache.insert(key, cached).await; } ``` ### Concurrency model The `get` → mutate → `insert` sequence is **not atomic**. A concurrent notification for the same key could race and cause one update to be lost. This is acceptable because: 1. The cache is best-effort — a full server fetch on the next read corrects any drift 2. Races are rare in practice (device and group notifications for the same user rarely overlap) 3. Device registry patches persist to the backend store immediately, so even if the cache entry is evicted, the persistent state is correct ### Where patching is used | Cache | Patched on | Persisted | Fallback / conflict behavior | | --------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Device registry | Device add/remove/update notifications | Yes (backend store) | Hash-only notifications trigger full invalidation | | Group metadata | Participant add/remove notifications and API calls | No (cache-only) | `leave()` and cache eviction trigger server re-fetch | | LID-PN mappings | Directed sources overwrite; observational bulk sources only seed new LIDs (see [`LearningSource`](/api/client#learningsource)) | Yes (backend store) | Conflict: source-aware write policy gates overwrites; observational mismatches queue a live re-resolve. The server can also flag a mapping stale directly, via `` on a send — the client re-resolves it the same way (see [Client — refresh on `refresh_lid` acks](/api/client#refresh-on-refresh_lid-acks)) | For full details including the write-policy gate, the live-query reconcile path, and data flow, see [Granular cache patching](/concepts/storage#granular-cache-patching). ## Related Components * [Signal Protocol](/advanced/signal-protocol) - Cryptographic state stored in Device * [Binary Protocol](/advanced/binary-protocol) - Protocol messages modify device state * [WebSocket Handling](/advanced/websocket-handling) - Connection state in Device * [Storage](/concepts/storage) - Cache patching and pluggable cache stores ## References * Implementation: `src/store/persistence_manager.rs` * Commands: `wacore/src/store/commands.rs` * Device structure: `wacore/src/store/device.rs` * Backend trait: `wacore/src/store/traits.rs` * SQLite backend: `storages/sqlite-storage/src/sqlite_store.rs` # WAM telemetry Source: https://whatsapp-rust.jlucaso.com/advanced/wam-telemetry Emit WhatsApp Metrics (WAM) — the binary telemetry buffer the official client uploads about itself — from a generated event catalog, behind an opt-in native plugin. ## Overview WAM (WhatsApp Metrics) is the telemetry the official WhatsApp client uploads about itself: a binary buffer of numbered events, preceded by buffer-level globals, sent under ``. whatsapp-rust has never sent any of it. Two workspace crates now make a small, honest subset of it possible: | crate | what it is | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `whatsapp-rust-wam-catalog` | A generated catalog — every event, field id, enum member, global, and constant WA Web declares (436 events, 8,112 fields) — plus a byte-exact buffer codec. | | `whatsapp-rust-plugin-wam` | The runtime: observation, sampling, buffering, flush, and upload, packaged as a [native plugin](/advanced/plugins). | Neither crate is in the default build, and neither is published to crates.io yet — add them as `git` dependencies the same way you would [`passkey`](/concepts/authentication#passkey-linking-shortcake_passkey) or [`voip`](/guides/voip-calls) ahead of a release that carries them. This is the client reporting on itself, not a message-content feature. Read [Privacy and PII](#privacy-and-pii) before enabling it in an application that handles user data you don't control. ## Why a plugin, not a built-in subsystem [Native plugins](/advanced/plugins) exist precisely for this shape of extension: something that wants to *watch* work the core already does, rather than claim a stanza tag, notification type, or IQ namespace of its own. WAM claims none of those, so it attaches through the plugin host's event-observation capability instead of becoming a feature-gated part of the core. The practical consequence is that a default build — and a build with `plugins` enabled but this plugin not installed — carries none of this. Installing `WamPlugin` is what turns on: * Three additional core events flowing to a subscriber for the life of the client (`DecryptedPayload`, `EncDecryptFailed`, `RawNode` — the last is filtered to `` stanzas). * The plugin's own buffering, flush, and upload task. ## Enabling it ```toml Cargo.toml theme={null} [dependencies] whatsapp-rust = { git = "https://github.com/oxidezap/whatsapp-rust", rev = "", features = ["plugins"] } whatsapp-rust-plugin-wam = { git = "https://github.com/oxidezap/whatsapp-rust", rev = "" } ``` ```rust theme={null} use whatsapp_rust_plugin_wam::{WamConfig, WamPlugin}; let client = Client::builder() // platform dependencies: backend, transport, http client, runtime... .with_plugin(WamPlugin::new(WamConfig::default())) .build() .await? .into_client(); let wam = client .plugin::() .expect("wam plugin is installed"); println!("{:?}", wam.stats()); ``` `WamPlugin` requests the `CoreEvents`, `Tasks`, and `Iq` [capabilities](/advanced/plugins#capabilities) — nothing else. Construct a fresh `WamPlugin` per `Client`; like every plugin, it's install-once for that client's lifetime. ## The honesty rule **An event is emitted only when every field it writes is honestly derivable from activity this client actually observed.** No invented value, no sentinel, no placeholder standing in for something the client can't actually see. A field this client doesn't know is left absent — the wire format distinguishes absent from zero, so the server can tell the difference. That rule, not implementation effort, is why the catalog carries 436 events and the plugin emits seven of them. ## What it emits Each event is derived from the core event stream, at the unit it actually counts: | event | derived from | the unit it counts | | ------------------------------------ | ------------------------------------------- | ---------------------------------------- | | `E2eMessageRecv` | `DecryptedPayload`, some `EncDecryptFailed` | one `` this client tried to decrypt | | `MessageReceive` | `Messages` | one decrypted message | | `ReceiptStanzaReceive` | `RawNode`, filtered to `` | one inbound receipt stanza | | `WebcSocketConnect` | `Connected` | one authenticated socket | | `WebWamForceFlush` | plugin shutdown | one flush ahead of schedule | | `WamClientErrors`, `WamDroppedEvent` | the runtime's own losses | one abandoned buffer or event | All seven upload on the `regular` channel. A conformance test (`plugins/wam/src/parity.rs`) encodes every one of them at its maximum through the real codec and checks every field id that reaches the wire against what WA Web itself writes at that call site, so a field this plugin writes that the official client doesn't is a build failure, not a runtime surprise. ## What it does not emit, and why * **Anything derived from sending a message (18 events).** `MessageSend`, `MediaUpload2`, `StatusPost`, `E2eMessageSend`, and the rest of that family describe an outgoing message's type, media, per-device encryption count, and stage timings. The core currently publishes only `Event::SentFrame` — the marshaled bytes of a stanza after the write — which carries none of that semantic information. Re-deriving a 95-field event from a frame would be reconstruction, not observation. * **The `private` channel (50 events).** These need a blind-signed token and a persisted, rotating anonymous id, neither of which this client has. * **Two inbound-only counters.** `MessageHighRetryCount` and `MdRetryFromUnknownDevice` describe values this client already tracks internally, but nothing puts them on the event bus yet — a plugin can only see events, not internal counters. * **Two browser-only lifecycle facts.** `WebcPageResume` and `WebcStreamModeChange` describe WA Web's own page/stream model, which this client has no equivalent of. * **Beaconing.** The official client gives a small fraction of clients a per-event sequence number, rolled once per UTC day. Getting that right needs a durable, per-event counter that survives every restart — without one, a process that restarts several times a day would roll several times and skew any cohort built on the "once per day" assumption. Not implementing it is the honest answer to not having that counter, not an oversight. ## Configuration ```rust theme={null} pub struct WamConfig { pub identity: WamIdentity, pub store: Arc, pub max_queued_events: usize, } ``` * **`identity`** — what this client says about itself in a buffer's globals. `WamIdentity::web()` (the default) derives `appVersion`, `platform`, and `osVersion` from the same `ClientProfile` the pairing payload is built from, so telemetry and pairing agree at the point `WamIdentity` is built. `ocVersion` (0) and `appIsBetaRelease` (false) are facts, not configuration — this isn't the official client, and the pairing payload announces the release channel. Build a `WamIdentity` with `from_profile` to match a non-web `ClientProfile`. `WamIdentity` is captured once, when you build `WamConfig`, and the plugin never re-reads it. If your application calls `Client::set_client_profile` after installing `WamPlugin`, the pairing payload and the WAM identity can drift apart, and there's no way to update the running plugin's copy — a plugin is [install-once for the lifetime of its `Client`](/advanced/plugins#registering-plugins-with-clientbuilder). Decide the `ClientProfile` before you build either, and build a new `Client` (with a matching `WamIdentity`) if it needs to change. `service_improvement_opt_out` is left absent by default because the library has no way to read the account's actual preference. Setting it only records a value in the buffer's globals — it does **not** suppress anything this plugin sends. If your application knows the user opted out of telemetry, don't install `WamPlugin` at all; setting this field to `true` is not a substitute for that. * **`store`** — see [Persistence](#persistence). * **`max_queued_events`** (default `4096`) — a bound on memory, not throughput. Events are small and the queue drains every few seconds under normal operation; this only protects against unbounded growth if the flush task stalls. ## Persistence Two things need to survive a restart: the per-channel sequence number, and buffers the server hasn't accepted yet. Both go through the `WamStore` trait, which the plugin owns — there is no storage capability in the plugin host, deliberately: a capability is a promise about every plugin, and one plugin needing a key-value store isn't that. `InMemoryWamStore` is the default. With it, a restart renumbers the sequence from 1 (what a fresh browser profile does anyway) and loses any buffer that hadn't been delivered yet — telemetry that was already best-effort. An application that wants durability implements `WamStore` against its own database; `WamApi::stats().store_is_durable` reports which one is in effect. A store that fails outright — it cannot be read, cannot issue a sequence number, or refuses to hold a buffer — costs the same buffer or event a durable store's ordinary unavailability would, and the runtime reports the loss itself once a later buffer exists to carry it (`WamClientErrors`, above), rather than only moving a local counter nothing ever uploads. ## Diagnostics `WamApi::stats()` returns a `WamStats` snapshot: | field | meaning | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `observed` | Events handed to the runtime | | `written` | Events written into a buffer | | `sampled_out` | Events discarded by sampling (expected — most events are meant to be sampled out) | | `dropped` | Events dropped because the queue was full | | `unbuffered` | Events lost because no buffer could be started for them (a sequence number couldn't be issued, or a global no longer belongs on the channel) | | `uploaded` | Buffers the server accepted | | `discarded` | Buffers abandoned (too large to upload, or past the retention cap) | | `upload_failures` | Failed upload attempts, retries included | | `store_is_durable` | Whether the configured store outlives the process | A snapshot is approximate under concurrency and, matching the rest of the client's [observability](/advanced/observability) surface, carries no JID, phone number, or message body. ## Privacy and PII Every derivation reads the stanza envelope, never the decoded message content — the same boundary the official client's own receive metrics are built from. Nothing here carries a JID, phone number, or message body as a field value. If you enable this plugin, you're opting your deployment into sending the same categories of client-health telemetry the official WhatsApp client sends; review the event table above and your applicable privacy obligations before shipping it. ## Upload behavior Buffers accumulate for a few seconds before being written, and are sent on a timer or once a size threshold is crossed. A failed upload is retried with a backoff (starting at one second, doubling, capped at two minutes) — except when the server's response classifies the buffer itself as refused (a 4xx that isn't a `wait`, `408`, or `429`), in which case it's dropped rather than retried forever. The best-effort flush at shutdown happens inside the flush loop itself, not in a separate shutdown callback. `WamPlugin` requests the install-scoped `Tasks` capability for this loop, so it runs once for the life of the client and is cancelled only at terminal teardown — a reconnect never touches it. The loop is spawned with [`spawn_cooperative`](/advanced/plugins#lifecycle-and-task-scopes) rather than the host's default abort-on-cancel. That makes shutdown turn the loop's `sleep` into an error instead of dropping the loop where it was suspended. The loop then makes one final pass itself: it observes a `WebWamForceFlush` event, then delivers or retains whatever buffer was already being filled, before returning. The same writer that was accumulating events is the one that flushes them, so nothing hands a parked buffer to a second writer. The [plugin host](/advanced/plugins#lifecycle-and-task-scopes) still only waits up to its configured task-drain deadline (5 seconds by default) for that final pass to finish. Teardown can block on it for up to the deadline, never longer, and whatever doesn't finish in time is left for the store to retain instead. ## Cost Measured on a stripped release build with `CARGO_PROFILE_RELEASE_STRIP=false`, the same build [binary-size CI](/changelog/2026-06-12-binary-size-ci) gates on: | build | delta | | ------------------------------------------------------------------------------ | ------------------------------------ | | Default build (neither crate linked) | **0 bytes** | | `plugins` on, `WamPlugin` installed, vs. `plugins` on with no plugin installed | +\~105 KB stripped, +\~89 KB `.text` | Installing the plugin also has a per-message cost that recurs rather than being one-time: it holds open three [lease-gated](/advanced/observability) core events (`DecryptedPayload`, `EncDecryptFailed`, `RawNode`) for the life of the client, so every decrypted `` and every decoded inbound stanza now also crosses the plugin's event handler. No plaintext is copied in the process — the payload types involved are `Bytes` and `Arc`-shared — but the cost of constructing and dispatching the event itself is real and, as of this writing, unbenchmarked. ## See also * [Native plugins](/advanced/plugins) — the capability model and lifecycle this plugin is built on. * [Metrics with the metrics facade](/advanced/metrics) — `wa_*` operational metrics for dashboards and alerts; a different thing from WAM, which is WhatsApp's own client-health telemetry format. * [Observability](/advanced/observability) — the PII and snapshot conventions this page's diagnostics follow. # WebSocket & Noise Protocol Source: https://whatsapp-rust.jlucaso.com/advanced/websocket-handling NoiseSocket, connection management, handshake protocol, and frame handling in whatsapp-rust ## Overview whatsapp-rust uses WebSocket for transport and the Noise Protocol for encryption. All messages are encrypted at the transport layer before being sent over the network. ## Architecture The WebSocket handling system has several layers: ``` src/ ├── handshake.rs # Noise protocol handshake ├── socket/ │ ├── noise_socket.rs # Encrypted communication layer │ ├── mod.rs # Re-exports │ └── error.rs # Socket errors ├── transport/ # WebSocket transport abstraction └── client.rs # High-level client orchestration ``` ## Noise protocol handshake The handshake establishes an encrypted channel using the Noise Protocol. whatsapp-rust supports two interactive patterns to match WhatsApp Web's behavior: * **Noise XX** — three-message mutual authentication used on cold start, after pairing, and as a fallback when IK fails. The server's static public key is unknown ahead of time and is delivered (and verified against a cert chain) inside the `ServerHello`. * **Noise IK** — a single round-trip resumed handshake used when a previously verified server static key is cached. This saves a server round trip on reconnect by pre-encrypting the client static and the 0-RTT `ClientPayload` against the cached server static. * **Noise XXfallback** — a recovery pattern the server transparently triggers when the cached server static used by an IK attempt no longer matches its current key. The transcript pivots from IK to XX without restarting the connection. A 20-second timeout (`NOISE_HANDSHAKE_RESPONSE_TIMEOUT`) is applied when waiting for the server's handshake response, ensuring the client does not hang indefinitely if the server is unresponsive. ### Pattern selection `do_handshake` picks the pattern based on cached state: ```rust theme={null} // src/handshake.rs enum HandshakePattern { /// Cold start / pairing / forced fallback after an earlier IK failure. Xx, /// Cached server static + valid cert chain available; attempt IK. Ik([u8; 32]), } ``` A handshake uses **IK** only when **all** of the following are true: * The device is registered (paired). * A cached `server_cert_chain` is present in `Device`. * Both the leaf and intermediate certificates are inside their `not_before`/`not_after` validity window for the current wall-clock time. * The process has not already observed an IK failure this session — the client allows at most `IK_FAILURE_THRESHOLD = 1` IK failure per process before forcing XX on subsequent connects. Otherwise, the client falls back to **XX**. A successful XX (or XXfallback) handshake refreshes the cached `server_cert_chain` so that the next reconnect can attempt IK again. ### Handshake state types The `wacore_noise` crate exposes one state machine per pattern: | Type | Pattern | Used by | | -------------------------- | ---------- | --------------------------------------------- | | `XxHandshakeState` | XX | `run_xx_handshake` | | `IkHandshakeState` | IK | `run_ik_handshake` | | `XxFallbackHandshakeState` | XXfallback | Reentry from `IkServerHelloOutcome::Fallback` | ```rust theme={null} // wacore/noise/src/handshake.rs pub struct XxHandshakeState { /* ... */ } pub struct IkHandshakeState { /* ... */ } pub struct XxFallbackHandshakeState { /* ... */ } pub enum IkServerHelloOutcome { /// Server accepted the cached static — IK can complete in one round trip. Continue(Box), /// Server rejected the cached static; pivot the transcript into XXfallback. Fallback(Box), } ``` The `pattern` strings are `"Noise_XX_25519_AESGCM_SHA256\0\0\0\0"` for XX and XXfallback (the longer `"Noise_XXfallback_25519_AESGCM_SHA256"` name is hashed to derive `h0` because it exceeds `HASHLEN`). ### XX handshake (cold start / fallback) ```rust theme={null} // src/handshake.rs pub async fn do_handshake( runtime: Arc, persistence_manager: &PersistenceManager, ik_handshake_failures: &AtomicU32, transport: Arc, transport_events: &mut async_channel::Receiver, observers: SendObservers, ) -> Result> ``` **Breaking change:** `do_handshake`'s last parameter used to be `stats: Option>`. It is now `observers: SendObservers`, the same struct [`NoiseSocket::with_observers`](#dedicated-sender-task) takes — see below. A caller that only wants wire-byte accounting passes `SendObservers::with_stats(stats)`; a caller that wants neither observer passes `SendObservers::default()`. **Step-by-step process for XX:** 1. **Prepare client payload:** ```rust theme={null} let client_payload = device.get_client_payload().encode_to_vec(); ``` The payload contains client version, platform, and device details. 2. **Initialize XX state:** ```rust theme={null} let mut handshake_state = XxHandshakeState::new( device.noise_key.clone(), client_payload, &WA_CONN_HEADER, // [0x57, 0x41] ("WA") )?; ``` 3. **Send ClientHello:** ```rust theme={null} let client_hello_bytes = handshake_state.build_client_hello()?; // Optionally include edge routing for faster reconnection let (header, used_edge_routing) = build_handshake_header(device.edge_routing_info.as_deref()); let framed = wacore::framing::encode_frame( &client_hello_bytes, Some(&header) )?; transport.send(framed).await?; ``` 4. **Receive ServerHello** (with 20s timeout): ```rust theme={null} const NOISE_HANDSHAKE_RESPONSE_TIMEOUT: Duration = Duration::from_secs(20); let resp_frame = loop { match timeout(NOISE_HANDSHAKE_RESPONSE_TIMEOUT, transport_events.recv()).await { Ok(Ok(TransportEvent::DataReceived(data))) => { frame_decoder.feed(&data); if let Some(frame) = frame_decoder.decode_frame() { break frame; } } // Handle errors... } }; ``` 5. **Send ClientFinish:** ```rust theme={null} let client_finish_bytes = handshake_state .read_server_hello_and_build_client_finish(&resp_frame)?; let framed = wacore::framing::encode_frame(&client_finish_bytes, None)?; transport.send(framed).await?; ``` 6. **Complete handshake and persist the cert chain:** ```rust theme={null} let outcome = handshake_state.finish()?; info!("Handshake complete (XX), switching to encrypted communication"); // The verified server cert chain is persisted so the next // connect can attempt Noise IK. persistence_manager .process_command(DeviceCommand::SetServerCertChain( outcome.server_cert_chain.into(), )) .await; Ok(Arc::new(NoiseSocket::new( runtime, transport, outcome.write_cipher, outcome.read_cipher, ))) ``` ### IK handshake (resumed) When `select_pattern` returns `HandshakePattern::Ik(server_static_pub)`, the client uses the cached server static and skips the second round trip: ```rust theme={null} // src/handshake.rs:run_ik_handshake let mut ik = IkHandshakeState::new( device.noise_key.clone(), server_static_pub, client_payload, &WA_CONN_HEADER, )?; // IK ClientHello carries: ephemeral, encrypted client static, encrypted 0-RTT payload. let client_hello_bytes = ik.build_client_hello()?; send_first_handshake_message(&transport, device, &client_hello_bytes).await?; let resp_frame = recv_frame(runtime, transport_events, &mut frame_decoder).await?; match ik.read_server_hello(&resp_frame)? { IkServerHelloOutcome::Continue(out) => { // Single round trip — handshake is already complete. info!("Handshake complete (IK), switching to encrypted communication"); // No client cert chain refresh — the cached entry stays authoritative. } IkServerHelloOutcome::Fallback(inputs) => { // Server rejected the cached static. Pivot to XXfallback below. } } ``` On `Continue`, no `SetServerCertChain` command is issued — the on-disk cache remains authoritative. ### XXfallback (server-driven recovery) If `read_server_hello` returns `IkServerHelloOutcome::Fallback(inputs)`, the server has signalled that the cached static is stale. The client constructs an `XxFallbackHandshakeState` from the IK transcript and finishes the handshake as if it had been XX from the start, **without reconnecting**: ```rust theme={null} // src/handshake.rs let mut fb = XxFallbackHandshakeState::from_ik_failure(*inputs, &WA_CONN_HEADER)?; let client_finish_bytes = fb.build_client_finish()?; let framed = wacore::framing::encode_frame(&client_finish_bytes, None)?; transport.send(bytes::Bytes::from(framed)).await?; let outcome = fb.finish()?; info!("Handshake complete (XXfallback), switching to encrypted communication"); // XXfallback re-derives the server cert chain, so persist the fresh value. persistence_manager .process_command(DeviceCommand::SetServerCertChain( outcome.server_cert_chain.into(), )) .await; ``` A `fallback_taken` flag is flipped before any operation that could fail. Failures **after** this pivot are not treated as crypto-fatal for IK cache invalidation purposes — by that point the server has already accepted the IK ClientHello and the cache is no longer the implicated party. ### IK failure handling Errors are partitioned into two buckets in `HandshakeError`: ```rust theme={null} impl HandshakeError { /// Transient — never invalidate the cache. pub fn is_transient(&self) -> bool { /* Transport, Timeout, Disconnected, StreamClosed */ } /// Crypto-fatal — the cached server static or cert chain is no longer trustworthy. pub fn is_crypto_fatal(&self) -> bool { /* AEAD/cert/parse failures */ } } ``` If `do_handshake` selected IK, the pivot to XXfallback was not taken, and the error returns `is_crypto_fatal() == true`, the client: 1. Increments a process-local `ik_handshake_failures: AtomicU32`. 2. Issues `DeviceCommand::ClearServerCertChain` to drop the cached chain. 3. Forces XX on the next connect via `select_pattern`. Programmer-side variants (`Proto`, generic `Crypto(String)`, `HkdfExpandFailed`, `CounterExhausted`) are explicitly **not** crypto-fatal — they indicate a code defect, and clearing the cache would mask it. Location: `src/handshake.rs` ### Edge routing pre-intro For optimized reconnection, the client can include edge routing info in the initial frame: ```rust theme={null} pub fn build_handshake_header( edge_routing_info: Option<&[u8]> ) -> (Vec, bool) { let mut header = WA_CONN_HEADER.to_vec(); // [0x57, 0x41] if let Some(info) = edge_routing_info { if info.len() <= 8192 { // Max size header.extend_from_slice(info); return (header, true); } } (header, false) } ``` Location: `wacore/noise/src/edge_routing.rs` Both XX and IK send their first handshake message through `send_first_handshake_message`, so the prologue (and any edge-routing pre-intro) is identical for the two patterns. This is required for the wire-side server to re-derive `h0` for transcript MAC checks regardless of which pattern is in use. ### Handshake Errors ```rust theme={null} pub enum HandshakeError { Transport(#[from] anyhow::Error), Core(#[from] CoreHandshakeError), Timeout, /// Producer side of `transport_events` was dropped — distinct from a /// timeout because nothing more will ever arrive on the channel. StreamClosed, Disconnected, UnexpectedEvent(String), } ``` Location: `src/handshake.rs` ## NoiseSocket The `NoiseSocket` provides encrypted send/receive operations after handshake. ### Architecture ```rust theme={null} pub struct NoiseSocket { read_key: Arc, read_counter: Arc, /// Channel to send jobs to the dedicated sender task. A channel instead of /// a mutex lets callers enqueue their work and await the result without /// blocking on the send in progress. send_job_tx: async_channel::Sender, /// Aborts the sender task on drop (prevents resource leaks if the task is /// stuck on a slow/hanging network operation). _sender_task_handle: AbortHandle, } /// A job sent to the dedicated sender task. struct SendJob { plaintext: bytes::Bytes, response_tx: oneshot::Sender, } ``` Location: `src/socket/noise_socket.rs:50-66` ### Design Patterns #### Dedicated sender task The socket uses a dedicated task for sending to ensure frame ordering: ```rust theme={null} /// What a socket reports its sends to. Both halves belong to the `Client`; a /// VoIP relay socket and most tests pass `Default`, reporting to neither. #[derive(Default, Clone)] pub struct SendObservers { /// Wire-byte accounting, recorded after the transport write. stats: Option>, /// Publisher for the plaintext of each frame that reached the transport. sent_frames: Option>, } impl SendObservers { /// Report wire bytes into `stats` and nothing else. pub fn with_stats(stats: Arc) -> Self { /* ... */ } /// Also publish each sent frame's plaintext through `tap`. pub(crate) fn with_sent_frames(mut self, tap: Arc) -> Self { /* ... */ } } impl NoiseSocket { pub fn with_observers( runtime: Arc, transport: Arc, write_key: NoiseCipher, read_key: NoiseCipher, observers: SendObservers, ) -> Self { let write_key = Arc::new(write_key); let read_key = Arc::new(read_key); // Small buffer matched to typical steady-state throughput; the sender // task is network-bound (awaits transport.send), so a transient // WebSocket stall backpressures producers here rather than queuing. let (send_job_tx, send_job_rx) = async_channel::bounded::(8); let sender_task_handle = runtime.spawn(Box::pin(Self::sender_task( runtime.clone(), transport.clone(), write_key.clone(), send_job_rx, observers, ))); Self { read_key, read_counter: Arc::new(AtomicU32::new(0)), send_job_tx, _sender_task_handle: sender_task_handle, } } } ``` `NoiseSocket::new` (used by the illustrative handshake walkthrough above) is unchanged: it's a thin wrapper that calls `with_observers` with `SendObservers::default()`, for the callers — mainly tests — that want neither observer. **Breaking change:** `with_stats(..., stats: Option>)` is now `with_observers(..., observers: SendObservers)`. `SendObservers` is one struct rather than one parameter per observer, so the next thing that wants to watch sends — [`SentFrame`](/concepts/events#sentframe) was the first — plugs in there instead of widening this constructor (and `do_handshake`'s) again. A caller that only wants what `with_stats` gave it passes `SendObservers::with_stats(stats)`, which is `pub` and usable from any crate. `with_sent_frames` and the client's `sent_frame_tap` field, by contrast, are `pub(crate)` — internal wiring the client itself uses to chain `.with_sent_frames(client.sent_frame_tap.clone())` when it builds its own socket, not something callable from outside `whatsapp-rust`. An embedder enables sent-frame forwarding the same way any consumer does: through [`Client::acquire_sent_frame_forwarding()`](/api/client#acquire_sent_frame_forwarding), which wires the tap internally. VoIP relay sockets and most tests pass `SendObservers::default()`, reporting to neither — same as passing `None` before. **Why a dedicated task?** 1. **Ordering guarantee**: Frames must be sent with sequential counters 2. **Non-blocking**: Callers don't block on encryption or network I/O 3. **Backpressure**: A bounded channel (8 jobs) backpressures producers instead of queuing unboundedly 4. **Frame coalescing**: whatever is already queued when the task wakes gets encrypted into one buffer and written in a single `transport.send()` — see [Write batching](#write-batching-frame-coalescing) below Location: `src/socket/noise_socket.rs:81-117` #### Sender task implementation A DM round trip can answer one inbound message with several independent producers (reply, delivery receipt, stanza ack) queuing at nearly the same instant. Rather than writing each as its own syscall/TLS record/WebSocket message, the sender drains whatever is **already** queued (`try_recv`, never a blocking wait) into one buffer and hands the transport a single write: ```rust theme={null} async fn sender_task( runtime: Arc, transport: Arc, write_key: Arc, send_job_rx: async_channel::Receiver, observers: SendObservers, ) { let SendObservers { stats, sent_frames } = observers; let mut write_counter: u32 = 0; let mut enc_buf = Vec::with_capacity(4096); let mut out_buf = BytesMut::with_capacity(4096); let mut poisoned = false; let mut waiters: Vec<(oneshot::Sender, usize)> = Vec::new(); let mut carry_over: Option = None; // Plaintexts of this batch's frames, held only while a consumer is // watching; empty (unallocated) otherwise. Kept until after the write so // what is published is what the transport actually accepted. let mut observed: Vec = Vec::new(); loop { let job = match carry_over.take() { Some(job) => job, None => match send_job_rx.recv().await { Ok(job) => job, Err(_) => break, }, }; if poisoned { let _ = job.response_tx.send(Err(EncryptSendError::poisoned())); continue; } // Encrypt everything already queued into out_buf, cloning each // plaintext into `observed` first when `sent_frames` is enabled. Stop // at the MAX_BATCH_FRAMES / MAX_BATCH_WIRE_BYTES ceiling, or when // try_recv finds nothing more waiting. // ... encrypt_frame_into loop, then a single transport.send(out_buf) ... // After the write succeeds: stats.record_frame_sent for each wire // frame, then — re-checking sent_frames.enabled() rather than trusting // the read at capture time, so a batch that outlived its last lease // stays quiet — tap.publish(plaintext) for each entry in `observed`. // A write that fails clears `observed` instead: a frame the transport // refused is not reported as sent. } } ``` Two ceilings bound a batch: `MAX_BATCH_FRAMES` (16) and `MAX_BATCH_WIRE_BYTES` (64 KiB). The byte ceiling only stops a batch from *growing*: you check it against the *next* frame's projected wire size **before** appending, and hold a frame that would overflow over (`carry_over`) to open the next batch instead. A dropped held-over job (e.g. on shutdown) drops its response channel, which its caller observes as a closed sender — a held-over job can be lost, but it can never hang its caller. The ceiling does not shrink a single frame that is already too big. The first job of a batch is always encrypted and appended before either ceiling is checked, so a plaintext whose framed ciphertext alone exceeds 64 KiB (frames up to the 16 MB protocol limit are valid — see [Frame Format](#frame-format)) still goes out, alone, in a write larger than the ceiling. The ceiling governs *coalescing*, not the size of any one frame. The whole batch shares the fate of its one `transport.send()` call. A crypto or framing error is detected before any byte reaches the wire. It leaves the write counter untouched. Only the offending job's caller sees the error — every frame already encrypted into the buffer still goes out, because the peer's counters must stay contiguous. A transport error is different: the peer may have partially or fully received the write, so you can't tell which frames landed. The sender poisons itself, and every waiter in the batch is told the send was lost. `EncryptSendError` wraps an `anyhow::Error`, which isn't `Clone`, so you can't hand each waiter its own copy of the real cause. Instead every waiter gets its own `EncryptSendError` (kind `Transport`) so `SendResult` stays `Result<(), EncryptSendError>` for every caller, and each one's `source` wraps the same shared `Arc` via a small `SharedSendFailure` type — so all of them can still downcast to the one real cause instead of a re-worded copy. A batch that coalesces more than one frame logs at debug level (`noise: coalesced {n} frames into one {bytes}-byte write`) — the only externally visible sign that batching happened. Location: `src/socket/noise_socket.rs:119-289` ### Encryption `encrypt_frame_into` (renamed from the pre-batching `process_send_job`) only encrypts and frames a single plaintext into the shared `out_buf` — it no longer performs the transport write itself, since a batch's write happens once, after every already-queued frame has been folded in. #### Small Messages (≤16KB) Encrypted inline to avoid thread pool overhead: ```rust theme={null} if plaintext.len() <= INLINE_ENCRYPT_THRESHOLD { enc_buf.clear(); enc_buf.extend_from_slice(&plaintext); write_key.encrypt_in_place_with_counter(counter, enc_buf)?; // append_frame_into, not encode_frame_into: it appends to out_buf // instead of clearing it, so several frames can be laid out back to // back in one buffer for a single write. wacore::framing::append_frame_into(enc_buf, None, out_buf)?; } ``` Location: `src/socket/noise_socket.rs:309-317` #### Large Messages (>16KB) Offloaded to the runtime's blocking pool. The `Bytes` plaintext is moved into the blocking closure (a refcount bump) rather than copied: ```rust theme={null} else { let write_key = write_key.clone(); let ciphertext = wacore::runtime::blocking(&**runtime, move || { write_key.encrypt_with_counter(counter, &plaintext) }) .await??; wacore::framing::append_frame_into(&ciphertext, None, out_buf)?; } *write_counter = counter + 1; Ok(out_buf.len() - before) ``` Location: `src/socket/noise_socket.rs:291-337` The 16KB threshold is chosen based on benchmarking. Smaller messages benefit from inline encryption (no thread spawning overhead), while larger messages benefit from parallel execution. The write counter is burned as soon as the framed ciphertext is committed to `out_buf` — at encrypt time, before the batch's single `transport.send()` — not when the write is confirmed, since a failed send says nothing about how many bytes the peer actually received. ### Write batching (frame coalescing) `wacore/noise/src/framing.rs` splits the old `encode_frame_into` into two functions: * **`encode_frame_into`** — clears `out` first, then appends. Unchanged behavior for existing one-frame-per-buffer callers. * **`append_frame_into`** — the same encoder without the clear, so several frames can be laid out back to back. `encode_frame_into` is now implemented as `clear` + `append_frame_into`. This is what makes coalescing possible: `out_buf` accumulates every already-queued frame across successive `encrypt_frame_into` calls before the sender task hands it to `transport.send()` once. On the wire, this is functionally the same thing the official WhatsApp Web client already does — `WAFrameSocket.sendFrame` (`WAWeb/Open/ChatSocket.js`) concatenates its handshake prefix and payload into one buffer before a single `requestSend()`, and its receive side already loops multiple frames out of one buffered chunk. ### Send API ```rust theme={null} pub(crate) async fn enqueue_send( &self, plaintext: bytes::Bytes, ) -> std::result::Result, EncryptSendError> { let (response_tx, response_rx) = oneshot::channel(); let job = SendJob { plaintext, response_tx }; if let Err(_send_err) = self.send_job_tx.send(job).await { return Err(EncryptSendError::channel_closed()); } Ok(response_rx) } pub(crate) async fn await_send(receiver: oneshot::Receiver) -> SendResult { match receiver.await { Ok(result) => result, Err(_) => Err(EncryptSendError::channel_closed()), } } pub async fn encrypt_and_send(&self, plaintext: bytes::Bytes) -> SendResult { let receiver = self.enqueue_send(plaintext).await?; Self::await_send(receiver).await } ``` `plaintext` is a `bytes::Bytes`, and `SendResult = Result<(), EncryptSendError>` — there is no buffer round-trip; the caller doesn't get anything back to reuse. A caller has no way to tell from this API alone whether its frame was written alone or coalesced into a batch with others queued at the same time. **`encrypt_and_send` split into enqueue and await halves ([#1139](https://github.com/oxidezap/whatsapp-rust/pull/1139)).** `encrypt_and_send` is unchanged from a caller's perspective — it's now just `enqueue_send` immediately followed by `await_send` — but the split lets a multi-frame caller enqueue every frame before awaiting any of them, which is what [burst sends](#burst-sends) below need to reach the sender task queued together instead of one completion apart. Location: `src/socket/noise_socket.rs:416-460` ### Burst sends `Client::send_raw_bytes_burst` (`src/client/messaging.rs`) sends several pre-marshaled stanzas as one burst and returns a result per stanza in the same order, for the two callers — the ack worker and the receipt worker — that already have more than one stanza ready at once. It exists because a worker that awaits each `encrypt_and_send` before starting the next never has two frames queued at the same time, so the coalescing described above never fires for it: batching only helps a caller that hands over the whole burst up front. A single-frame burst — the common case — is just `encrypt_and_send`. A multi-frame burst enqueues every frame via `enqueue_send` before awaiting any of them, holding the returned receivers in a `SmallVec<[_; MAX_INLINE_BURST]>` (`MAX_INLINE_BURST` is 4, matching the ack and receipt workers' own burst caps, `MAX_ACK_BURST` and `MAX_RECEIPT_BURST`) so a real burst never spills to the heap. `results` is a caller-owned, reused `Vec` rather than a return value, and `frames` is always fully drained — both retain their allocation across calls instead of being rebuilt per send. **Before [#1139](https://github.com/oxidezap/whatsapp-rust/pull/1139), a multi-frame burst allocated its result storage anyway ([#1138](https://github.com/oxidezap/whatsapp-rust/issues/1138)).** The out-parameter `results` above was added by [#1137](https://github.com/oxidezap/whatsapp-rust/pull/1137) to remove the single-frame burst's per-send allocation, but the multi-frame path still called `futures::future::join_all` over the sends and copied its returned `Vec` into `results` — `join_all` allocates storage for the futures it joins and a fresh `Vec` for their outputs, so that path kept paying what it paid before the out-parameter existed. Splitting `encrypt_and_send` into enqueue and await halves removed both allocations for a burst up to `MAX_INLINE_BURST` frames. If an enqueue fails partway through — the sender task is gone, which no later frame recovers from either — the remaining frames are still drained from `frames` and reported failed in their own positions, so `results` stays aligned with the frames the caller handed in rather than shifting out of step. Location: `src/client/messaging.rs` ### Decryption ```rust theme={null} pub fn decrypt_frame(&self, mut ciphertext: BytesMut) -> Result { let counter = self .read_counter .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |c| c.checked_add(1)) .map_err(|_| SocketError::Cipher(NoiseError::CounterExhausted))?; self.read_key .decrypt_in_place_with_counter(counter, &mut ciphertext) .map_err(SocketError::Cipher)?; Ok(ciphertext) } ``` **Decryption is synchronous** because: 1. Frames arrive sequentially in the transport receiver 2. Decryption is fast (AES-GCM hardware acceleration) 3. No ordering concerns (unlike send) Unaffected by write batching: coalescing only changes how outbound frames are packed into a write, not how inbound frames are decoded off the wire (`FrameDecoder`, covered below, already loops multiple frames out of one buffered chunk). Location: `src/socket/noise_socket.rs:462-474` ### Cleanup The sender task is aborted on drop automatically: `_sender_task_handle` is an `AbortHandle` whose own `Drop` impl does the work, so `NoiseSocket` no longer needs a manual `Drop` implementation. Location: `src/socket/noise_socket.rs:65,113-116` ## Frame Protocol Messages are framed before encryption: ```rust theme={null} pub fn encode_frame( payload: &[u8], header: Option<&[u8]> ) -> Result> { let mut output = Vec::new(); if let Some(header) = header { output.extend_from_slice(header); } // 3-byte big-endian length let len = payload.len() as u32; output.push(((len >> 16) & 0xFF) as u8); output.push(((len >> 8) & 0xFF) as u8); output.push((len & 0xFF) as u8); output.extend_from_slice(payload); Ok(output) } ``` Location: `wacore/src/framing/mod.rs:10-30` ### Frame Format ``` ┌────────────────┬─────────────────┬────────────────────┐ │ Optional Header│ Length (3 bytes)│ Payload │ │ (handshake) │ (big-endian) │ (encrypted) │ └────────────────┴─────────────────┴────────────────────┘ ``` **Frame length limits:** * Maximum frame size: 16MB (enforced by framing layer) * Typical message frame: \< 1KB * Media messages: 100KB - 2MB (encrypted metadata) ### Frame Decoder `FrameDecoder` (in `wacore/noise/src/framing.rs`) keeps one long-lived accumulation `BytesMut` and splits complete frames out of it — the shape `tokio_util::codec::Decoder` uses. Most inbound reads go through the copying `feed(&[u8])`; `feed_owned(Bytes)` is the owned counterpart a caller reaches for when the read is its own uniquely-owned buffer — see below for when each fires. ```rust theme={null} use bytes::{Buf, Bytes, BytesMut}; const CHUNK_SIZE: usize = 1024; const MAX_IDLE_CAPACITY: usize = 64 * 1024; const MAX_EAGER_RESERVE: usize = 64 * 1024; pub struct FrameDecoder { buffer: BytesMut, /// Whether the buffer has ever grown past `MAX_IDLE_CAPACITY`. grew_oversized: bool, } impl FrameDecoder { pub fn new() -> Self { Self { buffer: BytesMut::with_capacity(CHUNK_SIZE), grew_oversized: false, } } pub fn feed(&mut self, data: &[u8]) { self.reserve_for(data.len()); self.buffer.extend_from_slice(data); } /// Adopts a uniquely-owned transport read instead of copying it, when the /// accumulation buffer is empty and the read is at least a chunk long. /// Falls back to `feed` (copy) for a shared read, a read under a chunk, /// or one that lands mid-frame. pub fn feed_owned(&mut self, data: Bytes) { if self.buffer.is_empty() && data.len() >= CHUNK_SIZE { match data.try_into_mut() { Ok(owned) => { self.grew_oversized |= owned.capacity() > MAX_IDLE_CAPACITY; self.buffer = owned; } Err(shared) => self.feed(&shared), } } else { self.feed(&data); } } fn reserve_for(&mut self, incoming: usize) { if self.buffer.capacity() - self.buffer.len() < incoming { self.buffer.reserve(incoming.max(CHUNK_SIZE)); self.grew_oversized |= self.buffer.capacity() > MAX_IDLE_CAPACITY; } } pub fn decode_frame(&mut self) -> Option { if self.buffer.len() < FRAME_LENGTH_SIZE { return None; } let frame_len = ((self.buffer[0] as usize) << 16) | ((self.buffer[1] as usize) << 8) | (self.buffer[2] as usize); if self.buffer.len() < FRAME_LENGTH_SIZE + frame_len { // Size the buffer ahead for the rest of a large frame instead of // growing geometrically across the reads that carry it. let missing = FRAME_LENGTH_SIZE + frame_len - self.buffer.len(); self.reserve_for(missing.min(MAX_EAGER_RESERVE)); return None; } self.buffer.advance(FRAME_LENGTH_SIZE); let frame_data = self.buffer.split_to(frame_len); // Copy a short residue into a fresh chunk so an oversized allocation // doesn't get pinned by a few leftover bytes of the next frame. if (self.grew_oversized || self.buffer.capacity() > MAX_IDLE_CAPACITY) && self.buffer.len() <= CHUNK_SIZE { let mut fresh = BytesMut::with_capacity(CHUNK_SIZE); fresh.extend_from_slice(&self.buffer); self.buffer = fresh; self.grew_oversized = false; } Some(frame_data) } } ``` Location: `wacore/noise/src/framing.rs` **Why an accumulation buffer instead of adopting each payload.** An earlier version had `feed_bytes(Bytes)`, an owned path meant to adopt the transport's payload allocation zero-copy. It used `Bytes::try_into_mut` to check whether the decoder's buffer was empty and the payload had no other references. In practice that fast path never fired. `tokio-websockets` builds every payload via `BytesMut::split_to` on its own read buffer, which leaves the payload sharing storage with that read buffer. A shared payload always fails the `try_into_mut` uniqueness check, so it fell back to copying anyway. The steady-state branch made this worse: it also donated the decoder's whole buffer downstream via `mem::take`, forcing a fresh allocation on the very next read. `feed_bytes` was removed rather than fixed at the time. `feed_owned` (above) is its replacement: it checks `try_into_mut` per read rather than assuming the fast path fires, and it only takes it when the read is both uniquely owned *and* at least `CHUNK_SIZE` long — a short or shared read still copies through `feed`. The distinction that makes this worth having again is the source of the `Bytes`, not the check itself: the transports this library ships (Tokio's `tokio-websockets`, and the ESP32 transport) each deliver one WebSocket message as its own standalone `Bytes` — `Bytes::from(msg.into_payload())`, not a view split off a shared read buffer — so a message at least a chunk long adopts cleanly. Copying it first would have meant two copies of the largest frame of a connection alive at once, which on a 400 KB microcontroller is the difference between decoding a 28 KB props response and aborting on it. The accumulation buffer itself, and `feed`'s copy into it, still exist and are still the common path: `feed_owned` only replaces the copy for a read that both qualifies and arrives while the buffer is empty (not mid-frame). The accumulation-buffer design amortizes the buffer's allocation over every frame that fits in a `CHUNK_SIZE` (1 KiB) chunk, instead of allocating per read. A buffer produced by `split_to`, or adopted by `feed_owned`, already uses reference-counted storage, so `BytesMut::freeze()` further down the receive path (in-place AEAD decryption) becomes a pointer move rather than a fresh allocation. `MAX_IDLE_CAPACITY` (64 KiB) bounds how much capacity a drained buffer is allowed to keep. `MAX_EAGER_RESERVE` (64 KiB) caps how far the decoder will grow ahead of an announced-but-not-yet-received frame. This cap matters because the 3-byte length prefix is only the peer's claim, not evidence of data actually sent — without it, a hostile peer could pin an arbitrary allocation by announcing a large frame and sending almost none of it. ## Transport Abstraction The transport layer abstracts WebSocket implementation: ```rust theme={null} use bytes::Bytes; #[async_trait] pub trait Transport: Send + Sync { async fn send(&self, data: Vec) -> Result<(), anyhow::Error>; async fn disconnect(&self); } pub enum TransportEvent { Connected, Disconnected, /// Inbound payload, fed into the frame decoder's accumulation buffer /// and dropped immediately after — see [Frame Decoder](#frame-decoder). DataReceived(Bytes), } ``` Location: `src/transport/mod.rs` ### WebSocket implementation The transport is generic over any `AsyncRead + AsyncWrite` stream. The `from_websocket` function wraps an already-upgraded `WebSocketStream` into a `Transport` + event channel: ```rust theme={null} pub fn from_websocket( ws: WebSocketStream, ) -> (Arc, async_channel::Receiver) where S: AsyncRead + AsyncWrite + Send + Unpin + 'static, ``` Internally, the WebSocket is split into a write half (guarded by `Arc`) and a read half (moved to a spawned `read_pump` task). A `watch` channel coordinates graceful shutdown between the transport and the read pump. `TokioWebSocketTransportFactory` handles the default DNS/TCP/TLS connection and delegates to `from_websocket`. For custom connection strategies (IPv4 preference, TCP keepalive, proxies), call `from_websocket` directly. Location: `transports/tokio-transport/src/lib.rs` ## Connection Lifecycle ### Connect timeout Both the transport connection and the version fetch are wrapped in a 20-second timeout (`TRANSPORT_CONNECT_TIMEOUT`), matching WhatsApp Web's MQTT `CONNECT_TIMEOUT` and DGW `connectTimeoutMs` defaults. Without this, a dead network would block on the OS TCP SYN timeout (\~60-75s). ```rust theme={null} const TRANSPORT_CONNECT_TIMEOUT: Duration = Duration::from_secs(20); ``` The client runs the version fetch and transport connection **in parallel** using `tokio::join!`, both under this timeout: ```rust theme={null} let version_future = tokio::time::timeout( TRANSPORT_CONNECT_TIMEOUT, resolve_and_update_version(&persistence_manager, &http_client, override_version), ); let transport_future = tokio::time::timeout( TRANSPORT_CONNECT_TIMEOUT, transport_factory.create_transport(), ); let (version_result, transport_result) = tokio::join!(version_future, transport_future); ``` If either times out, the connection attempt fails with a descriptive error (e.g., `"Transport connect timed out after 20s"`). Location: `src/client.rs:108-877` ### 1. Connect ```rust theme={null} impl Client { pub async fn connect(&self) -> Result<()> { // Version fetch + transport connection run in parallel, both under 20s timeout let (transport, mut events) = tokio::time::timeout( TRANSPORT_CONNECT_TIMEOUT, self.transport_factory.create_transport(), ).await??; // Perform Noise handshake let noise_socket = do_handshake( self.runtime.clone(), &self.persistence_manager, &self.ik_handshake_failures, transport, &mut events, SendObservers::with_stats(self.stats.clone()) .with_sent_frames(self.sent_frame_tap.clone()), ).await?; // Store socket and start receivers *self.noise_socket.lock().unwrap_or_else(|p| p.into_inner()) = Some(Arc::clone(&noise_socket)); self.start_frame_receiver(events, noise_socket).await; Ok(()) } } ``` ### 2. Message loop (read loop) The `read_messages_loop` runs inline on whichever task drives the connection — `run()`'s loop, or (as of PR #1258) the caller of [`Connection::read_until_disconnected()`](/api/client#connection) — not spawned, so the keepalive loop (which runs in a separate spawned task) is never blocked by frame processing. This eliminates a class of bugs where a long-running batch of frames (e.g., offline sync) could starve the keepalive timer. ```rust theme={null} async fn read_messages_loop(self: &Arc) -> Result<(), anyhow::Error> { let transport_events = self.transport_events.lock().await.take() .ok_or_else(|| anyhow!("Cannot start message loop: not connected"))?; // Noise socket is stable for the lifetime of this loop; resolve once // instead of locking the mutex on every inbound frame. let noise_socket = self.get_noise_socket() .map_err(|_| anyhow!("Cannot start message loop: no noise socket"))?; let mut frame_decoder = FrameDecoder::new(); loop { futures::select_biased! { _ = self.shutdown_notifier.listen().fuse() => { return Ok(()); }, event_result = transport_events.recv().fuse() => { match event_result { Ok(TransportEvent::DataReceived(data)) => { // Update dead-socket timer on arrival self.last_data_received.store(Instant::now()); // Consumed here, before any await below: a read the // transport still shares is copied and released, so the // node processed further down never keeps a second copy // of its bytes alive; a read the transport handed over // outright is adopted without either. frame_decoder.feed_owned(data); let mut frames_in_batch: u32 = 0; while let Some(encrypted_frame) = frame_decoder.decode_frame() { if let Some(node) = self.decrypt_frame(&noise_socket, encrypted_frame) { // Inline vs spawned processing (see below) if is_critical(&node) { self.process_decrypted_node(node).await; } else { self.runtime.spawn_detached(/* ... */); } } // Cooperative yield every N frames frames_in_batch += 1; if frames_in_batch.is_multiple_of(self.runtime.yield_frequency()) { if let Some(yield_fut) = self.runtime.yield_now() { yield_fut.await; } } } // Refresh timestamp after batch so keepalive sees // batch completion time, not just arrival time if frames_in_batch > 1 { self.last_data_received.store(Instant::now()); } }, Ok(TransportEvent::Disconnected) | Err(_) => { /* handle disconnect */ } _ => {} } } } } } ``` **Key design decisions:** * **`select_biased!`** — the shutdown listener has priority over transport events, ensuring the loop exits promptly when `shutdown_notifier` fires (e.g., on stream error or disconnect) * **Batch timestamp refresh** — after processing multiple frames, `last_data_received` is updated again so the keepalive loop sees the batch completion time rather than the arrival time. This prevents false-positive dead-socket triggers during large offline sync batches that take seconds to drain * **Cooperative yielding** — the loop yields to the runtime every `yield_frequency()` frames, preventing a large burst of frames from monopolizing the executor #### Inline vs concurrent node processing Frame decryption is always sequential (noise protocol counter ordering), but node processing uses a hybrid strategy: | Node tag | Processing | Reason | | ------------------------------------ | -------------------- | --------------------------------------------------------------------------------------------------------------------- | | `success`, `failure`, `stream:error` | Inline | Critical for connection state transitions | | `message` | Inline | Preserves arrival order for per-chat queues (MessageHandler just enqueues + ACKs; heavy crypto runs in queue workers) | | `ib` | Inline | Ensures offline sync tracking (expected count) is set up before offline messages are processed | | Everything else | Spawned concurrently | Maximizes parallelism for non-ordering-sensitive stanzas | ```` ### 3. Send message ```rust impl Client { pub async fn send_node(&self, node: &Node) -> Result<()> { let noise_socket = self.get_noise_socket()?; // Marshal node to binary with auto-sized buffer let plaintext_buf = marshal_auto(node)?; // Encrypt and send self.send_raw_bytes(plaintext_buf).await } } ```` The `marshal_auto` function automatically selects an appropriate buffer capacity based on the node's characteristics. For nodes exceeding certain thresholds (24+ attributes, 64+ children, or 8KB+ scalar content), it pre-estimates the capacity to avoid reallocations. For typical small nodes, it uses the default 1024-byte capacity. This replaces the previous manual `Vec::with_capacity(1024)` + `marshal_to` pattern. ```` ### 4. Disconnect On disconnect, `cleanup_connection_state()` runs exactly once, after the message loop exits. As of PR #1258 that call site is the shared `drive_connection()` helper, used by both `run()`'s loop body and [`Connection::read_until_disconnected()`](/api/client#connection) — so the reset below happens the same way whether `run()` or a direct `connect()` drove the connection: ```rust impl Client { async fn cleanup_connection_state(&self) { self.shutdown_notifier.notify(usize::MAX); *self.transport.lock().await = None; *self.noise_socket.lock().unwrap_or_else(|p| p.into_inner()) = None; self.is_connected.store(false, Ordering::Release); // Drop per-chat lane senders so workers exit via channel close. // Without this, stale workers from the old connection survive reconnects // holding outdated signal/crypto state. self.chat_lanes.invalidate_all(); // Clear signal cache, pending retries, IQ waiters, offline sync state... } } ```` Key cleanup actions include invalidating chat lanes (so stale message processing workers don't survive with outdated crypto state), clearing the signal cache, draining IQ response waiters, and resetting offline sync state. See [disconnect cleanup](/concepts/architecture#disconnect-cleanup) for the full list. **Bounded teardown ([whatsapp-rust#1410](https://github.com/oxidezap/whatsapp-rust/pull/1410)).** Every `transport.disconnect().await` on a teardown path — `disconnect()`, `reconnect()`, `reconnect_immediately()`, `pause()`, and `cleanup_connection_state_inner` — now runs through `Client::close_transport_bounded`, which wraps the close in a `TRANSPORT_CLOSE_TIMEOUT` (2s) and abandons the socket with a `warn!` log if it doesn't finish in time. A close is a network write that shares the transport's sink with every send; on a peer that vanished without a FIN, that write is retried by the kernel until `tcp_retries2` gives up (\~15 minutes on Linux). Untimed, one such socket parked the whole teardown — and the run loop behind it — for that long. Before the bounded close, `cleanup_connection_state_inner` also calls `NoiseSocket::abort_sender()` on the socket it is clearing: it closes the sender task's job channel and aborts the task outright, rather than waiting for the last `Arc` clone to drop it. A send parked inside `transport.send()` on a black-holed connection (e.g. via `send_raw_bytes`, which holds a socket clone across its await) would otherwise keep that task — and the transport's sink — alive for as long as the kernel keeps retrying, which is exactly what the close below would then queue behind. Every send `abort_sender()` cuts short — queued or in flight — fails its caller with `EncryptSendError::channel_closed()` instead of hanging; a send racing a teardown was going to fail once the socket closed anyway. Because `disconnect()` closes the transport itself and `cleanup_connection_state()` closes it again, a dead socket now costs at most 2 × `TRANSPORT_CLOSE_TIMEOUT` (4s) rather than an unbounded wait — the second close is a no-op on an already-closed transport. ### Connection state tracking The client tracks whether the noise socket is established using a dedicated `AtomicBool` (`is_connected`) rather than probing the noise socket mutex. This design prevents a TOCTOU race where `try_lock()` on the mutex fails due to contention (e.g., during frame encryption), not because the socket is absent — which previously caused `is_connected()` to return `false` on live connections, silently dropping receipt acks. **State transitions:** | Event | `is_connected` value | Ordering | | ---------------------------- | -------------------- | ---------------------------------- | | `connect()` start | `false` | `Relaxed` (reset) | | Noise socket stored | `true` | `Release` (after socket is `Some`) | | `cleanup_connection_state()` | `false` | `Release` (after socket is `None`) | The `Release`/`Acquire` ordering ensures that any task reading `is_connected() == true` is guaranteed to see the noise socket as `Some`, and any task reading `false` after cleanup sees the socket as `None`. ```rust theme={null} // Lock-free connection check — never affected by mutex contention pub fn is_connected(&self) -> bool { self.is_connected.load(Ordering::Acquire) } ``` This is critical for the keepalive loop and stanza acknowledgment, both of which call `is_connected()` to decide whether to send data. Under the old `try_lock()` approach, concurrent `send_node()` calls holding the mutex would cause false negatives, leading to skipped keepalive pings or dropped ack stanzas. ## Error Handling ### Socket Errors ```rust theme={null} pub enum SocketError { SocketClosed, Io(#[from] std::io::Error), Cipher(#[from] NoiseError), Marshal(#[source] BinaryError), } pub enum EncryptSendErrorKind { Crypto, Framing, Transport, Join, ChannelClosed, /// A previous frame failed at the transport; this connection's write /// keystream can no longer be extended safely. See "Send poisoning" /// below. Poisoned, } pub struct EncryptSendError { pub kind: EncryptSendErrorKind, pub source: anyhow::Error, } ``` `EncryptSendError` is one struct with a `kind` and a single `source: anyhow::Error` field, not a distinct payload per kind — there are no `plaintext_buf`/`out_buf` fields to recover. For `Crypto`/`Transport`/`Framing`/`Join`, `source` is the real downstream error: `Cipher` wraps a `NoiseError` (from `wacore::handshake`), which itself carries a typed `CryptoProviderError` source, and walking the chain with `std::error::Error::source()` lets callers downcast to the original AES-GCM, libsignal, or binary-protocol error without parsing strings. `Poisoned`'s source is different in kind: there is no downstream failure to wrap, since the poisoning is detected in-process rather than reported by a lower layer, so its source is a fixed explanatory message (`"noise sender disabled after a transport failure; reconnect to rekey"`) rather than something to downcast. `is_transport_unavailable()` returns `true` for `Transport`, `ChannelClosed`, **and** `Poisoned` — all three mean the same thing to a caller: stop retrying on this connection and reconnect. Unlike an older version of this API, no variant returns buffers for reuse — there is no `into_buffers()` method. This matches the [Send API](#send-api) above: `encrypt_and_send` takes an owned `bytes::Bytes` and gives nothing back on either the success or the error path, so there is nothing for a caller to recover from a failed send. Location: `src/socket/error.rs` ### Send poisoning after a transport failure A `transport.send()` call that returns `Err` says nothing about how much of the frame reached the peer — it may have been fully consumed, partially written, or not sent at all. Because the write counter feeds directly into the AES-GCM nonce, that ambiguity can't be resolved safely: * **Reusing the counter** on the next frame reuses the nonce under the same write key. Two ciphertexts under one key/nonce pair leak both plaintexts. * **Skipping the counter instead** desyncs the peer's read counter, since the peer's actual state depends on whether it saw the failed frame. Both recovery paths are unrecoverable in-band, so the sender task instead poisons itself on the first transport error: 1. The write counter is incremented at **encrypt time**, inside `encrypt_frame_into`, before the frame joins the batch's shared buffer — not when the batch's single `transport.send()` returns — so "a counter value is never used twice" holds regardless of whether the send later fails. 2. The first `Transport`-kind error flips an in-memory `poisoned` flag on the sender task and calls `transport.disconnect()`. Closing the transport drives the existing disconnect/reconnect path, since nothing else would otherwise notice a write-only failure on an otherwise-open socket. 3. Every send after that point is rejected immediately with `EncryptSendError::poisoned()` — it is never encrypted or written to the wire. The connection can only become usable again by reconnecting, which performs a fresh Noise handshake and installs new keys and counters. `Crypto` and `Framing` errors do **not** poison the sender: both are detected before any byte reaches the wire, so the counter and keystream are untouched and the connection stays usable for the next send. **Interaction with write batching:** since the sender coalesces several queued frames into one write (see [Write batching](#write-batching-frame-coalescing)), a poisoning transport error fails every frame in that batch, not just one — each waiter gets its own `EncryptSendError` whose `source` downcasts to the same shared cause (see above), rather than a separately-worded copy. A crypto/framing error, by contrast, only fails the one job that produced it; every frame already folded into the buffer ahead of it still goes out. ### Stream error handling When the server sends a `` stanza, it is processed inline (not spawned concurrently) because stream errors are critical for connection state. The `StanzaRouter` dispatches the node to a `StreamErrorHandler`, which calls `Client::handle_stream_error()`. Each stream error sets `is_logged_in = false` and fires the `shutdown_notifier` to exit the keepalive loop and other background tasks. **Error code behavior:** | Code | Action | Event | Reconnects? | | ------------------------------------------------------------------------- | --------------------------------------------- | ---------------- | -------------------------------------- | | **401** | Disables auto-reconnect | `LoggedOut` | No — session invalid, must re-pair | | **409** | Disables auto-reconnect | `StreamReplaced` | No — prevents displacement loop | | **429** | Adds 5 to backoff counter | `StreamError` | Yes — extended Fibonacci backoff | | **503** | Normal handling | None | Yes — standard backoff | | **515** | Marks as expected disconnect | None | Yes — immediate, no backoff | | **516** | Disables auto-reconnect | `LoggedOut` | No — device removed | | `` with an `` child (no numeric code) | Force-closes the socket | `StreamError` | Yes — standard backoff | | **500** / unknown / missing code | Logs as warning, stays connected (since v0.6) | `StreamError` | N/A — no disconnect; session preserved | Before v0.6 the client treated every unknown stream-error code as fatal: it set `is_logged_in = false`, fired `shutdown_notifier`, and ended up in a "zombie" state where the connection survived but background tasks (keepalive, prekey upload) refused to run. v0.6 keeps the fatal set listed above and downgrades everything else (including code `500` and code-less `` routing wrappers) to a warning. `is_logged_in` stays `true`, the transport stays open, and any reconnect is driven by the server's `` — not by the stream-error handler itself. `` is checked before the ack-wrapper case above and is an exception to that downgrade: WA Web (`Handle/StreamError.js`) treats it as "bad xml, closing socket" (`CLOSE_SOCKET`). A malformed frame desyncs the stream, so the client proactively recycles the socket (`is_logged_in = false`, `should_disconnect = true`) instead of waiting for the server to end it. It counts toward the reconnect backoff like a normal disconnect — it is not an expected disconnect (515), so no immediate reconnect. Like every other case in this code-less/unknown-code branch, an `Event::StreamError` is still dispatched before the socket is closed — consumers see the event even though the connection is being force-recycled rather than gracefully downgraded. **Processing pipeline:** ``` Server sends → read_messages_loop (inline, not spawned) → StanzaRouter → StreamErrorHandler → Client::handle_stream_error() → is_logged_in = false → Code-specific handling (see table above) → shutdown_notifier fires → keepalive exits → run() loop checks enable_auto_reconnect ``` ### Stanza acknowledgment The client automatically sends `` nodes in response to incoming stanzas (messages, receipts, notifications, calls). The ack construction follows WhatsApp Web and whatsmeow behavior: **Class gating for newsletter and status:** Newsletter and status\@broadcast inbound messages produce a `` instead of a ``, matching WA Web's `WAWebSendMsgAckOrReceiptJob`. Regular DMs and groups continue to skip the message-class ack because they ride on the regular receipt path. **Ack attributes:** | Attribute | Value | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `class` | Original stanza tag (e.g., `"message"`, `"receipt"`, `"notification"`) | | `id` | Copied from the incoming stanza | | `to` | Flipped from the incoming `from` attribute | | `participant` | Copied from the incoming stanza (when present) | | `recipient` | Echoed from the incoming stanza (when present) — required for LID-routed and hosted-companion messages so the server can route the ack back correctly. Stripping it caused `` disconnects for peer-routed traffic. | | `from` | Own device phone number JID — only included for message acks | **`type` attribute rules:** The `type` attribute is handled differently depending on the stanza: | Stanza | `type` in ack | Reason | | ------------------------------------------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------- | | `message` | **Never included** | Matches whatsmeow: `node.Tag != "message"` guard skips type for messages | | `receipt` (with type, e.g. `"read"`) | **Echoed** | WA Web echoes `type` when explicitly present | | `receipt` (without type, i.e. delivery) | **Omitted** | Delivery receipts have no `type`; including one (e.g., `type="delivery"`) causes `` disconnections | | `notification` | **Echoed** | Type is echoed for most notifications | | `notification type="encrypt"` (any child — ``, ``, ``, ``) | **Omitted** | Every WA Web handler for an encrypt notification builds its ack as `{to, id, class: "notification"}`, with no `type` at all | Sending incorrect `type` attributes in ack stanzas can cause the server to issue `` disconnections. The library handles this automatically — you don't need to build ack nodes manually. Location: `src/client.rs` (`build_ack_node`, `ack_type`, `is_encrypt_notification`) ### Fibonacci backoff The reconnection backoff follows the Fibonacci sequence, matching WhatsApp Web's behavior: ``` Sequence: 1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s, 55s, 89s, 144s, ... Maximum: 900s (15 minutes) Jitter: ±10% ``` For rate-limited errors (429), the client increments the backoff counter by 5 before the normal increment, causing the delay to jump significantly on the next reconnection attempt. Since [#1263](https://github.com/oxidezap/whatsapp-rust/pull/1263), the client also dispatches `Event::StreamError` for 429 — WA Web gives its UI no signal here at all (429 is outside the `500..600` range its handler special-cases), but an embedder has no UI to fall back on, so the rate limit is reported like every other coded stream error. **Stability-gated reset.** The backoff counter does not reset to its base immediately on a successful `` authentication. Instead, `connected_at` (a monotonic `wacore::time::Instant`, immune to system clock jumps — [whatsapp-rust#1379](https://github.com/oxidezap/whatsapp-rust/pull/1379)) records the auth time, and the counter only resets when the *next* disconnect finds the connection was stable for at least `STABLE_CONNECTION_RESET` (30s) — matching WA Web's `resetDelay`. A connection that authenticates and then immediately drops keeps escalating the backoff instead of resetting to 1s and retrying in a tight loop. An explicit penalty applied during the connection — a 429 rate-limit or a manual `Client::reconnect()` — sets `backoff_reset_suppressed`, which survives even a stable (≥30s) connection and prevents the next disconnect from erasing that deliberate backoff step (matching WA Web's `cancelReset()`). The suppression flag is cleared on the next successful ``, so it does not carry over indefinitely. An expected disconnect (e.g., 515) clears `connected_at` so a later failed connect cannot read the prior cycle's stale timestamp as "stable." **The attempt counter saturates at 64 ([whatsapp-rust#1410](https://github.com/oxidezap/whatsapp-rust/pull/1410)).** `auto_reconnect_errors` — the same counter exposed as [`stats().reconnect_errors`](/api/client#diagnostics) — is only reset by a connection that stays up the stability window above, so a link that flaps for weeks (or a run of 429s, which adds 5 per hit) used to climb it without bound. The delay itself is already pinned at the 900s cap from attempt 17 on, so nothing about the schedule changes; only the raw counter is now clamped to `MAX_BACKOFF_ATTEMPTS` (64), far past the point the cap takes effect, so it stays a number a consumer can read instead of growing indefinitely. ### Retry strategy The `run()` method handles reconnection automatically: ```rust theme={null} // Simplified reconnection logic in run() loop { match self.connect().await { Ok(connection) => { // read_until_disconnected() drives the same connection lifecycle // (read loop, teardown, Disconnected dispatch) that run() has // always used — see the `Connection` type below. connection.read_until_disconnected().await; } Err(ConnectError::Shutdown) => break, // shutdown is sticky and final; never retry Err(e) => { /* log and fall through to the retry delay */ } } if !self.enable_auto_reconnect.load(Ordering::Relaxed) { break; // 401, 409, 516 — stop permanently } if self.expected_disconnect.load(Ordering::Relaxed) { continue; // 515 — reconnect immediately } let errors = self.auto_reconnect_errors.fetch_add(1, Ordering::Relaxed); let delay = fibonacci_backoff(errors + 1); // This races the wait against the client's terminal shutdown listener. // If you call `disconnect()`, `logout()`, or `signal_shutdown_sync()`, // the wait ends immediately. A bare `sleep(delay).await` would otherwise // hold `run()` for up to the 900s cap after you'd already asked it to stop. select! { _ = sleep(delay) => {} _ = self.shutdown_signal() => break, } } ``` As of PR #1258, `run()`'s per-connection work (read loop, teardown, `Disconnected` dispatch) is shared with a directly-driven [`Connection::read_until_disconnected()`](/api/client#connection) via an internal `drive_connection()` helper, so the two paths cannot drift on what a connection ending means. `self.shutdown_signal()` above is `Client::shutdown_signal()`, the client's own terminal-shutdown listener — not the crate-level [`whatsapp_rust::shutdown_signal()`](/installation#graceful-shutdown) helper that waits for SIGINT/SIGTERM. This races the wait against that *terminal* signal, not the per-connection one `keepalive_loop` watches. The per-connection signal fires on every disconnect the loop exists to reconnect from. Watching it here would collapse every backoff into a no-op. Only `disconnect()`, `logout()`, and `signal_shutdown_sync()` fire the terminal signal and cut the backoff short. A routine disconnect-and-retry does not. ## Keepalive and dead socket detection The keepalive loop monitors connection health, matching WhatsApp Web's behavior precisely. ### Constants | Constant | Value | Description | | ------------------------------------ | ----- | --------------------------------------------------------------------------------------------- | | `KEEP_ALIVE_INTERVAL_MIN` | 15s | Minimum interval between pings | | `KEEP_ALIVE_INTERVAL_MAX` | 30s | Maximum interval between pings | | `KEEP_ALIVE_RESPONSE_DEADLINE` | 20s | Timeout waiting for pong response | | `DEAD_SOCKET_TIME` | 20s | Max silence after the watchdog arms before declaring socket dead | | `KEEPALIVE_MAX_CONSECUTIVE_FAILURES` | 3 | Consecutive unanswered pings after which the loop forces a reconnect instead of pinging again | ### Timestamp safety The dead-socket anchors (`SessionStats::first_send_since_recv`, `last_data_received`) and the reconnect-backoff stability anchor (`connected_at`) are `wacore::time::Instant`, the monotonic clock `wacore` ships for exactly this — they never touch `now_millis()`, so a system clock adjustment (NTP resync, waking from suspend) can't be misread as elapsed time and can't trip the watchdog or the backoff reset on a live connection ([whatsapp-rust#1379](https://github.com/oxidezap/whatsapp-rust/pull/1379); before this fix, all three were wall-clock milliseconds and a clock jump alone could read as 20+ seconds of silence). Two values are still deliberately wall-clock, because their callers need an absolute instant rather than an elapsed duration: the public `StatsSnapshot::last_data_received_ms` (derived from the monotonic anchor at snapshot time, so it stays correct even across a clock jump) and keepalive's `wall_rtt_ms`, which feeds WA Web's `onClockSkewUpdate`. Both still read `now_millis()` (which returns `i64`) and guard the cast to `u64` with `.max(0)`, preventing silent wrap-around on negative clock values (e.g., from NTP corrections or virtualized environments). ### Keepalive loop behavior The loop runs every 15-30 seconds (randomized, matching WA Web's `15 * (1 + random())` formula) and performs these checks in order: 1. **Skip if recently active** — if data was received within `KEEP_ALIVE_INTERVAL_MIN` (15s), the connection is proven alive; skip the ping and reset the error counter 2. **Send keepalive ping** — sends the ping *before* the dead-socket check so that a successful pong updates `last_data_received` and prevents false-positive dead-socket detection on idle-but-healthy connections 3. **RTT-adjusted clock skew** — on pong, calculates server time offset using the midpoint formula: `(startTime + rtt/2) / 1000 - serverTime`, matching WA Web's `onClockSkewUpdate` 4. **Skip ping when IQ pending** — if there are already pending IQ responses, the connection is implicitly being tested; skip the explicit ping ### Dead socket detection Dead socket detection mirrors WA Web's `deadSocketTimer.onOrBefore` pattern, which keeps the **earliest** armed deadline rather than the most recent one: * **Not armed** if nothing has been sent since the last receive (the anchor is `None`) * **Cancelled** if data was received after the anchor was armed * **Fires** if `DEAD_SOCKET_TIME` (20s) has elapsed since the anchor with no receive since The watchdog is anchored to `SessionStats::first_send_since_recv` — the **first** send since the last receive, not the most recent send. `record_frame_sent` only stores a new anchor when the current one is unset or stale (`<=` the last-received timestamp); once armed, further sends leave it in place. Every receive clears the anchor (`None`), and the next send re-arms it. Anchoring on the most recent send instead (the pre-fix behavior) let continued outgoing traffic — messages, receipts, presence — keep pushing the deadline forward, hiding a half-open socket (a peer that silently disappeared while writes still buffer and reads hang) for as long as the app kept emitting frames. The dead-socket check runs on **every** keepalive tick — not just after a failed ping. This catches scenarios where pending IQs caused the ping to be skipped, or where the ping "succeeded" but the connection died immediately after. When a dead socket is detected, the client calls `reconnect_immediately()` and exits the keepalive loop. WA Web's `deadSocketTimer.onOrBefore` (`WA/Shift/Timer.js`) arms on the first `callStanza` after a receive and is cancelled by `parseAndHandleStanza`; subsequent sends never push the deadline back out. The keepalive loop approximates this by checking `is_dead_socket_at(first_send_since_recv, last_recv, now)` unconditionally each iteration, where `first_send_since_recv` is the armed-anchor value described above. The tick reads the clock once into `now` and evaluates both the dead-socket check and the elapsed-time log message against that single instant, rather than re-reading the clock for each. There is no `last_data_sent_ms` field — nothing reads a "most recent send" timestamp, only the armed anchor. ### Error classification Keepalive errors are classified exhaustively (compile-time enforced for new error variants): | Error type | Classification | Behavior | | ----------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------- | | `Socket`, `Disconnected`, `NotConnected`, `InternalChannelClosed` | Fatal | Exit keepalive loop immediately | | `Timeout`, `ServerError`, `ParseError` | Transient | Increment error count, check dead socket, force a reconnect on the 3rd consecutive occurrence | **Three consecutive unanswered pings force a reconnect ([whatsapp-rust#1410](https://github.com/oxidezap/whatsapp-rust/pull/1410)).** `error_count` used to be computed and logged but never read, so it did nothing on its own. A ping is only sent when the link has been idle for at least `KEEP_ALIVE_INTERVAL_MIN` (a recently-active connection skips it and resets the counter), so three transient failures in a row is roughly a minute where nothing arrived *and* nothing sent was answered. On the third, the loop calls `reconnect_immediately()` and exits. This closes a gap the dead-socket watchdog is blind to by design: `is_dead_socket_at` is cancelled by any receive, so a half-open socket that still delivers inbound frames while outbound writes go nowhere looked alive to it forever, silently losing every send, ack, and receipt for the rest of the session. Reconnect-looping against a merely slow (rather than broken) server is bounded by the existing resets — a successful pong, or renewed activity — and by `should_reset_backoff`, which keeps escalating the Fibonacci delay until a connection stays up 30s. **Pinned to its own connection generation ([whatsapp-rust#1410](https://github.com/oxidezap/whatsapp-rust/pull/1410)).** `keepalive_loop` now takes its shutdown signal and `connection_generation` snapshot from the caller at spawn time (`drive_connection`) rather than reading them on its own first poll, and exits as soon as the live generation no longer matches. A spawned task's first poll can land arbitrarily late — after a reconnect has already completed — so reading `connection_generation` or re-subscribing to `connection_shutdown_signal()` from inside the loop could silently hand it the *next* connection's identity: `is_connected()` reads `true` again, and the stale loop never exits, leaving two keepalive pingers on one socket (one more with every reconnect that races a slow-starting loop). ### Periodic maintenance Approximately every 12 keepalive ticks (\~5 minutes), the keepalive loop runs background cleanup of expired sent messages from the database, based on `CacheConfig::sent_message_ttl_secs`. ## Performance Considerations ### Buffer Sizing The sender task's outbound batch buffer (`out_buf`) starts at `BytesMut::with_capacity(OUT_BUF_IDLE_CAPACITY)` — 4096 bytes — and grows to whatever a batch needs to hold. Because `out_buf.split()` hands the written bytes to the transport without copying, that growth used to be permanent: once a single large stanza (a media-sized frame, say) pushed the buffer past its idle size, the allocation never shrank back down, so any socket that had ever sent one large frame carried the high-water-mark capacity — measured at 60 KiB resident per socket, against 8 KiB for a socket that only ever sent small frames — for the rest of the connection. **The batch buffer now shrinks back after a burst ends ([#1246](https://github.com/oxidezap/whatsapp-rust/pull/1246)).** After `SMALL_BATCHES_BEFORE_SHRINK` (32) consecutive small batches following a large one, the sender task replaces `out_buf` with a fresh `BytesMut::with_capacity(OUT_BUF_IDLE_CAPACITY)`, freeing the grown allocation. The counter only advances on a *small* batch and resets to zero on every large one, so a burst spread across many batches is never interrupted mid-flight to reallocate — only a connection that sends enough small batches after a burst pays the (one-time) regrowth cost the next time it sends something large. This buffer isn't a dedicated line item in either report. `memory_report()`'s named collections don't include it — it's local to the spawned sender task, with no handle anything outside that task can read. `resource_report()` is less absolute: the sender task is spawned through `runtime.spawn()`, so if the host wires up [`BotBuilder::with_alloc_meter`](/api/bot#with_alloc_meter), this buffer's growth and shrink allocations are folded into the client's aggregate `alloc` churn like any other task allocation — just not attributed to this buffer by name, and not as a live retained-capacity figure. Location: `src/socket/noise_socket.rs` (`OUT_BUF_IDLE_CAPACITY`, `SMALL_BATCHES_BEFORE_SHRINK`, `should_release_batch_buffer`) ### Write batching The sender task coalesces whatever send jobs are already queued (never blocking to wait for more) into one encrypted buffer and issues a single `transport.send()` for the batch. Coalescing stops at 16 frames or 64 KiB, whichever comes first — except a single frame already larger than 64 KiB, which still goes out alone rather than being truncated. See [Write batching (frame coalescing)](#write-batching-frame-coalescing) under NoiseSocket for the mechanics. Measured against a 120k-message/12k-per-second pingpong harness, this cut write syscalls per message by \~8.7% and allocator calls by \~0.76%; CPU impact was not statistically significant in that workload. The gain scales with how many independent producers (replies, delivery receipts, stanza acks) happen to queue within the same scheduler tick — a lone frame is written exactly as before. ### SIMD Encryption The Noise cipher uses hardware AES acceleration when available: ```rust theme={null} pub struct NoiseCipher { cipher: Aes256Gcm, // Uses AES-NI on x86_64 } ``` ### Zero-Copy Patterns **Send path** — reuse the marshal and output buffers across sends: ```rust theme={null} // Bad: Allocates new buffer let data = node.to_bytes(); socket.send(data).await?; // Good: Reuses buffer let mut buf = Vec::with_capacity(1024); marshal_to_vec(&node, &mut buf)?; socket.send(buf).await?; ``` **Receive path** — a read the transport still shares with its own buffer is copied once into `FrameDecoder`'s accumulation buffer, amortized over every frame that fits in a chunk; a uniquely-owned `Bytes` read of at least `CHUNK_SIZE` is adopted instead, when the accumulation buffer is empty: ```rust theme={null} frame_decoder.feed_owned(data); // copies shared, short, or mid-frame reads // Each frame is split out of the accumulation (or adopted) buffer, so the // downstream freeze() is a pointer move rather than a fresh allocation. while let Some(frame) = frame_decoder.decode_frame() { /* ... */ } ``` See [Frame Decoder](#frame-decoder) for `feed` vs. `feed_owned`, and for why an earlier per-payload zero-copy attempt (`feed_bytes`) never actually avoided a copy until this one. ## Testing ### Mock Transport ```rust theme={null} pub struct MockTransport; #[async_trait] impl Transport for MockTransport { async fn send(&self, data: Vec) -> Result<()> { // Record for assertions Ok(()) } async fn disconnect(&self) {} } ``` Location: `src/transport/mock.rs` ### Test Cases Key test scenarios: ```rust theme={null} #[tokio::test] async fn test_concurrent_sends_maintain_order() #[tokio::test] async fn test_encrypted_buffer_sizing_is_sufficient() #[tokio::test] async fn test_handshake_with_edge_routing() // Write batching (frame coalescing) #[tokio::test] async fn queued_frames_leave_in_one_write_in_counter_order() #[tokio::test] async fn a_batch_never_overshoots_the_byte_ceiling() #[tokio::test] async fn every_encrypted_frame_burns_its_own_counter() #[tokio::test] async fn the_transport_cause_reaches_the_caller() ``` `queued_frames_leave_in_one_write_in_counter_order` and `a_batch_never_overshoots_the_byte_ceiling` use a `GatedTransport` whose `send()` blocks on a semaphore, so a test can queue every job into the sender's channel before releasing any of them — the precondition for exercising coalescing at all. Location: `src/socket/noise_socket.rs:381-1082` ## Related Components * [Signal Protocol](/advanced/signal-protocol) - Message-level encryption * [Binary Protocol](/advanced/binary-protocol) - Payload serialization * [State Management](/advanced/state-management) - Connection state persistence ## References * Handshake: `src/handshake.rs` * NoiseSocket: `src/socket/noise_socket.rs` * Framing: `wacore/noise/src/framing.rs` * Transport: `src/transport/` * [Noise Protocol Framework](https://noiseprotocol.org/noise.html) * [Noise XX Pattern](https://noiseprotocol.org/noise.html#interactive-patterns) # Blocking Source: https://whatsapp-rust.jlucaso.com/api/blocking Block and unblock contacts, manage blocklist The `Blocking` struct provides methods for blocking and unblocking contacts, as well as retrieving and checking the blocklist. ## Access Access blocking operations through the client: ```rust theme={null} let blocking = client.blocking(); ``` ## Methods ### block Block a contact. Accepts either a LID or a PN JID; the client resolves the LID↔PN pair from its mapping cache and emits a stanza carrying both (`jid=LID`, `pn_jid=PN`). Modern WhatsApp servers reject PN-only block requests. ```rust theme={null} pub async fn block(&self, jid: &Jid) -> Result<(), BlockingError> ``` **Parameters:** * `jid` - Contact JID to block (LID or PN) **Requirements:** * A LID↔PN mapping for the target must exist in the client's mapping store. If no mapping is available, the call returns `BlockingError::InvalidJid`. **Effects:** * Contact will not be able to message you * Contact will not see your presence updates * Contact will not see your profile picture (depending on privacy settings) **Example:** ```rust theme={null} let contact: Jid = "15551234567@s.whatsapp.net".parse()?; client.blocking().block(&contact).await?; println!("Blocked {}", contact); ``` ### unblock Unblock a previously blocked contact. Accepts either a LID or a PN JID; PN input is internally resolved to the LID required on the wire. ```rust theme={null} pub async fn unblock(&self, jid: &Jid) -> Result<(), BlockingError> ``` **Parameters:** * `jid` - Contact JID to unblock (LID or PN) **Example:** ```rust theme={null} let contact: Jid = "15551234567@s.whatsapp.net".parse()?; client.blocking().unblock(&contact).await?; println!("Unblocked {}", contact); ``` ### get\_blocklist Retrieve the full list of blocked contacts. ```rust theme={null} pub async fn get_blocklist(&self) -> Result, BlockingError> ``` **Returns:** * `Vec` - All blocked contacts **BlocklistEntry fields:** * `jid: Jid` - Blocked contact JID * `timestamp: Option` - Unix timestamp when blocked (if available) **Example:** ```rust theme={null} let blocklist = client.blocking().get_blocklist().await?; println!("Blocked contacts: {}", blocklist.len()); for entry in blocklist { println!(" JID: {}", entry.jid); if let Some(ts) = entry.timestamp { println!(" Blocked at: {}", ts); } } ``` ### is\_blocked Check if a specific contact is blocked. Accepts either a LID or a PN JID; the client resolves the LID↔PN pair from its mapping cache so a PN-input query correctly matches a LID-keyed blocklist entry (and vice versa). ```rust theme={null} pub async fn is_blocked(&self, jid: &Jid) -> Result ``` **Parameters:** * `jid` - Contact JID to check (LID or PN) **Returns:** * `bool` - `true` if blocked, `false` otherwise **Behavior:** * Compares only the user part of the JID (ignores device ID) * Blocking applies to the entire user account, not individual devices * Because `block()` stores entries keyed by LID, `is_blocked()` resolves the queried JID's LID/PN pair before matching. If the mapping lookup fails (network or backend error), the call returns `Err` rather than silently falling back to the raw user, which would risk a false negative. When no mapping exists for the JID, the raw user part is used as-is. **Example:** ```rust theme={null} let contact: Jid = "15551234567@s.whatsapp.net".parse()?; if client.blocking().is_blocked(&contact).await? { println!("{} is blocked", contact); } else { println!("{} is not blocked", contact); } ``` ## BlocklistEntry Type ```rust theme={null} pub struct BlocklistEntry { pub jid: Jid, pub timestamp: Option, } ``` **Fields:** * `jid` - The blocked contact's JID * `timestamp` - Unix timestamp (seconds since epoch) when the contact was blocked The timestamp may be `None` if the server doesn't provide it. ## Error Types ### `BlockingError` All methods return `Result`: ```rust theme={null} #[non_exhaustive] pub enum BlockingError { #[error("{0}")] Iq(#[from] IqError), #[error("invalid blocklist target: {0}")] InvalidJid(String), #[error("{0}")] Internal(#[from] anyhow::Error), } ``` **Variants:** * `Iq` — Wraps an IQ request failure (timeout, server error, etc.) * `InvalidJid` — The provided JID was not a valid blocklist target * `Internal` — Internal error (network, encoding, etc.) ## Wire Format ### Block Request The block stanza carries both the LID (in `jid`) and the PN (in `pn_jid`). Modern WhatsApp servers reject blocks that omit `pn_jid`. ```xml theme={null} ``` ### Unblock Request The unblock stanza only requires the LID; `pn_jid` is omitted. ```xml theme={null} ``` ### Get blocklist request ```xml theme={null} ``` ### Blocklist Response ```xml theme={null} ``` Or direct items without `` wrapper: ```xml theme={null} ``` ## Usage Examples ### Block a Contact ```rust theme={null} let contact: Jid = "15551234567@s.whatsapp.net".parse()?; match client.blocking().block(&contact).await { Ok(_) => println!("Successfully blocked {}", contact), Err(e) => eprintln!("Failed to block: {}", e), } ``` ### Unblock a Contact ```rust theme={null} let contact: Jid = "15551234567@s.whatsapp.net".parse()?; match client.blocking().unblock(&contact).await { Ok(_) => println!("Successfully unblocked {}", contact), Err(e) => eprintln!("Failed to unblock: {}", e), } ``` ### Check before blocking ```rust theme={null} let contact: Jid = "15551234567@s.whatsapp.net".parse()?; if !client.blocking().is_blocked(&contact).await? { client.blocking().block(&contact).await?; println!("Contact blocked"); } else { println!("Contact already blocked"); } ``` ### List all blocked contacts ```rust theme={null} let blocklist = client.blocking().get_blocklist().await?; if blocklist.is_empty() { println!("No blocked contacts"); } else { println!("Blocked contacts:"); for entry in blocklist { print!(" - {}", entry.jid); if let Some(ts) = entry.timestamp { println!(" (blocked: {})", ts); } else { println!(); } } } ``` ### Conditional Block/Unblock ```rust theme={null} let contact: Jid = "15551234567@s.whatsapp.net".parse()?; let should_block = true; // Your logic here if should_block { if !client.blocking().is_blocked(&contact).await? { client.blocking().block(&contact).await?; println!("Blocked {}", contact); } } else { if client.blocking().is_blocked(&contact).await? { client.blocking().unblock(&contact).await?; println!("Unblocked {}", contact); } } ``` ### Batch block multiple contacts ```rust theme={null} let contacts_to_block = vec![ "15551111111@s.whatsapp.net".parse()?, "15552222222@s.whatsapp.net".parse()?, "15553333333@s.whatsapp.net".parse()?, ]; for contact in contacts_to_block { match client.blocking().block(&contact).await { Ok(_) => println!("Blocked {}", contact), Err(e) => eprintln!("Failed to block {}: {}", contact, e), } } ``` ## Error Handling ```rust theme={null} use whatsapp_rust::BlockingError; let contact: Jid = "15551234567@s.whatsapp.net".parse()?; match client.blocking().block(&contact).await { Ok(_) => println!("Blocked successfully"), Err(BlockingError::Iq(e)) => { eprintln!("IQ request failed: {}", e); } Err(BlockingError::InvalidJid(msg)) => { eprintln!("Invalid JID: {}", msg); } Err(e) => eprintln!("Error: {}", e), } ``` ## Device ID Handling The `is_blocked()` method compares only the user part of JIDs, ignoring device IDs: ```rust theme={null} let jid1: Jid = "15551234567.0@s.whatsapp.net".parse()?; // Device 0 let jid2: Jid = "15551234567.1@s.whatsapp.net".parse()?; // Device 1 // Block device 0 client.blocking().block(&jid1).await?; // Check if device 1 is blocked (will return true) assert!(client.blocking().is_blocked(&jid2).await?); // Blocking applies to the user account, not specific devices ``` ## Complete Example ```rust theme={null} use whatsapp_rust::features::blocking::BlocklistEntry; async fn manage_blocklist(client: &Client) -> anyhow::Result<()> { // Get current blocklist let blocklist = client.blocking().get_blocklist().await?; println!("Current blocklist: {} contacts", blocklist.len()); // Block a new contact let spam_contact: Jid = "15559999999@s.whatsapp.net".parse()?; if !client.blocking().is_blocked(&spam_contact).await? { client.blocking().block(&spam_contact).await?; println!("Blocked spam contact"); } // Unblock an old contact let old_friend: Jid = "15551234567@s.whatsapp.net".parse()?; if client.blocking().is_blocked(&old_friend).await? { client.blocking().unblock(&old_friend).await?; println!("Unblocked old friend"); } // Print updated blocklist let updated_blocklist = client.blocking().get_blocklist().await?; println!("\nUpdated blocklist:"); for entry in updated_blocklist { println!(" - {}", entry.jid); } Ok(()) } ``` # Bot Source: https://whatsapp-rust.jlucaso.com/api/bot High-level builder for creating WhatsApp bots with event handlers The `Bot` provides a simplified, ergonomic API for building WhatsApp bots. It handles client setup, event routing, and background sync tasks automatically. Don't confuse this with [`client.bots()`](/api/bots), which fetches WhatsApp's server-side directory of first-party AI bots. `Bot` here is the client-side framework for building a program that answers messages — a different domain that happens to share the word. ## Overview Use the Bot builder pattern to: * Configure storage backend, transport, HTTP client, and async runtime * Register event handlers * Configure device properties and versions * Enable pair code authentication * Skip history sync for bot use cases The builder uses a **typestate pattern** with four type parameters `` (Backend, Transport, HttpClient, Runtime). The `build()` method is only callable when all four are `Provided`, making missing-component errors compile-time instead of runtime. The Bot is the **recommended way** to use whatsapp-rust. It provides sensible defaults and handles boilerplate setup. ## Basic Usage ```rust theme={null} use whatsapp_rust::bot::Bot; use whatsapp_rust::TokioRuntime; use wacore::types::events::{Event, InboundMessage}; let mut bot = Bot::builder() .with_backend(backend) .with_transport_factory(transport) .with_http_client(http_client) .with_runtime(TokioRuntime) .on_event(|event, client| async move { match &*event { Event::Messages(batch) => { for InboundMessage { message: msg, info, .. } in batch.iter() { println!("Message from {}: {:?}", info.source.sender, msg); } } Event::Connected(_) => { println!("Connected to WhatsApp!"); } _ => {} } }) .build() .await?; let bot_handle = bot.run().await?; bot_handle.await?; ``` *** ## Builder Methods ### builder ```rust theme={null} pub fn builder() -> BotBuilder ``` Creates a new bot builder. ### with\_backend ```rust theme={null} pub fn with_backend(self, backend: Arc) -> Self ``` Sets the storage backend (required). Backend implementation providing storage operations **Example:** ```rust theme={null} use whatsapp_rust::store::SqliteStore; let backend = Arc::new(SqliteStore::new("whatsapp.db").await?); let bot = Bot::builder() .with_backend(backend) // ... ``` For multi-account scenarios, use `SqliteStore::new_for_device(path, device_id)` to create isolated storage per account. A bot that pairs once and stays connected for weeks is the single-long-lived-session profile, and `SqliteStore`'s defaults are tuned for the opposite one (many small per-session stores in a process). See [Memory and Thread Tuning](/concepts/storage#memory-and-thread-tuning-sqlitestoreconfig) for the cache size, reader count, and mmap setting that profile wants, and pass them with `SqliteStore::with_config` ([whatsapp-rust#1411](https://github.com/oxidezap/whatsapp-rust/pull/1411)). ### with\_transport\_factory ```rust theme={null} pub fn with_transport_factory(self, factory: F) -> Self where F: TransportFactory + 'static ``` Sets the transport factory for creating WebSocket connections (required). Transport factory implementation **Example:** ```rust theme={null} use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory; let bot = Bot::builder() .with_transport_factory(TokioWebSocketTransportFactory::new()) // ... ``` ### with\_http\_client ```rust theme={null} pub fn with_http_client(self, client: C) -> Self where C: HttpClient + 'static ``` Sets the HTTP client for media operations and version fetching (required). The client is wrapped in its own `Arc` on each call, so that `Arc` isn't shared with any other builder — though the client value itself still can be: cloning a `Clone` client like `UreqHttpClient` shares its underlying `ureq::Agent` and connection pool, so `with_http_client(shared.clone())` already shares state today. Use [`with_http_client_arc`](#with_http_client_arc) to hand several bots the identical `Arc` instead of a clone apiece. HTTP client implementation **Example:** ```rust theme={null} use whatsapp_rust_ureq_http_client::UreqHttpClient; let bot = Bot::builder() .with_http_client(UreqHttpClient::new()) // ... ``` ### with\_http\_client\_arc ```rust theme={null} pub fn with_http_client_arc(self, client: Arc) -> Self ``` Like [`with_http_client`](#with_http_client), but for a client that's already behind an `Arc`. Reach for this when a process runs several bots and you want them all pointing at one client — and one connection pool — instead of a clone apiece per builder. A single non-`Clone` client passed by value to one builder already works with `with_http_client`, since it carries no `Clone` bound. What `with_http_client` can't do is hand that same non-`Clone` client to a *second* builder — passed by value, it moves into the first one and no further. This setter covers that case, and it's also the only way in for a client that's already type-erased to `Arc` because the host chooses it at runtime. Already-shared HTTP client implementation **Example:** ```rust theme={null} use std::sync::Arc; use whatsapp_rust::http::{HttpClient, UreqHttpClient}; let http_client: Arc = Arc::new(UreqHttpClient::new()); for backend in session_backends { let bot = Bot::builder() .with_backend(backend) .with_http_client_arc(http_client.clone()) // ... .build() .await?; } ``` See [sharing one client across many sessions](/api/http-client#sharing-one-client-across-many-sessions) for what a shared client costs and what it doesn't. ### with\_runtime ```rust theme={null} pub fn with_runtime(self, runtime: Rt) -> Self where Rt: Runtime + 'static ``` Sets the async runtime for spawning tasks, sleeping, and blocking operations (required). Runtime implementation providing spawn, sleep, and spawn\_blocking **Example:** ```rust theme={null} use whatsapp_rust::TokioRuntime; let bot = Bot::builder() .with_runtime(TokioRuntime) // ... ``` `TokioRuntime` is only available when the `tokio-runtime` feature is enabled (it is by default). To use a different async runtime, implement the `Runtime` trait from `wacore::runtime`. See [custom backends](/guides/custom-backends#custom-runtime) for details. ### with\_task\_instrument ```rust theme={null} pub fn with_task_instrument( mut self, instrument: Arc, ) -> Self ``` Instruments the client's internal tasks with a `TaskInstrument` hook, called around every poll of a spawned task (and around blocking work). Runtime-agnostic — it wraps whichever `Runtime` the client uses (via [`with_runtime`](#with_runtime) or the default), so every task spawned through the `Runtime` trait is covered. [`Bot::run`](#run) also meters the main run loop itself (the read loop, including frame decryption), so that work is covered whether you launch via `bot.run().await` or `Bot::spawn()` — the two paths never double-wrap. Default: no hook, the runtime is used untouched and nothing is metered. When the `voip` feature is enabled, 1:1 call media tasks spawn directly on Tokio instead of through the `Runtime` trait, so they are **not** covered by this hook. A `CpuMeter` attached here will undercount CPU for sessions with active calls. Pass the built-in `wacore::stats::CpuMeter` for per-session CPU accounting (busy time + poll count), keeping a clone to read `.snapshot()` later. Or implement `TaskInstrument` yourself to scope allocator attribution, an ESP-IDF `heap_caps` sampler, or any other per-session platform hook — the library only calls `on_poll_start`/`on_poll_end` and never inspects what the hook does. Hook invoked around every poll of the client's internal tasks **Example:** ```rust theme={null} use std::sync::Arc; use wacore::stats::CpuMeter; let cpu = Arc::new(CpuMeter::new()); let bot = Bot::builder() .with_backend(backend) .with_task_instrument(cpu.clone()) .build() .await?; // Later, read accumulated busy time and poll count: let snapshot = cpu.snapshot(); println!("busy: {:?}, polls: {}", snapshot.busy, snapshot.polls); ``` Opt-in and off by default — instrumenting every poll has measurable overhead, so treat this as a diagnostics tool rather than an always-on meter. See [`Client::stats()`](/api/client#stats) for always-on wire I/O counters (atomics incremented on every frame) and [`Client::memory_report()`](/api/client#memory_report) for the on-demand, zero-cost-when-unused memory breakdown. ### with\_alloc\_meter ```rust theme={null} pub fn with_alloc_meter(mut self, meter: Arc) -> Self ``` Installs a `wacore::stats::AllocMeter` as this client's task instrument and keeps a typed handle so [`Client::resource_report()`](/api/client#resource_report) can fold in its allocation-churn snapshot. `AllocMeter` is a first-class `TaskInstrument` — the churn counterpart to `CpuMeter`'s busy-time tracking — that attributes heap bytes allocated and freed to this client. This is sugar over [`with_task_instrument`](#with_task_instrument): it occupies the same single instrument slot, so it's mutually exclusive with `CpuMeter` or any other hook — **last setter wins**. Calling `with_task_instrument` after `with_alloc_meter` drops the typed alloc-meter handle, so `resource_report()`'s `alloc` field reverts to `None` even though the instrument itself is replaced. The library never touches an allocator directly. The host must install a `#[global_allocator]` that calls `AllocMeter::on_alloc` / `AllocMeter::on_dealloc` on every (de)allocation — `examples/alloc_tracking.rs` in the source repo is the \~20-line reference implementation. Only allocations made *inside* an instrumented poll or blocking closure are counted — every task spawned through the `Runtime` trait, plus the main run loop (see the `with_task_instrument` `voip` caveat above, which applies here too). Deallocations are charged to whichever meter is active when the free happens, not the one that allocated the block, so `freed_bytes` (and `net_bytes()`) drift for buffers that outlive the poll that made them — `allocated_bytes` is the reliable cumulative signal. The allocation meter to install and drive via the poll hooks **Example:** ```rust theme={null} use std::sync::Arc; use wacore::stats::AllocMeter; let meter = Arc::new(AllocMeter::new()); let bot = Bot::builder() .with_backend(backend) .with_alloc_meter(meter.clone()) .build() .await?; // Later, fold the snapshot into the unified resource report: let report = bot.client().resource_report().await; if let Some(alloc) = report.alloc { println!("allocated: {} B, freed: {} B, net: {} B", alloc.allocated_bytes, alloc.freed_bytes, alloc.net_bytes()); } ``` *** ## Event Handling ### on\_event ```rust theme={null} pub fn on_event(self, handler: F) -> Self where F: Fn(Arc, Arc) -> Fut + Send + Sync + 'static, Fut: Future + Send + 'static ``` Registers an async event handler. The handler receives an `Arc` — use `&*event` to pattern-match on the inner event type. Async function that receives `Arc` and `Arc` **Example:** ```rust theme={null} use waproto::whatsapp as wa; Bot::builder() .on_event(|event, client| async move { match &*event { Event::Messages(batch) => { // Reply to messages for InboundMessage { info, .. } in batch.iter() { let reply = wa::Message { conversation: Some("Hello back!".to_string()), ..Default::default() }; let _ = client.send_message(info.source.chat.clone(), reply).await; } } Event::Connected(_) => { println!("Bot online!"); } _ => {} } }) // ... ``` See [Events Reference](/concepts/events) for all event types. ### on\_event\_for ```rust theme={null} pub fn on_event_for(self, kinds: &[EventKind], handler: F) -> Self where F: Fn(Arc, Arc) -> Fut + Send + Sync + 'static, Fut: Future + Send + 'static ``` Like `on_event`, but registers the handler with a narrowed [`EventInterest`](/concepts/events#typed-event-interest-skip-boxing-unwanted-events) so the event bus skips materializing kinds you don't subscribe to. Useful when you only handle a couple of event types and want to avoid paying the `Arc` allocation for high-frequency events like presence or receipts. ```rust theme={null} use wacore::types::events::EventKind; Bot::builder() .on_event_for(&[EventKind::Messages, EventKind::Connected], |event, client| async move { match &*event { Event::Messages(batch) => { /* … */ } Event::Connected(_) => println!("online"), _ => {} } }) // ... ``` ### with\_event\_delivery ```rust theme={null} pub fn with_event_delivery(mut self, delivery: EventDelivery) -> Self ``` Chooses how `on_event`/`on_event_for` (and the other closure-based registrars) receive events off the bus. ```rust theme={null} #[non_exhaustive] pub enum EventDelivery { /// Each event is delivered to each interested callback on its own spawned /// task (default). A slow callback stalls neither the bus nor its /// siblings, but ordering across events is not guaranteed and a /// persistently slow consumer can accumulate unbounded in-flight tasks. Concurrent, /// Events are delivered to the callbacks strictly in arrival order /// through a single bounded mailbox drained by one task — the ordered /// `messages.upsert` contract of WA Web (`preserveOrder`), whatsmeow and /// Baileys. When the mailbox is full the event is dropped and counted in /// `StatsSnapshot::events_dropped` instead of blocking the receive /// pipeline or growing without limit. Ordered { capacity: usize }, } ``` Delivery strategy. Defaults to `EventDelivery::Concurrent` — existing consumers are unaffected unless they opt in. **Example:** ```rust theme={null} use whatsapp_rust::prelude::*; Bot::builder() .with_backend(backend) // Deliver callbacks in arrival order through a 256-slot mailbox. .with_event_delivery(EventDelivery::Ordered { capacity: 256 }) .on_message(|ctx| async move { // Guaranteed to run in the order messages arrived. println!("{}", ctx.info.id); }) .build() .await?; ``` `capacity` is clamped to at least 1. Only affects the closure-based callbacks (`on_event`, `on_event_for`, `on_message`, and the other typed registrars) — a raw handler registered via [`with_event_handler`](#with_event_handler) always runs inline on the dispatch path and is unaffected by this setting. Under `Ordered`, a callback that panics is caught and logged; it does not kill the single drainer or drop later events. If you need at-least-once delivery instead of best-effort drops under load, pair `Ordered` with an [inbound durability hook](/advanced/inbound-durability) — the hook buffers and redelivers independently of the delivery mailbox. See [`Client::stats()`](/api/client#stats) for the `events_dropped` counter. ### with\_event\_handler ```rust theme={null} pub fn with_event_handler(mut self, handler: impl EventHandler + 'static) -> Self ``` Registers a struct-based [`EventHandler`](/concepts/events#eventhandler-trait) directly on the bus. Unlike the closure registrars, the handler holds its state as struct fields (no per-field clone dance); because `handle_event` takes `&self`, mutable state requires interior mutability (`Mutex`, `RwLock`, or atomics). `handle_event` runs inline on the dispatch path — spawn your own task for slow work. Not affected by [`with_event_delivery`](#with_event_delivery). **Example:** ```rust theme={null} use std::sync::Arc; use wacore::types::events::{Event, EventHandler}; struct MyStatefulHandler { /* ... */ } impl EventHandler for MyStatefulHandler { fn handle_event(&self, event: Arc) { // Runs inline — spawn if this does non-trivial work. } } Bot::builder() .with_backend(backend) .with_event_handler(MyStatefulHandler { /* ... */ }) // ... ``` ### Using ChannelEventHandler For scenarios where you need to process events outside of a closure (e.g., testing, custom event loops, or runtime-agnostic code), use `ChannelEventHandler` with `register_handler` instead of `on_event`: ```rust theme={null} use wacore::types::events::{ChannelEventHandler, Event, InboundMessage}; let mut bot = Bot::builder() .with_backend(backend) .with_transport_factory(transport) .with_http_client(http_client) .with_runtime(TokioRuntime) .build() .await?; let client = bot.client(); let (event_handler, event_rx) = ChannelEventHandler::new(); client.register_handler(event_handler); let handle = bot.run().await?; // Process events in your own async loop while let Ok(event) = event_rx.recv().await { match &*event { Event::Connected(_) => println!("Connected!"), Event::Messages(batch) => { for InboundMessage { info, .. } in batch.iter() { println!("Message from {}", info.source.sender); } } _ => {} } } ``` `ChannelEventHandler` uses `async-channel` (runtime-agnostic) with an unbounded buffer, so events fired before the receiver starts listening are not lost. You can combine it with `on_event` — both handlers will receive all events. ### with\_enc\_handler ```rust theme={null} pub fn with_enc_handler(self, enc_type: impl Into, handler: H) -> Self where H: EncHandler + 'static ``` Registers a custom handler for specific encrypted message types. Encrypted message type (e.g., "frskmsg", "skmsg") Handler implementation On `wasm32` targets, `EncHandler` drops the `Send + Sync` supertrait — your handler can capture `!Send` JS handles. On native, `Send + Sync` is retained via `MaybeSendSync` so `Arc` remains thread-safe. This mirrors the convention used by `EventHandler` and `SendContextResolver`. *** ## Configuration ### with\_version ```rust theme={null} pub fn with_version(self, version: (u32, u32, u32)) -> Self ``` Overrides the WhatsApp version used by the client. Tuple of (primary, secondary, tertiary) version numbers **Example:** ```rust theme={null} Bot::builder() .with_version((2, 3000, 1027868167)) // ... ``` By default, the client checks the cached WhatsApp Web version on each connect and only fetches a new one if that cache is missing or over 24 hours old. It reads that version from `web.whatsapp.com/sw.js`, except on `wasm32` targets, which read the same revision from the Facebook JS SDK bundle instead (see [HTTP Client — the version fetch does not pool a connection](/api/http-client#the-version-fetch-does-not-pool-a-connection)). Use `with_version` to pin a specific version when you need deterministic behavior — for example, in integration tests or CI environments where external HTTP requests are undesirable. As of PR #1360: that `wasm32` source, the Facebook JS SDK bundle, sits on common tracker blocklists. If a content blocker keeps a wasm client from reaching it, the client still connects: it settles for the version the device already holds and reports this on [`Event::Connected`](/concepts/events#connected) via `app_version_fallback`. Call `with_version` to skip the fetch (and this fallback) entirely, on either target. ### with\_device\_props ```rust theme={null} pub fn with_device_props(self, override_: DevicePropsOverride) -> Self ``` Overrides `DeviceProps` fields sent to WhatsApp servers during pairing. Takes a [`DevicePropsOverride`](#devicepropsoverride) built via its chained setters — a field left unset keeps the library's default for that field, it does not clear it. Builder describing which `DeviceProps` fields to override **Example:** ```rust theme={null} use waproto::whatsapp::device_props::PlatformType; use wacore::store::DevicePropsOverride; Bot::builder() .with_backend(backend) .with_device_props( DevicePropsOverride::new() .with_os("macOS") .with_platform_type(PlatformType::CHROME), ) // ... ``` `platform_type` determines the device name shown on the phone's **Linked Devices** list. Common values: `CHROME`, `FIREFOX`, `SAFARI`, `DESKTOP`. Only applied on the initial pairing — `DeviceProps` is not sent again after registration. #### DevicePropsOverride ```rust theme={null} pub struct DevicePropsOverride { pub os: Option, pub version: Option, pub platform_type: Option, pub require_full_sync: Option, pub history_sync_config: Option, } ``` | Setter | Overrides | Library default | | ---------------------------------------------- | --------------------- | ------------------------------------- | | `.with_os(impl Into)` | `os` | `"rust"` | | `.with_version(AppVersion)` | `version` | `0.1.0` | | `.with_platform_type(PlatformType)` | `platform_type` | `UNKNOWN` | | `.with_require_full_sync(bool)` | `require_full_sync` | `false` — requests a recent-only sync | | `.with_history_sync_config(HistorySyncConfig)` | `history_sync_config` | see below | WA Web itself only ever sends one of two coherent `DeviceProps` shapes, chosen by whether the companion is a browser or the Windows-native ("win\_hybrid") client: | | `platform_type` | `require_full_sync` | `full_sync_days_limit` | | -------------------------------- | -------------------- | ------------------- | ---------------------- | | browser (this library's default) | `CHROME`/`FIREFOX`/… | `false` | unset | | win\_hybrid | `UWP` | `true` | `365` | The library's sync fields (`require_full_sync`, `history_sync_config`) default to the browser row's sync behavior. Pairing requests a recent history sync, not a full backfill. The identity fields (`os`, `platform_type`) deliberately follow neither row. They stay at `"rust"` / `UNKNOWN` by default, so the library doesn't impersonate a specific client unless you configure one. To opt into a full backfill, set `require_full_sync` together with the matching `history_sync_config` fields. Setting `require_full_sync` alone produces a combination no real WhatsApp client sends: ```rust theme={null} use waproto::whatsapp::device_props::{HistorySyncConfig, PlatformType}; use wacore::store::{DevicePropsOverride, device::default_history_sync_config}; DevicePropsOverride::new() .with_platform_type(PlatformType::UWP) .with_require_full_sync(true) .with_history_sync_config(HistorySyncConfig { full_sync_days_limit: Some(365), on_demand_ready: Some(true), complete_on_demand_ready: Some(true), ..default_history_sync_config() }) ``` Earlier versions requested a full history sync (`require_full_sync: true`) on every pairing by default, and advertised `support_call_log_history: false`. Both now match WA Web's own browser default: `require_full_sync` is `false` (a recent-only sync) and `support_call_log_history` is `true`. If you relied on receiving a full backfill at pairing time, set `.with_require_full_sync(true)` plus a matching `history_sync_config`, as shown above ([PR #1164](https://github.com/oxidezap/whatsapp-rust/pull/1164)). ### with\_push\_name ```rust theme={null} pub fn with_push_name(self, name: impl Into) -> Self ``` Sets an initial push name on the device before connecting. Display name to set on the device **Example:** ```rust theme={null} Bot::builder() .with_push_name("My Bot") // ... ``` The push name is included in the `ClientPayload` during registration. This is useful for testing scenarios where the server assigns phone numbers based on push name. ### with\_ab\_props\_fetch ```rust theme={null} pub fn with_ab_props_fetch(self, enabled: bool) -> Self ``` Whether to fetch the server's A/B props catalog on connect, as WA Web does. On by default. Whether `fetch_props()` runs on connect. Default: `true`. **Example:** ```rust theme={null} Bot::builder() .with_ab_props_fetch(false) // skip the abt fetch entirely // ... ``` The catalog is the largest frame of an ordinary login — a few thousand props, \~30 KB compressed — and the client keeps a couple of dozen of them. It's consumed as a stream (see [`Client::execute_streaming`](/api/client#execute_streaming)), so on most hosts it costs nothing worth turning off. Turn it off only if your target's heap can't afford even the compressed frame plus the inflate state (\~80 KB together) at the moment it arrives. Turned off, every flag reads as its registry default — the value WA Web itself uses before its first fetch. The server sees no `abt` request (whatsmeow never sends one) and accepts your client either way. The cost: an account the server has 1:1-LID-migrated is not recognized as such from the props (`lid_one_on_one_migration_enabled` defaults to off), and the privacy-token and trusted-contact-token gates run on their defaults. *** ## Authentication ### with\_pair\_code ```rust theme={null} pub fn with_pair_code(self, options: PairCodeOptions) -> Self ``` Configures pair code authentication to run automatically after connecting. Configuration for pair code authentication **Example:** ```rust theme={null} use whatsapp_rust::pair_code::PairCodeOptions; use wacore::companion_reg::CompanionWebClientType; use wacore::types::events::{Event, PairingCode}; Bot::builder() .with_pair_code(PairCodeOptions { phone_number: "15551234567".to_string(), show_push_notification: true, custom_code: None, // `None` derives the wire id from the device's `PlatformType`. // Override only when you need a specific ``. platform_id: Some(CompanionWebClientType::Chrome), // `..Default::default()` covers `display_os` (`None` = safe OS // canonicalization; see the Tip below). ..Default::default() }) .on_event(|event, _client| async move { match &*event { Event::PairingCode(PairingCode { code, timeout, .. }) => { println!("Enter this code on your phone: {}", code); println!("Expires in: {} seconds", timeout.as_secs()); } _ => {} } }) // ... ``` Pair code runs concurrently with QR code pairing — whichever completes first wins. `with_pair_code` runs [`Client::pair_with_code`](/api/client#pair_with_code) in a detached task, so a failure never reaches a caller as an `Err` — it only reaches [`Event::PairingCodeError`](/concepts/events#pairingcodeerror). Register [`on_pair_code_error`](#on_pair_code_error) if the consumer must distinguish "still waiting for the user" from "no code is coming" (a rate-limited request otherwise looks identical to the former). The `companion_platform_display` shown on the phone is derived automatically from the resolved `platform_id` and a **canonicalized** OS derived from the device's `os` string: web variants emit ` ()` (Android `PlatformType`s map to `Chrome`, so they show as `Chrome (Android)` by default); explicit `AndroidPhone`/`AndroidTablet`/`AndroidAmbiguous` overrides emit `Android ()`. The OS is coerced into a small server-safe set (`Windows`/`Mac OS`/`Linux`/`Android`/`iOS`) because the pair-code server rejects a non-OS display with `bad-request` — an arbitrary branding `os` string falls back to `Linux`. Set `PairCodeOptions::display_os` to send a real, non-canonical OS name verbatim instead. See [Authentication — companion\_platform\_display](/concepts/authentication#companion-platform-display) for the full classification table. ### on\_pair\_code\_refresh ```rust theme={null} pub fn on_pair_code_refresh(self, handler: F) -> Self where F: Fn(bool, Arc) -> Fut + Send + Sync + 'static, Fut: Future + Send + 'static ``` Registers a handler for [`Event::PairingCodeRefresh`](/concepts/events#pairingcoderefresh), fired when the in-progress phone-number pairing code should be replaced. The `bool` argument is `force_manual`. Async function that receives `force_manual: bool` and `Arc` **Example:** ```rust theme={null} use whatsapp_rust::pair_code::PairCodeOptions; Bot::builder() .with_pair_code(PairCodeOptions { phone_number: "15551234567".to_string(), ..Default::default() }) .on_pair_code_refresh(|force_manual, client| async move { println!("Pair code needs a refresh (force_manual={force_manual})"); // The previous code is no longer valid, and the flow is already // clear — request a fresh one with the same phone number. let _ = client.pair_with_code(PairCodeOptions { phone_number: "15551234567".to_string(), ..Default::default() }).await; }) // ... ``` This callback fires for **two** triggers, not just a server request: the server asking for a refresh (only while a pair-code flow is outstanding and the notification's ref matches it — a `refresh_code` notification for a stale or unrelated flow is ignored), and a non-refused `companion_finish` — accepted, or its own 30s wait going unanswered — whose `pair-success` then went unanswered for a minute (`force_manual` is always `false` for this second trigger). A `companion_finish` the server actively *refuses* is reported through [`on_pair_code_error`](#on_pair_code_error) instead, immediately rather than after this timeout — register that handler too if you need to react to a refusal, since it no longer reaches this one. See [Pair code refresh events](/concepts/authentication#pair-code-refresh-events) for the full breakdown. ### on\_pair\_code\_error ```rust theme={null} pub fn on_pair_code_error(self, handler: F) -> Self where F: Fn(PairingCodeError, Arc) -> Fut + Send + Sync + 'static, Fut: Future + Send + 'static ``` Registers a handler for [`Event::PairingCodeError`](/concepts/events#pairingcodeerror), fired when a pair-code flow fails — either a stage-1 request that never gets a code issued, or a stage-2 `companion_finish` the server refuses after a code was already entered on the phone. The counterpart to [`on_pair_code_refresh`](#on_pair_code_refresh) on the failure path, and a dedicated convenience over matching the event yourself in `on_event` — either works to observe it. A [`with_pair_code`](#with_pair_code) request runs in a detached task, so the `Err` it would otherwise return reaches no caller directly; this event is the only surface that reports its failure at all. Async function that receives the `PairingCodeError` event and `Arc` **Example:** ```rust theme={null} use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; use whatsapp_rust::pair_code::{PairCodeOptions, PairCodeRejection}; const MAX_THROTTLE_RETRIES: u32 = 3; let retries = Arc::new(AtomicU32::new(0)); Bot::builder() .with_pair_code(PairCodeOptions { phone_number: "15551234567".to_string(), ..Default::default() }) .on_pair_code_error(move |err, client| { let retries = retries.clone(); async move { eprintln!("Pair code request failed: {}", err.error); match err.rejection { // Cap attempts — a persistently throttled number should stop // retrying rather than loop forever on repeated PairingCodeError. Some(r) if r.is_throttled() && retries.fetch_add(1, Ordering::Relaxed) < MAX_THROTTLE_RETRIES => { // Back off using the server's own hint when it gave one. let delay = err.backoff.unwrap_or(std::time::Duration::from_secs(30)); tokio::time::sleep(delay).await; let _ = client.pair_with_code(PairCodeOptions { phone_number: "15551234567".to_string(), ..Default::default() }).await; } Some(PairCodeRejection::FeatureNotAvailable) => { // Retrying will not help — fall back to QR pairing instead. } _ => {} } } }) // ... ``` Branch on `err.rejection` rather than the message, which is not a stable surface — see [Pair code failure events](/concepts/authentication#pair-code-failure-events) for the full field breakdown and the two failures ([`PairCodeError::CodeAlreadyOutstanding`](/concepts/authentication#one-code-at-a-time) and `Cancelled`) that deliberately never reach this handler. *** ## Cache Configuration ### with\_cache\_config ```rust theme={null} pub fn with_cache_config(self, config: CacheConfig) -> Self ``` Configures cache TTL and capacity settings for internal caches. Custom cache configuration **Example:** ```rust theme={null} use whatsapp_rust::{CacheConfig, CacheEntryConfig}; use std::time::Duration; Bot::builder() .with_cache_config(CacheConfig { group_cache: CacheEntryConfig::new(None, 500), // No TTL, 500 entries device_registry_cache: CacheEntryConfig::new(Some(Duration::from_secs(1800)), 2000), ..Default::default() }) // ... ``` See [Cache Configuration](#cache-configuration-reference) for available cache types. *** ## History Sync History sync transfers chat history from the phone to the linked device. The processing pipeline is optimized for minimal RAM usage through zero-copy streaming and lazy parsing. ### How it works When your bot receives history sync data, the pipeline: 1. **Stream-decrypts** external blobs in 8KB chunks (or moves inline payloads without copying) 2. **Decompresses** zlib data on a blocking thread with pre-allocated buffers capped at 8 MiB 3. **Walks protobuf fields manually** instead of decoding the entire message tree — only internal data (pushname, NCT salt, TC tokens) is extracted at this stage 4. **Wraps the compressed payload** in a [`LazyHistorySync`](/concepts/events#lazyhistorysync) with cheap metadata (sync type, chunk order, progress) available without decoding 5. **Dispatches** `Event::HistorySync(Box)` — full protobuf decoding is deferred until you call `.get()`. Use `.stream()` for incremental memory-bounded access, `.decompress()` for one-shot inflation, or `.compressed_bytes()` for the raw compressed payload If no event handlers are registered, the blob is not retained in memory. ### skip\_history\_sync ```rust theme={null} pub fn skip_history_sync(self) -> Self ``` Skips processing of history sync notifications from the phone. When enabled: * Sends a receipt so the phone stops retrying uploads * Does not download or process historical data * Emits debug log for each skipped notification * Useful for bot use cases where message history is not needed **Example:** ```rust theme={null} Bot::builder() .skip_history_sync() // ... ``` For bots that only need to respond to new messages, enabling this can significantly reduce startup time and bandwidth usage. *** ### with\_wanted\_pre\_key\_count ```rust theme={null} pub fn with_wanted_pre_key_count(self, count: usize) -> Self ``` Sets the number of one-time pre-keys generated and uploaded per batch. Mirrors WhatsApp Web's `UPLOAD_KEYS_COUNT`. Default: `812`. The value is clamped at upload time to `5..=65_535`. Values outside that range log a `warn!` and are clamped to the nearest bound. Pre-keys per upload batch. Clamped to `5..=65_535`. **Example:** ```rust theme={null} Bot::builder() .with_wanted_pre_key_count(256) // smaller batch for embedded hosts // ... ``` Leave this at the default unless you have a specific reason to change it. Embedded or memory-constrained consumers may prefer a smaller batch to shrink the working set during each upload; smaller batches also mean more frequent uploads as peers consume keys. The floor of 5 prevents an empty-but-flagged pool and a re-upload loop (the count guard never clears below the trigger threshold). The ceiling of 65,535 is the wire-format limit — the upload IQ encodes the pre-key list length as a `u16`, so a larger batch would generate and store keys locally and then fail to encode. *** ## Building and Running ### build ```rust theme={null} pub async fn build(self) -> Result ``` Builds the bot with the configured options. **Errors:** ```rust theme={null} pub enum BotBuilderError { Other(anyhow::Error), } ``` | Variant | Cause | | ------- | ----------------------------------------------- | | `Other` | Backend initialization or other runtime failure | Missing required components (backend, transport, HTTP client, runtime) are caught at **compile time** via the typestate pattern — `build()` is only available when all four type parameters are `Provided`. You won't see runtime errors for missing components. `BotBuilder` is `#[must_use]` — building it does nothing until you call `.build()`, so a builder chain left unbound (or dropped before `.build()`) now triggers a compiler warning. ### client ```rust theme={null} pub fn client(&self) -> Arc ``` Returns the underlying Client Arc. **Example:** ```rust theme={null} let bot = Bot::builder() .with_backend(backend) .with_transport_factory(transport) .with_http_client(http_client) .with_runtime(TokioRuntime) .build() .await?; let client = bot.client(); let jid = client.pn(); ``` ### run ```rust theme={null} pub async fn run(&mut self) -> Result ``` Starts the bot's connection loop and background workers. Returns a `BotHandle` that implements `Future`. You can also call `.abort()` on it to cancel the bot. **Example:** ```rust theme={null} let mut bot = Bot::builder() // ... configuration .build() .await?; let handle = bot.run().await?; // Wait for bot to finish (runs until disconnect) handle.await?; ``` `BotHandle` is `#[must_use]`: dropping it aborts the bot task instead of leaving it running in the background. Bind it and either `.await` it or call `.shutdown()`/`.abort()` explicitly — do not let it fall out of scope while you expect the bot to keep running. If a [`with_task_instrument`](#with_task_instrument) hook is configured, `run()` meters the client's main run loop (the read loop, including frame decryption) itself, in addition to the tasks the instrumented runtime already covers. This closes the gap where `bot.run().await` polls that future on the caller's task rather than through `Runtime::spawn`. *** ## MessageContext A convenience helper for message handling. You can construct it from an `InboundMessage` — the item type carried by `Event::Messages`' `MessageBatch`: ```rust theme={null} pub struct MessageContext { pub message: Arc, pub info: MessageInfo, pub client: Arc, /// Disappearing-message timer of the chat this message arrived in, in /// seconds, when the stanza carried one. Mirrors /// `InboundMessage::ephemeral_expiration`. pub ephemeral_expiration: Option, /// For a decrypted newsletter comment, the key of the post it replies to. /// Mirrors `InboundMessage::comment_target`. pub comment_target: Option>, } ``` Since v0.6 `message` is `Arc` (was `Box`). This matches the `InboundMessage` payload and lets `from_inbound` / `from_arc` reuse the bus-dispatched `Arc` with zero deep clones. `ephemeral_expiration` and `comment_target` used to live on the shared `MessageInfo`. Writing them there needed an `Arc::make_mut` copy of the whole struct on every message in an ephemeral chat. They moved to `InboundMessage` to avoid that copy, and `MessageContext` now carries them too. Use [`from_inbound`](#from_inbound) when you need these fields — it's the only constructor that sees the stanza-derived event. `from_arc` and `from_parts` build from `message`/`info` alone, so they always leave both fields `None`. A bot handler reached through `Bot::on_message` (which uses `from_inbound` internally) sees them populated whenever the underlying event carried them. ### from\_parts ```rust theme={null} pub fn from_parts(message: &wa::Message, info: &MessageInfo, client: Arc) -> Self ``` Constructs a `MessageContext` from individual message components. Internally clones the `wa::Message` into a new `Arc`. ### from\_arc ```rust theme={null} pub fn from_arc(message: Arc, info: &MessageInfo, client: Arc) -> Self ``` Constructs a `MessageContext` from an existing `Arc` without copying the body — pair this with the `Arc` you receive from an `InboundMessage` to keep dispatch zero-clone. `ephemeral_expiration` and `comment_target` are left `None`, since this constructor never sees the stanza they're read from. ### from\_inbound ```rust theme={null} pub fn from_inbound(inbound: &InboundMessage, client: Arc) -> Self ``` Extracts a `MessageContext` from a single `InboundMessage` (one item of a `MessageBatch`). Unlike the removed `from_event`, this is infallible — there's no "wrong event kind" case once you're iterating `Event::Messages`' batch. Reuses the existing `Arc` rather than cloning the body, and carries `ephemeral_expiration` and `comment_target` straight from the event. This is what `Bot::on_message` uses internally to fan a batch out to your per-message handler, invoked once per item in arrival order. ### send\_message ```rust theme={null} pub async fn send_message(&self, message: wa::Message) -> Result ``` Sends a message to the same chat. Returns a [`SendResult`](/api/send#sendresult) containing the `message_id` and `to` JID. ### build\_quote\_context ```rust theme={null} pub fn build_quote_context(&self) -> wa::ContextInfo ``` Builds a quote context for replying to this message. Handles: * Correct stanza\_id/participant for groups and newsletters * Stripping nested mentions * Preserving bot quote chains **Example:** ```rust theme={null} use waproto::whatsapp as wa; use whatsapp_rust::bot::MessageContext; .on_event(|event, client| async move { for inbound in event.messages() { let ctx = MessageContext::from_inbound(inbound, client.clone()); let reply = wa::Message { extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Quoted reply!".to_string()), context_info: buffa::MessageField::some(ctx.build_quote_context()), ..Default::default() }), ..Default::default() }; let _ = ctx.send_message(reply).await; } }) ``` ### edit\_message ```rust theme={null} pub async fn edit_message( &self, original_message_id: impl Into, new_message: wa::Message ) -> Result ``` Edits a message in the same chat. See [`Client::edit_message`](/api/send#edit_message) for what the returned `SendResult` describes. ### revoke\_message ```rust theme={null} pub async fn revoke_message( &self, message_id: String, revoke_type: RevokeType ) -> Result ``` Deletes a message in the same chat. See [`Client::revoke_message`](/api/send#revoke_message) for what the returned `SendResult` describes. ### react ```rust theme={null} pub async fn react(&self, emoji: &str) -> Result ``` Sends an emoji reaction to the incoming message. The chat JID, target message ID, and group/status `participant` are taken from the context — you only supply the emoji. Pass an empty string (`""`) to remove a previously sent reaction. Internally this calls [`Client::send_reaction`](/api/send#send_reaction) with `self.message_key()` as the target. **Example:** ```rust theme={null} use whatsapp_rust::bot::MessageContext; .on_event(|event, client| async move { for inbound in event.messages() { // React with a thumbs-up to every incoming message. let ctx = MessageContext::from_inbound(inbound, client.clone()); let _ = ctx.react("👍").await; } }) ``` Newsletter (channel) messages don't flow through `MessageContext::react`. Use [`client.newsletter().send_reaction()`](/api/newsletter#send_reaction) for newsletter reactions. *** ## Complete Example ```rust theme={null} use whatsapp_rust::bot::Bot; use whatsapp_rust::TokioRuntime; use whatsapp_rust::store::SqliteStore; use wacore::types::events::{Event, InboundMessage, PairingQrCode}; use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory; use whatsapp_rust_ureq_http_client::UreqHttpClient; use waproto::whatsapp as wa; use std::sync::Arc; #[tokio::main] async fn main() -> anyhow::Result<()> { // Set up storage let backend = Arc::new(SqliteStore::new("bot.db").await?); // Build bot let mut bot = Bot::builder() .with_backend(backend) .with_transport_factory(TokioWebSocketTransportFactory::new()) .with_http_client(UreqHttpClient::new()) .with_runtime(TokioRuntime) .skip_history_sync() // Bot only needs new messages .on_event(|event, client| async move { match &*event { Event::Messages(batch) => { // Echo messages back for InboundMessage { message: msg, info, .. } in batch.iter() { if let Some(text) = &msg.conversation { let reply = wa::Message { conversation: Some(format!("You said: {}", text)), ..Default::default() }; let _ = client.send_message(info.source.chat.clone(), reply).await; } } } Event::Connected(_) => { println!("Bot is now online!"); // Set status let _ = client.presence().set_available().await; } Event::PairingQrCode(PairingQrCode { code, .. }) => { println!("Scan this QR code:"); println!("{}", code); } _ => {} } }) .build() .await?; // Run bot let handle = bot.run().await?; handle.await?; Ok(()) } ``` *** ## Cache configuration reference The `CacheConfig` struct controls TTL and capacity for all internal caches. All fields have sensible defaults matching WhatsApp Web behavior. ### CacheEntryConfig ```rust theme={null} pub struct CacheEntryConfig { pub timeout: Option, // None = no time-based expiry pub capacity: u64, // Maximum entries } ``` ### Available Caches #### Timed caches | Cache | Default TTL | Default Capacity | Description | | -------------------------- | ------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `group_cache` | 1 hour | 250 | Group metadata | | `device_registry_cache` | 1 hour | 20,000 | Device registry — one entry per contact whose device list is known; a ceiling, not a preallocation | | `lid_pn_cache` | 1 hour (TTI) | 10,000 | LID-to-phone mapping | | `retried_group_messages` | 5 minutes | 2,000 | Retry tracking | | `recent_messages` | 5 minutes | 0 (disabled) | Optional L1 in-memory cache for sent messages (retry support) | | `message_retry_counts` | 5 minutes | 1,000 | Retry count tracking | | `pdo_pending_requests` | 30 seconds | 500 | PDO pending requests | | `pdo_requested` | 24 hours | 512 | PDO placeholder-resend memo — at-most-once per message | | `dispatched_messages` | 5 minutes | 1,000 | Dispatch-once gate for a decrypted message, keyed by chat/id/sender — collapses a sender's outbox retry (same id, re-encrypted) into a single `Event::Messages`. Capacity 0 disables it. See [`Event::Messages`](/concepts/events#messages) | | `sender_key_devices_cache` | 1 hour (TTI) | 500 | Per-group SKDM distribution state | The `lid_pn_cache` and `sender_key_devices_cache` use time-to-idle (TTI) semantics — entries expire after being idle for the timeout period. All other caches use time-to-live (TTL) semantics. The `recent_messages` cache is disabled by default (capacity 0), meaning sent messages are stored only in the database for retry handling — matching WhatsApp Web's behavior. Set capacity greater than 0 to enable a fast in-memory L1 cache in front of the database. See [DB-backed sent message retry](#db-backed-sent-message-retry) for details. `device_registry_cache`'s default rose from 5,000 to 20,000 in [whatsapp-rust#1400](https://github.com/oxidezap/whatsapp-rust/pull/1400). At 5,000, if your account was active in a few dozen mid-sized groups, it could hold more distinct contacts than the cache, so every group-devices memo recompute paid a backend read per evicted member. The cache only ever holds what has been resolved, so you pay nothing for the higher ceiling if your account stays small. #### Coordination caches (capacity-only, no TTL) | Setting | Default | Description | | ----------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `session_locks_capacity` | 10,000 | Per-device Signal session lock capacity. Soft cap: a lock a task is actively holding is never evicted, so the map can briefly exceed this under heavy concurrent fan-out (bounded by the number of concurrently-held locks) rather than evicting a live lock and letting two writers race the same session | | `chat_lanes_capacity` | 5,000 | Per-chat lane capacity: number of cached lanes, each an enqueue lock paired with an **unbounded** message queue (there is no per-lane message cap). Soft cap: a lane with an in-flight message is never evicted, so the map can briefly exceed this under a backlog spread across many active chats, rather than evicting a live lane and letting a second worker start on the same chat. Independently of this capacity, a lane's worker exits on its own after 60s without a new message, so an idle chat doesn't hold its worker's memory for the life of the connection — see [Concurrency Patterns — Per-Chat Lanes](/concepts/architecture#per-chat-lanes) | | `group_distribution_locks_capacity` | 512 | Per-group sender-key distribution lane capacity. Soft cap: a live lane (held by an in-flight SKDM fan-out, rotation, or tracker reset) is never evicted, so the map can briefly exceed this under concurrent fan-out rather than breaking tracker ordering | | `group_devices_memo_capacity` | 512 | Per-group resolved-device memo capacity: the device list a group send fans out to plus its member index. Also bounds the SKDM warm-target memo, which is keyed the same way. Eviction is least-recently-used: if your account is active in more groups than this, it re-resolves only its least active ones. Below the limit, a warm send avoids re-resolution as long as its memo entry stays valid — see `miss_group_info` / `miss_topology` in [`GroupDevicesMemoStats`](/api/client#device_memo_stats) for the cases where it doesn't | | `dm_devices_memo_capacity` | 512 | Per-1:1-chat resolved-device memo capacity, same least-recently-used eviction policy | The lane's live entry count plus cumulative capacity evictions and blocked evictions are exposed via [`Client::memory_report()`](/api/client#memory_report) (`group_distribution_locks`, `group_distribution_lock_evictions`, `group_distribution_lock_eviction_blocks`), so operators can derive eviction rates and tune this capacity without guessing. `group_devices_memo_capacity` and `dm_devices_memo_capacity` replaced two private, fixed constants in [whatsapp-rust#1400](https://github.com/oxidezap/whatsapp-rust/pull/1400) and gained least-recently-used eviction in the same change (previously oldest-first). The prior fixed bound was 64 groups: if you rotate sends across more than 64 groups, oldest-first eviction guaranteed the entry for the group you were about to resolve had already been evicted by the intervening sends, for a hit rate of exactly zero. At the new default of 512, the `client_group_scale` bench's warm-resolve pass over 256 groups of 64 members each (one send per group, cycling through all of them) goes from a 33.4 ms median — every group missing and re-resolving from scratch, at the old 64-group bound — to 33.0 µs — every group hitting, at the new 512 bound. #### Sent message DB cleanup | Setting | Default | Description | | ----------------------- | -------------- | ------------------------------------------------------------------------------------------------------ | | `sent_message_ttl_secs` | 7200 (2 hours) | TTL in seconds for sent messages in DB before periodic cleanup. Set to 0 to disable automatic cleanup. | The `sent_message_ttl_secs` default was raised from 300s to 7200s. Retry receipts can arrive well after a message is sent (e.g. after the recipient comes back online); a 5-minute TTL could expire the stored payload before its retry, silently dropping the retry. Two hours covers realistic offline gaps. #### messageSecret retention The client stores `messageSecret` values so it can later decrypt add-ons that reference an original message — poll votes, poll/event edits, message edits, and Meta AI / fbid bot replies. Retention is bounded by policy and a per-class **event-time horizon** (`expires_at = parent_message_ts + horizon`, not insertion time), so secrets survive offline gaps without growing unbounded. | Field | Type | Default | Description | | ------------------------------- | ------------------------------------------ | --------------- | -------------------------------------------------- | | `msg_secret_policy` | `MsgSecretPolicy` | `Managed` | Which retention tier to use (see below) | | `msg_secret_retention` | `MsgSecretRetention` | 30d / 90d / 30d | Per-class horizons (`text` / `poll_event` / `bot`) | | `seed_msg_secrets_from_history` | `bool` | `true` | Seed secrets from pairing history-sync blobs | | `original_message_resolver` | `Option>` | `None` | App-supplied fallback when the store misses | | `msg_secret_resolver_timeout` | `Duration` | 5s | Timeout for a single resolver call | ```rust theme={null} pub enum MsgSecretPolicy { /// Bounded (default): capture live secrets, seed only the still-relevant /// history slice, prune by per-add-on event-time horizon. Managed, /// Pre-v0.6 behavior: capture/seed only in bot (msmsg) contexts. BotOnly, /// Unbounded: capture/seed everything, never prune. Full, /// Persist nothing in core; rely entirely on `original_message_resolver`. Disabled, } pub struct MsgSecretRetention { pub text: Duration, // default 30 days — message-edit parents pub poll_event: Duration, // default 90 days — poll votes / PollAddOption / EventEdit / PollEdit pub bot: Duration, // default 30 days — outbound msmsg bot context } ``` The `OriginalMessageResolver` trait lets you supply secrets from your own store (required when the policy is `Disabled`): ```rust theme={null} #[async_trait] pub trait OriginalMessageResolver: Send + Sync { async fn resolve_msg_secret( &self, chat: &str, sender: &str, msg_id: &str, ) -> Option<[u8; 32]>; } ``` It is consulted only after the in-core [`MsgSecretStore`](/api/store#msgsecretstore) and the LID/PN alternate lookups miss. `MsgSecretPolicy`, `MsgSecretRetention`, and `OriginalMessageResolver` are re-exported from the crate root (`whatsapp_rust::{MsgSecretPolicy, MsgSecretRetention, OriginalMessageResolver}`). The default `Managed` policy is bounded and needs no tuning for most apps. **Steady-state sizing.** `msg_secret_retention` sizes what is, by row count, the largest table the store holds: one row per inbound message that carries a `messageSecret`, plus one per outbound message that mints one, each kept until its horizon passes. The steady state is therefore the horizon's worth of traffic, and nothing else bounds it. For a busy bot — roughly 15k inbound and 1.5k outbound messages a day — the default 30-day `text` horizon settles at \~500k rows, or \~130 MB at roughly 270 bytes a row once the primary key and the expiry index are counted, and materially more where poll traffic (a 90-day horizon) is heavy. Shortening `text` trades add-on decryption of older messages for disk, and is the one lever that moves the figure. See [whatsapp-rust#1411](https://github.com/oxidezap/whatsapp-rust/pull/1411). **`InMemoryBackend` only:** on top of the horizons above, the built-in `InMemoryBackend` caps the number of *expiring* secret rows it retains at 8,192, backend-wide across all chats — so wasm32 linear memory (whose allocator never returns freed pages) can't grow without bound from that traffic. Permanent rows (`expires_at = 0`, written by `Full` policy or a direct `put_msg_secret` call) are never evicted and don't count toward the cap, so they can still accumulate past it. Once the cap is hit, eviction drops the soonest-to-expire rows first, always by whole message — a message's sender-alias rows are kept or dropped together. That's not necessarily the oldest messages: horizons differ by class, so a fresh text secret (30-day horizon) can expire, and be evicted, before an older poll secret (90-day horizon). `SqliteStore`, the production backend, has no such cap and honors the full `msg_secret_retention` horizons unmodified. See [whatsapp-rust#1297](https://github.com/oxidezap/whatsapp-rust/pull/1297). #### Custom cache store overrides You can replace any of the pluggable caches with a custom `CacheStore` backend (e.g., Redis): | Field | Cache | Description | | ------------------------------------ | --------------- | ---------------------------------- | | `cache_stores.group_cache` | Group metadata | Group info lookups | | `cache_stores.device_registry_cache` | Device registry | Device registry entries | | `cache_stores.lid_pn_cache` | LID-PN mapping | LID-to-phone bidirectional lookups | ```rust theme={null} use whatsapp_rust::{CacheConfig, CacheStores}; use std::sync::Arc; let redis = Arc::new(MyRedisCacheStore::new("redis://localhost:6379")); // Route specific caches to Redis let config = CacheConfig { cache_stores: CacheStores { group_cache: Some(redis.clone()), device_registry_cache: Some(redis.clone()), ..Default::default() }, ..Default::default() }; // Or route all pluggable caches at once let config = CacheConfig { cache_stores: CacheStores::all(redis.clone()), ..Default::default() }; ``` Fields left as `None` keep the default in-process `PortableCache` behaviour. See [Custom backends — cache store](/guides/custom-backends#custom-cache-store) for a full implementation guide. Coordination caches (`session_locks`, `chat_lanes`, `group_distribution_locks`), the signal write-behind cache, and `pdo_pending_requests` always stay in-process — they hold live Rust objects that cannot be serialized to an external store. ### Custom configuration example ```rust theme={null} use whatsapp_rust::{CacheConfig, CacheEntryConfig}; use std::time::Duration; let config = CacheConfig { // Disable TTL for group cache (entries only evicted by capacity) group_cache: CacheEntryConfig::new(None, 500), // Shorter TTL for device cache device_registry_cache: CacheEntryConfig::new(Some(Duration::from_secs(1800)), 3_000), // Enable L1 in-memory cache for sent messages (faster retry lookups) recent_messages: CacheEntryConfig::new(Some(Duration::from_secs(300)), 1_000), // Use defaults for everything else ..Default::default() }; let bot = Bot::builder() .with_runtime(TokioRuntime) .with_cache_config(config) // ... ``` ### DB-backed sent message retry Sent messages are persisted to the database for retry handling, matching WhatsApp Web's `getMessageTable` pattern. When a retry receipt arrives, the client looks up the original message payload from the database, re-encrypts it, and resends. **How it works:** 1. Every `send_message()` call stores the serialized message payload in the `sent_messages` table 2. On retry receipt, the client retrieves and consumes the payload (atomic take) 3. Expired entries are periodically cleaned up based on `sent_message_ttl_secs` **Optional L1 cache:** By default, the `recent_messages` cache capacity is 0 (DB-only mode). If you set capacity greater than 0, sent messages are also cached in memory for faster retrieval. In L1 mode, the DB write is backgrounded since the cache serves reads immediately. In DB-only mode, the write is awaited to guarantee persistence. ```rust theme={null} let config = CacheConfig { // Enable L1 in-memory cache for faster retry lookups recent_messages: CacheEntryConfig::new(Some(Duration::from_secs(300)), 1_000), // Keep sent messages in DB for 10 minutes before cleanup sent_message_ttl_secs: 600, ..Default::default() }; ``` *** ## See Also * [Client](/api/client) - Lower-level client API * [Bots](/api/bots) - Server-side directory of first-party AI bots * [Events](/concepts/events) - All event types * [Sending Messages](/guides/sending-messages) - Sending messages * [Storage](/concepts/storage) - Storage and multi-account patterns # Bots Source: https://whatsapp-rust.jlucaso.com/api/bots Fetch WhatsApp's directory of first-party AI bots The `Bots` feature fetches the server's directory of first-party AI bots offered to your account: a default bot plus display sections of `(jid, persona_id)` pairs. Don't confuse this with [`Bot`](/api/bot), the client-side framework for building a program that answers messages. `Bots` reads the server's bot *directory*; `Bot` is a different domain that happens to share the word. ## Access ```rust theme={null} let bots = client.bots(); ``` ## Methods ### list Fetch the bot directory. ```rust theme={null} pub async fn list(&self) -> Result ``` WhatsApp Web issues this once per session at startup. It refreshes the directory on a `bonsai_update_interval` (24h) timer. There's no server push for updates — call this again when you want fresh data. `list()` returns every section, not just ones typed `all`. WhatsApp Web itself reads bots out of every section and only uses `type`/`display_type` for presentation, so a bot whose sole carrier is a `category` or `featured` section stays reachable. The persona ids in the response are the input to WhatsApp's `WAWebFetchBotProfilesGQLQuery` MEX operation (not wrapped by this crate), which hydrates them into displayable profiles — name, description, creator, icebreakers. **Example:** ```rust theme={null} let bot_list = client.bots().list().await?; if let Some(default_jid) = bot_list.default_jid() { println!("Default bot: {}", default_jid); } for section in &bot_list.sections { println!("Section {:?} ({} bots)", section.name, section.bots.len()); for bot in §ion.bots { println!(" {} (persona {})", bot.jid, bot.persona_id); } } ``` **Flattened list:** ```rust theme={null} // All bots in section order, with the default bot prepended if no // section already carries it — mirrors WhatsApp Web's own flattening. for bot in bot_list.flatten() { println!("{} -> {}", bot.jid, bot.persona_id); } ``` ## Types ### BotList The whole bot directory as the server returned it. ```rust theme={null} pub struct BotList { pub version: BotListVersion, pub bhash: Option, pub default_bot: Option, pub sections: Vec, } ``` * `version` — which response shape the server sent * `bhash` — cache handle for the directory (`v="3"` only) * `default_bot` — the bot the client should offer by default; mandatory in `v="2"` responses, optional in `v="3"`, and not necessarily repeated inside a section * `sections` — display groups, kept exactly as the server sent them (not filtered by type) **Methods:** * `flatten() -> Vec` — every bot across all sections, in order, with the default bot prepended if no section already carries it * `default_jid() -> Option<&Jid>` — the default bot's JID, if the server named one ### BotListSection A display group of bots. ```rust theme={null} pub struct BotListSection { pub name: Option, pub section_type: BotSectionType, pub display_type: Option, pub bots: Vec, } ``` `section_type` parses the `type` attribute on `

`. `display_type` is present only in `v="3"` responses. ### BotListEntry One bot in the directory. ```rust theme={null} pub struct BotListEntry { pub jid: Jid, pub persona_id: String, pub card_title: Option, pub count: Option, pub themes: Vec, } ``` * `persona_id` — identifier to hand to the bot-profile MEX operation * `card_title` — `v="3"` only * `count` — a numeric counter on the bot card; either response version may include it, but WhatsApp Web's own client doesn't document what it counts * `themes` — `v="2"` only; empty in `v="3"` responses ### BotDefault The bot the client offers by default. ```rust theme={null} pub struct BotDefault { pub jid: Jid, pub persona_id: String, } ``` A distinct type from `BotListEntry`: `` carries exactly these two attributes on the wire in both response versions, so it has no `card_title`, `count`, or themes to lose. ### BotTheme Per-mode colours for a bot's card. ```rust theme={null} pub struct BotTheme { pub mode: BotThemeMode, pub background: Option, pub primary_text: Option, pub secondary_text: Option, } ``` ### Enums ```rust theme={null} pub enum BotListVersion { V2, V3, Other(String) } pub enum BotSectionType { All, Category, Featured, Other(String) } pub enum BotSectionDisplayType { Hidden, Hscroll, HscrollIcebreakers, HscrollLarge, HscrollSmall, Listview, Other(String) } pub enum BotThemeMode { Dark, Light, Other(String) } ``` Each carries an `Other(String)` fallback variant, so a value the server introduces later round-trips instead of being dropped. `Bots`, `BotList`, `BotListEntry`, `BotListSection`, `BotDefault`, `BotTheme`, and their enums are re-exported from the crate root (`whatsapp_rust::Bots`, `whatsapp_rust::BotList`, ...). ## Wire format ### Request ```xml theme={null} ``` The request always pins `v="2"`, even though the parser accepts either response shape. WhatsApp Web's only call site never sends `bhash` or per-JID filter children, so this crate doesn't either. ### Response (`v="2"`) ```xml theme={null}
#FFFFFF #000000
``` ### Response (`v="3"`) ```xml theme={null}
``` Each version has one field the other does not require, enforced only for its own version: `` in `v="2"`, `bhash` in `v="3"`. ## Error handling `list()` returns `Result` — see [Errors](/api/errors) for `IqError`'s variants. ## See also * [Bot](/api/bot) - Client-side framework for building a bot that answers messages * [MEX (GraphQL)](/api/mex) - Generic GraphQL query/mutate access, which can also carry the bot-profile hydration operation (not separately wrapped by this crate) # Business Source: https://whatsapp-rust.jlucaso.com/api/business Business profile operations, catalog and collection browsing, order lookup, and business profile/cover photo management The business API lets you read a WhatsApp Business account's profile, browse its product catalog and collections, look up an order's line items, and manage the authenticated account's own business profile and cover photo. ## Access Reading a business's profile stays a direct client method. Everything else — catalog, collections, orders, and profile writes — goes through `client.business()`: ```rust theme={null} let profile = client.get_business_profile(&jid).await?; let business = client.business(); ``` Catalog, collection, and order lookups go over MEX (persisted GraphQL) — WhatsApp Web has no IQ fallback for these. Business profile writes and the cover photo go over IQ (`w:biz`). See the [MEX API](/api/mex) for the underlying GraphQL transport. ## Methods ### get\_business\_profile Fetch the business profile for a WhatsApp Business account. ```rust theme={null} pub async fn get_business_profile( &self, jid: &Jid, ) -> Result, IqError> ``` **Parameters:** * `jid` - JID of the account to query **Returns:** * `Some(BusinessProfile)` if the account is a business with a profile * `None` if the account is not a business or has no profile **Example:** ```rust theme={null} let jid: Jid = "15551234567@s.whatsapp.net".parse()?; if let Some(profile) = client.get_business_profile(&jid).await? { println!("Description: {}", profile.description); if let Some(email) = &profile.email { println!("Email: {}", email); } for site in &profile.website { println!("Website: {}", site); } if let Some(addr) = &profile.address { println!("Address: {}", addr); } for category in &profile.categories { println!("Category: {} (ID: {})", category.name, category.id); } if let Some(tz) = &profile.business_hours.timezone { println!("Timezone: {}", tz); } if let Some(configs) = &profile.business_hours.business_config { for config in configs { println!( " {:?}: {:?} ({:?}–{:?})", config.day_of_week, config.mode, config.open_time, config.close_time, ); } } } else { println!("Not a business account"); } ``` ### get\_catalog Fetch one page of a business's product catalog, over MEX. ```rust theme={null} pub async fn get_catalog( &self, jid: &Jid, options: &CatalogOptions, ) -> Result ``` **Parameters:** * `jid` - JID of the business account * `options` - paging and thumbnail size; see [`CatalogOptions`](#catalogoptions) **Example:** ```rust theme={null} use whatsapp_rust::CatalogOptions; let mut options = CatalogOptions::default(); loop { let catalog = client.business().get_catalog(&jid, &options).await?; for product in &catalog.products { println!("{}: {:?}", product.id, product.name); if let Some(price) = &product.price { println!(" {} {:?}", price.amount_1000 as f64 / 1000.0, price.currency); } } match catalog.after_cursor { Some(cursor) => options.after = Some(cursor), None => break, } } ``` ### get\_collections Fetch one page of a business's product collections, each with its products inline, over MEX. ```rust theme={null} pub async fn get_collections( &self, jid: &Jid, options: &CollectionOptions, ) -> Result ``` **Parameters:** * `jid` - JID of the business account * `options` - paging and thumbnail size; see [`CollectionOptions`](#collectionoptions) **Example:** ```rust theme={null} use whatsapp_rust::CollectionOptions; let mut options = CollectionOptions::default(); loop { let collections = client.business().get_collections(&jid, &options).await?; for collection in &collections.collections { println!("{:?}: {} products", collection.name, collection.products.len()); if collection.products.len() as u32 == options.item_limit { println!(" (may be truncated — this collection may have more products)"); } } match collections.after_cursor { Some(cursor) => options.after = Some(cursor), None => break, } } ``` `Collection::products` is a possibly-truncated prefix — the collections query returns no per-collection cursor, so `products.len() == item_limit` is the only signal that more products *may* exist in that collection. Equality doesn't prove it: a collection with exactly `item_limit` products and no more looks identical. ### get\_order Look up an order's line items and totals, over MEX. ```rust theme={null} pub async fn get_order( &self, jid: &Jid, order_id: &str, token: &str, ) -> Result ``` **Parameters:** * `jid` - the business the order was placed with * `order_id` / `token` - taken from the order message itself (`OrderMessage.order_id` / `OrderMessage.token` on the Rust struct — `orderId`/`token` on the wire); the token is a per-order capability, so an order cannot be read without the message that announced it **Example:** ```rust theme={null} let order = client.business().get_order(&jid, &order_id, &token).await?; for product in &order.products { println!("{:?} x{:?}", product.name, product.quantity); for variant in &product.variant_properties { println!(" {:?}: {:?}", variant.name, variant.value); } } if let Some(details) = &order.price_details { println!("Total: {:?} {:?}", details.total, details.currency); } ``` ### update\_profile Apply a delta to the authenticated account's own business profile, over IQ (`w:biz`). ```rust theme={null} pub async fn update_profile( &self, update: &BusinessProfileUpdate, ) -> Result<(), BusinessError> ``` **Parameters:** * `update` - fields left `None` are untouched; see [`BusinessProfileUpdate`](#businessprofileupdate) for how to clear one instead The update is validated client-side before anything is sent — an invalid delta returns `BusinessError::InvalidUpdate` without touching the wire. See [Validation](#validation) below. **Example:** ```rust theme={null} use whatsapp_rust::{BusinessHourMode, BusinessHoursConfig, BusinessHoursUpdate, BusinessProfileUpdate, DayOfWeek}; let update = BusinessProfileUpdate { description: Some("Fresh bread, baked daily.".into()), email: Some("orders@example.com".into()), websites: Some(vec!["https://example.com".into()]), business_hours: Some(BusinessHoursUpdate { timezone: Some("America/Sao_Paulo".into()), config: vec![ BusinessHoursConfig::with_hours(DayOfWeek::Monday, BusinessHourMode::SpecificHours, 480, 1080), BusinessHoursConfig::new(DayOfWeek::Sunday, BusinessHourMode::AppointmentOnly), ], ..Default::default() }), ..Default::default() }; client.business().update_profile(&update).await?; ``` ### set\_cover\_photo Point the business profile at an already-uploaded cover photo, over IQ (`w:biz`). ```rust theme={null} pub async fn set_cover_photo(&self, upload: CoverPhotoUpload) -> Result<(), BusinessError> ``` **Parameters:** * `upload` - the `fbid`/`meta_hmac`/`ts` receipt from a `biz-cover-photo` media upload; see [`CoverPhotoUpload`](#coverphotoupload) for why this crate does not perform that upload itself **Example:** ```rust theme={null} use whatsapp_rust::CoverPhotoUpload; let upload = CoverPhotoUpload { id: "1234567890".into(), // fbid from the upload response token: "abcdef...".into(), // meta_hmac from the upload response timestamp: 1_700_000_000, // ts from the upload response }; client.business().set_cover_photo(upload).await?; ``` ### remove\_cover\_photo Remove the business profile's cover photo, over IQ (`w:biz`). ```rust theme={null} pub async fn remove_cover_photo(&self, id: &str) -> Result<(), BusinessError> ``` **Parameters:** * `id` - the `fbid` the cover photo was set with **Example:** ```rust theme={null} client.business().remove_cover_photo("1234567890").await?; ``` ## Types ### BusinessProfile ```rust theme={null} #[non_exhaustive] pub struct BusinessProfile { pub wid: Option, pub description: String, pub email: Option, pub website: Vec, pub categories: Vec, pub address: Option, pub business_hours: BusinessHours, } ``` `BusinessProfile` is `#[non_exhaustive]`: struct-literal construction and exhaustive struct destructuring from outside the crate are both disallowed. Field reads are unaffected; add `..` to any exhaustive destructuring patterns. ### BusinessCategory ```rust theme={null} #[non_exhaustive] pub struct BusinessCategory { pub id: String, pub name: String, } ``` `BusinessCategory` is `#[non_exhaustive]`: struct-literal construction and exhaustive struct destructuring from outside the crate are both disallowed. Field reads are unaffected; add `..` to any exhaustive destructuring patterns. ### BusinessHours ```rust theme={null} #[non_exhaustive] pub struct BusinessHours { pub timezone: Option, pub business_config: Option>, } ``` `BusinessHours` is `#[non_exhaustive]`: struct-literal construction and exhaustive struct destructuring from outside the crate are both disallowed. Field reads are unaffected; add `..` to any exhaustive destructuring patterns. ### BusinessHoursConfig The read side reports opening hours with this type; the write side ([`BusinessHoursUpdate::config`](#businesshoursupdate)) reuses it too. ```rust theme={null} #[non_exhaustive] pub struct BusinessHoursConfig { pub day_of_week: DayOfWeek, pub mode: BusinessHourMode, pub open_time: Option, pub close_time: Option, } ``` `BusinessHoursConfig` is `#[non_exhaustive]`: struct-literal construction and exhaustive struct destructuring from outside the crate are both disallowed. Field reads are unaffected; add `..` to any exhaustive destructuring patterns. **Constructors:** * `BusinessHoursConfig::new(day_of_week, mode)` — a day whose mode carries no explicit range (`Open24H` or `AppointmentOnly`) * `BusinessHoursConfig::with_hours(day_of_week, mode, open_time, close_time)` — a single opening range, in minutes past local midnight. A day with two ranges is two entries sharing a `day_of_week`. There's no "closed" mode or constructor. `config` holds one entry per day that *has* hours to report; a day closed every week is represented by leaving it out of `config` entirely, not by constructing an entry for it. ### DayOfWeek ```rust theme={null} pub enum DayOfWeek { Sunday, // "sun" Monday, // "mon" Tuesday, // "tue" Wednesday, // "wed" Thursday, // "thu" Friday, // "fri" Saturday, // "sat" Other(String), } ``` ### BusinessHourMode ```rust theme={null} pub enum BusinessHourMode { Open24H, // "open_24h" SpecificHours, // "specific_hours" AppointmentOnly, // "appointment_only" Other(String), } ``` ### BusinessHoursUpdate Opening hours carried by a profile mutation. Distinct from the read-side `BusinessHours` because the mutation also carries a free-text note, which the profile query does not return. ```rust theme={null} pub struct BusinessHoursUpdate { /// IANA zone name (e.g. `America/Araguaina`). Omitted when `None`. pub timezone: Option, /// Free-text note shown under the hours. `Some("")` is not special-cased: /// WhatsApp Web drops empty notes, and so does the builder. pub note: Option, /// One entry per opening range; see `BusinessHoursConfig::with_hours`. pub config: Vec, } ``` ### BusinessProfileUpdate A delta mutation of the business profile, passed to [`update_profile`](#update_profile). This is a delta, not a replacement: every field defaults to `None`, and a `None` field is left untouched on the server. To clear a field instead of leaving it alone, set it to `Some` of an empty value — `Some(String::new())` for text fields, `Some(Vec::new())` for `websites`. `None` and an empty `Some` are not interchangeable: only the latter clears anything. ```rust theme={null} pub struct BusinessProfileUpdate { pub address: Option, pub latitude: Option, pub longitude: Option, pub description: Option, pub email: Option, /// Up to `BUSINESS_PROFILE_MAX_WEBSITES` URLs. `Some(vec![])` clears the /// list, matching WhatsApp Web's lone empty `` node. pub websites: Option>, /// Category *ids* (from the business categories directory), not names. pub categories: Option>, pub business_hours: Option, } ``` ### BUSINESS\_PROFILE\_MAX\_WEBSITES ```rust theme={null} pub const BUSINESS_PROFILE_MAX_WEBSITES: usize = 2; ``` WhatsApp Web emits at most two `` nodes per profile mutation. A `websites` list longer than this is rejected client-side (`BusinessProfileUpdateError::TooManyWebsites`) rather than silently truncated, since the real client would drop the extra entries without telling you. ### CoverPhotoUpload The upload receipt a cover photo mutation has to quote back. ```rust theme={null} pub struct CoverPhotoUpload { /// `fbid` from the upload response. pub id: String, /// `meta_hmac` from the upload response. pub token: String, /// `ts` from the upload response. pub timestamp: i64, } ``` These three values come from a `biz-cover-photo` media upload, whose response carries `fbid`/`meta_hmac`/`ts` instead of the `url`/`direct_path` pair every other media type returns. **This crate does not yet perform that upload** — the upload pipeline (`src/upload.rs`) parses `url`/`direct_path` out of every response, which the cover-photo endpoint doesn't return. Obtain the receipt yourself and pass it in. ### CatalogOptions Paging and thumbnail options for [`get_catalog`](#get_catalog). ```rust theme={null} pub struct CatalogOptions { /// Products per page. pub limit: u32, /// Cursor from a previous page's `Catalog::after_cursor`. pub after: Option, pub image_width: u32, pub image_height: u32, /// Lets the server include products surfaced through Shop as well as the /// business's own catalog. pub allow_shop_source: bool, } ``` `Default`: `limit: 10`, `after: None`, `image_width: 100`, `image_height: 100`, `allow_shop_source: true`. These defaults are this client's choice — WhatsApp Web computes them per-surface at the call site, so there's no bundle constant to match. Override them per request. ### CollectionOptions Options for [`get_collections`](#get_collections). ```rust theme={null} pub struct CollectionOptions { /// Collections per page. pub collection_limit: u32, /// Products returned inline per collection. pub item_limit: u32, pub after: Option, pub image_width: u32, pub image_height: u32, } ``` `Default`: `collection_limit: 51`, `item_limit: 51`, `after: None`, `image_width: 100`, `image_height: 100`. ### Catalog One page of a business catalog, returned by `get_catalog`. ```rust theme={null} #[non_exhaustive] pub struct Catalog { pub products: Vec, /// Cursor for the next page; `None` when this is the last page. Pass it /// back as `CatalogOptions::after`. pub after_cursor: Option, /// Cursor for the previous page. pub before_cursor: Option, } ``` ### Collections One page of a business's collections, returned by `get_collections`. ```rust theme={null} #[non_exhaustive] pub struct Collections { pub collections: Vec, /// Cursor for the next page; `None` on the last page. Pass it back as /// `CollectionOptions::after`. /// /// Unlike the catalog, this response carries a forward cursor only — there /// is no `before` in the collections paging object. pub after_cursor: Option, } ``` ### Collection A named group of products within a catalog. ```rust theme={null} #[non_exhaustive] pub struct Collection { pub id: Option, pub name: Option, /// The first `CollectionOptions::item_limit` products, inline. pub products: Vec, pub review_status: Option, /// Whether a rejected collection can still be appealed. pub can_appeal: Option, /// Why the collection was rejected. Present only on a rejection. pub reject_reason: Option, /// Where to review or appeal the decision. pub commerce_url: Option, } ``` `Collection::products` can be truncated, and says so only by its length: the collections query returns a prefix with no per-collection cursor, so `products.len() == item_limit` is the only signal more products *may* exist — it isn't conclusive, since a collection with exactly that many products and no more reads the same way. Reading a collection to the end would need a different, not-yet-wired-up operation. ### Product A catalog product. Only `id` is guaranteed — every other field is optional because the server omits rather than blanks a missing value (an absent `name` is `None`, never `""`; an absent `is_hidden` is `None`, never `false`). ```rust theme={null} #[non_exhaustive] pub struct Product { pub id: String, /// The merchant's own SKU, distinct from `id`. pub retailer_id: Option, pub name: Option, pub description: Option, pub url: Option, /// The link-shimmed form of `url`, sent alongside it rather than instead /// of it. Which one to open is a policy call left to the caller. pub shimmed_url: Option, pub price: Option, pub sale_price: Option, /// Hidden products stay in the catalog but are not shown to customers. pub is_hidden: Option, pub is_sanctioned: Option, /// Upper bound on the quantity a single order may contain. pub max_available: Option, pub availability: Option, /// WhatsApp's review verdict, e.g. `APPROVED`. pub review_status: Option, pub can_appeal: Option, /// Whether the product belongs to the queried business's own catalog. pub belongs_to: Option, pub images: Vec, pub videos: Vec, pub compliance_category: Option, pub country_code_origin: Option, /// Importer of record, alongside `importer_address`. pub importer_name: Option, pub importer_address: Option, } ``` Catalog and collection products never carry variant data (`variant_info` is opt-in on the wire and this crate doesn't request it), so there's no `variant_properties` field here. Order line items are the one place variant data is surfaced — see [`OrderProduct`](#orderproduct). ### Price A price, in **thousandths** of the currency's main unit — not hundredths. ```rust theme={null} pub struct Price { /// Thousandths of one currency unit: `1_990` is 1.99 in `currency`. pub amount_1000: i64, /// ISO 4217 code. Absent when the server sends a price without one. pub currency: Option, } ``` WhatsApp Web's protobuf field is `priceAmount1000: int64`, formatted through `formatAmount1000`. Keeping the raw integer avoids rounding — dividing by 1000 into a float is a display-time decision for the caller to make. ### SalePrice ```rust theme={null} #[non_exhaustive] pub struct SalePrice { pub price: Price, /// Both ends or neither: a lone endpoint is normalized away rather than /// surfacing a period WhatsApp Web itself does not honour. pub start_date: Option, pub end_date: Option, } ``` ### ProductImage ```rust theme={null} #[non_exhaustive] pub struct ProductImage { pub id: Option, /// URL at the dimensions the request asked for. pub request_image_url: Option, /// URL at the image's stored dimensions. pub original_image_url: Option, } ``` ### ProductVideo ```rust theme={null} #[non_exhaustive] pub struct ProductVideo { pub id: Option, pub original_video_url: Option, pub thumbnail_url: Option, } ``` ### ImporterAddress A postal address, sent for a product's importer of record. Every part is optional — the server omits what it does not hold rather than sending an empty string. ```rust theme={null} #[non_exhaustive] pub struct ImporterAddress { pub street1: Option, pub street2: Option, pub city: Option, pub region: Option, pub postal_code: Option, pub country_code: Option, } ``` ### ProductAvailability Whether a product can currently be bought. The wire values are the GraphQL enum names. ```rust theme={null} #[non_exhaustive] pub enum ProductAvailability { InStock, // "IN_STOCK" OutOfStock, // "OUT_OF_STOCK" AvailableForAnotherPostcode, // "AVAILABLE_FOR_ANOTHER_POSTCODE" Other(String), } ``` ### Order Returned by `get_order`. ```rust theme={null} #[non_exhaustive] pub struct Order { pub products: Vec, pub price_details: Option, /// Unix seconds, as sent. Absent when the server omits it. pub creation_timestamp: Option, } ``` ### OrderProduct A line item on an order — a snapshot rather than a live product: the price is what was quoted when the order was placed. ```rust theme={null} #[non_exhaustive] pub struct OrderProduct { pub id: Option, pub name: Option, pub price: Option, pub quantity: Option, pub images: Vec, /// The variant the customer actually chose — size, colour, and the like. /// Empty for a product with no variants. pub variant_properties: Vec, } ``` ### VariantProperty One dimension of a chosen product variant, e.g. `name: "Size"`, `value: "Large"`. ```rust theme={null} #[non_exhaustive] pub struct VariantProperty { pub name: Option, pub value: Option, } ``` ### OrderPriceDetails ```rust theme={null} #[non_exhaustive] pub struct OrderPriceDetails { pub currency: Option, pub subtotal: Option, pub total: Option, } ``` ## Validation `update_profile` validates a `BusinessProfileUpdate` before sending anything, because the server rejects a `business_profile` delta on any single bad field — losing the other, valid fields in the same update. All of the following surface as `BusinessError::InvalidUpdate(BusinessProfileUpdateError::...)`: * **Empty delta** (`Empty`) — every field is `None`, so the update would be a no-op. * **Too many websites** (`TooManyWebsites { count }`) — more than [`BUSINESS_PROFILE_MAX_WEBSITES`](#business_profile_max_websites) (2) entries in `websites`. * **Invalid coordinate** (`InvalidCoordinate { axis, value, limit }`) — `latitude`/`longitude` outside ±90°/±180°, or non-finite (`NaN`/`inf`). * **Invalid business-hour time** (`InvalidBusinessHourTime { day, field, value }`) — `open_time`/`close_time` must be less than 1440 (minutes in a day). Ranges that cross midnight (`open_time > close_time`) are legal — WhatsApp Web's picker UI discourages them, but the wire format doesn't forbid them. * **Incomplete business-hour range** (`IncompleteBusinessHourRange { day }`) — `open_time` and `close_time` must both be set or both be absent. * **Mismatched business-hour mode** (`MismatchedBusinessHourMode { day, mode, expectation }`) — `SpecificHours` requires a range; `Open24H` and `AppointmentOnly` must not carry one. An unrecognized `Other(...)` mode is never checked this way. ## Error handling ```rust theme={null} #[non_exhaustive] pub enum BusinessError { Mex(MexError), Request(IqError), InvalidUpdate(BusinessProfileUpdateError), /// The response was structurally valid JSON but missing a field the /// operation is defined by, or carrying it with the wrong shape. MalformedResponse { operation: &'static str, detail: String, }, } ``` `get_catalog`, `get_collections`, and `get_order` return `BusinessError::Mex` on a failed MEX call and `BusinessError::MalformedResponse` on a structurally-unexpected (but successfully-fetched) response. `update_profile`, `set_cover_photo`, and `remove_cover_photo` return `BusinessError::Request` on a failed IQ, and `update_profile` additionally returns `BusinessError::InvalidUpdate` when client-side validation rejects the delta (see [Validation](#validation)). ## Not yet supported * **Product create/edit/delete.** Only reads exist (`get_catalog`, `get_collections`, `get_order`) — there is no product-mutation surface in this crate. WhatsApp Web itself has no such operation to mirror; only third-party clients expose one against the legacy IQ transport. * **Uploading a new cover photo.** `set_cover_photo` requires a pre-uploaded [`CoverPhotoUpload`](#coverphotoupload) receipt because the `biz-cover-photo` media type isn't wired into this crate's upload pipeline yet. * **Reading a full (untruncated) collection.** `get_collections` returns a possibly-truncated product prefix per collection with no way to page further within one collection. * **Catalog/collection variant data.** Only orders carry `variant_properties`; requesting variant info for catalog/collection products isn't implemented. ## Business events Business account changes are reported through the event system. Subscribe to `BusinessStatusUpdate` events to track changes: ```rust theme={null} use wacore::types::events::{Event, BusinessStatusUpdate, BusinessUpdateType}; match event { Event::BusinessStatusUpdate(update) => { match update.update_type { BusinessUpdateType::VerifiedNameChanged => { println!("Verified name: {:?}", update.verified_name); } BusinessUpdateType::ProfileUpdated => { println!("Business profile updated for {}", update.jid); } BusinessUpdateType::RemovedAsBusiness => { println!("{} is no longer a business account", update.jid); } BusinessUpdateType::ProductsUpdated => { println!("Products updated: {:?}", update.product_ids); } BusinessUpdateType::CollectionsUpdated => { println!("Collections updated: {:?}", update.collection_ids); } BusinessUpdateType::SubscriptionsUpdated => { println!("Subscriptions: {:?}", update.subscriptions); } _ => {} } } _ => {} } ``` See [Events](/concepts/events#businessstatusupdate) for the full `BusinessStatusUpdate` type. ## Checking if a contact is a business You can check if a contact is a business account using the contacts API: ```rust theme={null} let results = client.contacts().is_on_whatsapp(&[Jid::pn("15551234567")]).await?; for result in &results { if result.is_business { println!("{} is a business account", result.jid); } } ``` See [Contacts API](/api/contacts) for details. ## Automatic business stanza detection When sending interactive business messages (native-flow buttons for payments, CTAs, catalogs, etc.), the library automatically injects a `` stanza child node on the outgoing message. This means you can send `InteractiveMessage` with `NativeFlowMessage` content through `send_message` or `send_message_with_options` without manually constructing business protocol nodes. See [Send API - Automatic business node detection](/api/send#automatic-business-node-detection) for the full list of supported button-to-flow mappings. ## See also * [Client API](/api/client#get_business_profile) - Client-level business methods * [MEX API](/api/mex) - GraphQL transport used by catalog, collections, and order lookup * [Contacts API](/api/contacts) - Check `is_business` flag on contacts * [Events](/concepts/events#businessstatusupdate) - Business status update events * [Send API](/api/send#automatic-business-node-detection) - Auto-detected `` stanza nodes # Chat actions Source: https://whatsapp-rust.jlucaso.com/api/chat-actions Archive, pin, mute, delete chats, star messages, and mark chats as read The `ChatActions` feature provides methods for managing chat organization through archiving, pinning, muting, starring messages, marking chats as read, deleting chats, and deleting individual messages. These operations sync across all your devices via WhatsApp's app state sync mechanism. ## Access Access chat action operations through the client: ```rust theme={null} let chat_actions = client.chat_actions(); ``` ## Archive ### archive\_chat Archive a chat to hide it from the main chat list. ```rust theme={null} pub async fn archive_chat( &self, jid: &Jid, message_range: Option, ) -> Result<(), AppStateError> ``` **Parameters:** * `jid` - The chat JID to archive * `message_range` - Optional message range for multi-device conflict resolution. Pass `None` in most cases **Example:** ```rust theme={null} let jid: Jid = "15551234567@s.whatsapp.net".parse()?; client.chat_actions().archive_chat(&jid, None).await?; ``` ### unarchive\_chat Unarchive a chat to show it in the main chat list. ```rust theme={null} pub async fn unarchive_chat( &self, jid: &Jid, message_range: Option, ) -> Result<(), AppStateError> ``` **Parameters:** * `jid` - The chat JID to unarchive * `message_range` - Optional message range for multi-device conflict resolution. Pass `None` in most cases **Example:** ```rust theme={null} client.chat_actions().unarchive_chat(&jid, None).await?; ``` ## Pin ### pin\_chat Pin a chat to keep it at the top of the chat list. ```rust theme={null} pub async fn pin_chat(&self, jid: &Jid) -> Result<(), AppStateError> ``` **Parameters:** * `jid` - The chat JID to pin **Example:** ```rust theme={null} let jid: Jid = "15551234567@s.whatsapp.net".parse()?; client.chat_actions().pin_chat(&jid).await?; ``` WhatsApp limits the number of pinned chats. Attempting to pin too many chats may fail. ### unpin\_chat Unpin a chat. ```rust theme={null} pub async fn unpin_chat(&self, jid: &Jid) -> Result<(), AppStateError> ``` **Parameters:** * `jid` - The chat JID to unpin **Example:** ```rust theme={null} client.chat_actions().unpin_chat(&jid).await?; ``` ## Mute ### mute\_chat Mute a chat indefinitely. ```rust theme={null} pub async fn mute_chat(&self, jid: &Jid) -> Result<(), AppStateError> ``` **Parameters:** * `jid` - The chat JID to mute **Example:** ```rust theme={null} let jid: Jid = "15551234567@s.whatsapp.net".parse()?; client.chat_actions().mute_chat(&jid).await?; ``` ### mute\_chat\_until Mute a chat until a specific time. ```rust theme={null} pub async fn mute_chat_until( &self, jid: &Jid, mute_end_timestamp_ms: i64 ) -> Result<(), AppStateError> ``` **Parameters:** * `jid` - The chat JID to mute * `mute_end_timestamp_ms` - Unix timestamp in milliseconds when mute expires (must be in the future) **Example:** ```rust theme={null} use chrono::{Utc, Duration}; let jid: Jid = "15551234567@s.whatsapp.net".parse()?; // Mute for 8 hours let mute_until = Utc::now() + Duration::hours(8); client.chat_actions() .mute_chat_until(&jid, mute_until.timestamp_millis()) .await?; // Mute for 1 week let mute_until = Utc::now() + Duration::weeks(1); client.chat_actions() .mute_chat_until(&jid, mute_until.timestamp_millis()) .await?; ``` ### unmute\_chat Unmute a chat. ```rust theme={null} pub async fn unmute_chat(&self, jid: &Jid) -> Result<(), AppStateError> ``` **Parameters:** * `jid` - The chat JID to unmute **Example:** ```rust theme={null} client.chat_actions().unmute_chat(&jid).await?; ``` ## Star messages ### star\_message Star a message to mark it as important. ```rust theme={null} pub async fn star_message( &self, chat_jid: &Jid, participant_jid: Option<&Jid>, message_id: &str, from_me: bool ) -> Result<(), AppStateError> ``` **Parameters:** * `chat_jid` - The chat containing the message * `participant_jid` - For group messages from others, pass `Some(&sender_jid)`. For 1-on-1 chats or your own messages, pass `None` * `message_id` - The message ID to star * `from_me` - Whether the message was sent by you **Example:** ```rust theme={null} // Star your own message in a 1-on-1 chat client.chat_actions() .star_message(&chat_jid, None, "MESSAGE_ID", true) .await?; // Star someone else's message in a 1-on-1 chat client.chat_actions() .star_message(&chat_jid, None, "MESSAGE_ID", false) .await?; // Star someone else's message in a group let sender_jid: Jid = "15559876543@s.whatsapp.net".parse()?; client.chat_actions() .star_message(&group_jid, Some(&sender_jid), "MESSAGE_ID", false) .await?; ``` For group messages not sent by you, `participant_jid` is required. The method will return an error if it's not provided. ### unstar\_message Remove the star from a message. ```rust theme={null} pub async fn unstar_message( &self, chat_jid: &Jid, participant_jid: Option<&Jid>, message_id: &str, from_me: bool ) -> Result<(), AppStateError> ``` **Parameters:** Same as `star_message`. **Example:** ```rust theme={null} client.chat_actions() .unstar_message(&chat_jid, None, "MESSAGE_ID", true) .await?; ``` ## Mark chat as read ### mark\_chat\_as\_read Mark a chat as read or unread. This is distinct from `mark_as_read` (IQ receipts) — it syncs the read/unread state across all linked devices. ```rust theme={null} pub async fn mark_chat_as_read( &self, jid: &Jid, read: bool, message_range: Option, ) -> Result<(), AppStateError> ``` **Parameters:** * `jid` - The chat JID to mark * `read` - `true` to mark as read, `false` to mark as unread * `message_range` - Optional message range for multi-device conflict resolution. Pass `None` in most cases **Example:** ```rust theme={null} let jid: Jid = "15551234567@s.whatsapp.net".parse()?; // Mark chat as read client.chat_actions().mark_chat_as_read(&jid, true, None).await?; // Mark chat as unread client.chat_actions().mark_chat_as_read(&jid, false, None).await?; ``` This syncs the read/unread badge across linked devices via app state sync (`regular_low` collection). To send read receipts to the sender, use `client.mark_as_read()` instead. ## Delete chat ### delete\_chat Delete a chat from the chat list across all linked devices. ```rust theme={null} pub async fn delete_chat( &self, jid: &Jid, delete_media: bool, message_range: Option, ) -> Result<(), AppStateError> ``` **Parameters:** * `jid` - The chat JID to delete * `delete_media` - Whether to also delete downloaded media files * `message_range` - Optional message range for multi-device conflict resolution. Pass `None` in most cases **Example:** ```rust theme={null} let jid: Jid = "15551234567@s.whatsapp.net".parse()?; // Delete chat and its media client.chat_actions().delete_chat(&jid, true, None).await?; // Delete chat but keep media files client.chat_actions().delete_chat(&jid, false, None).await?; ``` This operation is not reversible. The chat and optionally its media will be removed from all linked devices. ## Clear chat ### clear\_chat Clear a chat's messages while **keeping the chat** itself (WhatsApp Web's "Clear chat"). Unlike [`delete_chat`](#delete_chat), the chat stays in the list — only its messages are removed. Syncs across all linked devices. ```rust theme={null} pub async fn clear_chat( &self, jid: &Jid, delete_starred: bool, delete_media: bool, message_range: Option, ) -> Result<(), AppStateError> ``` **Parameters:** * `jid` - The chat JID to clear * `delete_starred` - Also remove starred messages * `delete_media` - Also remove downloaded media files * `message_range` - Optional message range for multi-device conflict resolution. Pass `None` in most cases Both flags are encoded in the mutation index (not the proto body), matching WhatsApp Web's `clearChat` action. **Example:** ```rust theme={null} let jid: Jid = "15551234567@s.whatsapp.net".parse()?; // Clear all messages, including starred ones and downloaded media client.chat_actions().clear_chat(&jid, true, true, None).await?; // Clear messages but keep starred messages and media client.chat_actions().clear_chat(&jid, false, false, None).await?; ``` A clear performed on another linked device arrives as an [`Event::ClearChatUpdate`](/concepts/events#clearchatupdate). ## Save and remove contacts ### save\_contact Save or rename a contact, syncing the name to your other linked devices (WhatsApp Web's contact-sync action). ```rust theme={null} pub async fn save_contact( &self, jid: &Jid, full_name: Option, first_name: Option, save_on_primary_addressbook: bool, ) -> Result<(), AppStateError> ``` **Parameters:** * `jid` - The contact's JID. **Must be a bare phone-number JID** — LIDs and device-specific JIDs are rejected (LID contacts use a separate path on WhatsApp Web). * `full_name` - Full display name, or `None` * `first_name` - Short name, or `None` (omitted when absent; WhatsApp Web derives no default) * `save_on_primary_addressbook` - Whether to save the name to the phone's address book **Example:** ```rust theme={null} let jid: Jid = "15551234567@s.whatsapp.net".parse()?; client.chat_actions() .save_contact(&jid, Some("Jane Doe".into()), Some("Jane".into()), true) .await?; ``` ### remove\_contact Delete a saved contact, syncing the removal to your other linked devices. ```rust theme={null} pub async fn remove_contact(&self, jid: &Jid) -> Result<(), AppStateError> ``` **Parameters:** * `jid` - The contact's JID. **Must be a bare phone-number JID**, the same rule as `save_contact`. **Example:** ```rust theme={null} client.chat_actions().remove_contact(&jid).await?; ``` Unlike `save_contact`, which writes a `Set`, `remove_contact` sends a syncd **`Remove`** for the same `["contact", jid]` index. WhatsApp Web picks the operation from an `isDelete` flag when building this mutation. Its receiving side then branches on the operation: a `Set` with an empty `ContactAction` gets applied as a rename to the empty string, not a deletion, so only a genuine `Remove` deletes the contact. WhatsApp Web still builds the action value before choosing the operation, so `remove_contact` sends an all-default `ContactAction` alongside the `Remove` operation. ## Status mute ### set\_user\_status\_mute Mute or unmute a contact, group, or channel's **status updates** across linked devices (WhatsApp Web's `userStatusMute`). This is distinct from [`mute_chat`](#mute_chat), which silences a chat's message notifications. ```rust theme={null} pub async fn set_user_status_mute( &self, jid: &Jid, muted: bool, ) -> Result<(), AppStateError> ``` **Parameters:** * `jid` - The entity whose status updates to mute/unmute * `muted` - `true` hides their status updates, `false` unmutes **Example:** ```rust theme={null} let jid: Jid = "15551234567@s.whatsapp.net".parse()?; client.chat_actions().set_user_status_mute(&jid, true).await?; // mute status client.chat_actions().set_user_status_mute(&jid, false).await?; // unmute ``` A status-mute change on another linked device arrives as an [`Event::UserStatusMuteUpdate`](/concepts/events#userstatusmuteupdate). ## Delete message for me ### delete\_message\_for\_me Delete a specific message locally (not for the other party). This is different from `revoke_message` which deletes for everyone. ```rust theme={null} pub async fn delete_message_for_me( &self, chat_jid: &Jid, participant_jid: Option<&Jid>, message_id: &str, from_me: bool, delete_media: bool, message_timestamp: Option, ) -> Result<(), AppStateError> ``` **Parameters:** * `chat_jid` - The chat containing the message * `participant_jid` - For group messages from others, pass `Some(&sender_jid)`. For 1-on-1 chats or your own messages, pass `None` * `message_id` - The ID of the message to delete * `from_me` - Whether the message was sent by you * `delete_media` - Whether to also delete the associated media file * `message_timestamp` - Optional timestamp of the message **Example:** ```rust theme={null} // Delete your own message in a 1-on-1 chat client.chat_actions() .delete_message_for_me(&chat_jid, None, "MESSAGE_ID", true, true, None) .await?; // Delete someone else's message in a group let sender_jid: Jid = "15559876543@s.whatsapp.net".parse()?; client.chat_actions() .delete_message_for_me(&group_jid, Some(&sender_jid), "MESSAGE_ID", false, true, None) .await?; ``` For group messages not sent by you, `participant_jid` is required. The method will return an error if it's not provided. This only removes the message from your own devices. The other party can still see the message. To delete for everyone, use `client.revoke_message()` instead. ## Helper functions ### message\_range Construct a `SyncActionMessageRange` for multi-device conflict resolution. In most cases you can pass `None` instead — only WhatsApp Web with a full message database populates this. ```rust theme={null} pub fn message_range( last_message_timestamp: i64, last_system_message_timestamp: Option, messages: Vec<(wa::MessageKey, i64)>, ) -> SyncActionMessageRange ``` ### message\_key Construct a `MessageKey` for use with `message_range`. ```rust theme={null} pub fn message_key( id: impl Into, remote_jid: &Jid, from_me: bool, participant: Option<&Jid>, ) -> wa::MessageKey ``` ## Generic app state action ### send\_app\_state\_action Send any syncd (app state) `Set` action, driven by a generated schema from the `whatsapp_rust::schemas` registry. Use this as the escape hatch when there isn't a dedicated helper yet (for example, `clear_chat`, `favorites`). Most typed methods on `ChatActions`, `Labels`, `QuickReplies`, and `AppStateSettings` are thin wrappers over this same call. The one exception is [`ChatActions::remove_contact`](#save-and-remove-contacts), which wraps [`remove_app_state_action`](#remove_app_state_action) below instead, since contact removal needs a `Remove` rather than a `Set`. ```rust theme={null} pub async fn send_app_state_action( &self, schema: &Schema, index_args: &[&str], value: &wa::SyncActionValue, ) -> Result<(), AppStateError> ``` **Parameters:** * `schema` — A `&Schema` constant from `whatsapp_rust::schemas` (re-exported from `wacore::appstate::schemas`). The schema decides the collection, action version, and index shape. * `index_args` — The non-literal index parts in the order declared by `schema.index_parts`. Literal slots (the action name prefix) are filled automatically. * `value` — A `wa::SyncActionValue` with the matching action sub-field set and a `timestamp` in epoch milliseconds. **When to use it:** * The action you need does not have a typed helper on `ChatActions`, `Labels`, `QuickReplies`, or `AppStateSettings`. * You need to interoperate with a schema added to the registry without waiting for a new helper to land. Prefer the typed wrappers (`pin_chat`, `mute_chat`, `archive_chat`, label methods, etc.) whenever they exist — they handle the timestamp, conflict-resolution fields, and index args for you. **Example:** ```rust theme={null} use whatsapp_rust::schemas; use whatsapp_rust::waproto::whatsapp as wa; let value = wa::SyncActionValue { clear_chat_action: Some(Default::default()), timestamp: Some(1_700_000_000_000), ..Default::default() }; // CLEAR_CHAT's non-literal index parts are [chatJid, deleteStarred, deleteMedia]. client .send_app_state_action( &schemas::CLEAR_CHAT, &["15551234567@s.whatsapp.net", "0", "0"], &value, ) .await?; ``` Index arguments are positional and must match `schema.index_parts` length and order, excluding `IndexPart::Literal` slots. Mismatched arity returns an error before any patch is sent. ### remove\_app\_state\_action Send an app-state action as a syncd `Remove` rather than a `Set`. Sibling of `send_app_state_action` above, for the small set of actions that model deletion as their own operation instead of a `deleted` flag inside a `Set` value. ```rust theme={null} pub async fn remove_app_state_action( &self, schema: &Schema, index_args: &[&str], value: &wa::SyncActionValue, ) -> Result<(), AppStateError> ``` **Parameters:** same as [`send_app_state_action`](#send_app_state_action) — `schema`, `index_args`, and `value` are used identically. Only the wire operation differs. Most delete-like actions — labels, quick replies — use a `deleted` flag inside a `Set` value, not a syncd `Remove`. Check the action's WhatsApp Web builder before reaching for this method. Sending a `Remove` for one of those actions drops the record from the collection locally, but the linked devices never see the deletion, since they only watch for the `deleted` flag on a `Set`. [`ChatActions::remove_contact`](#save-and-remove-contacts) is the one action in this codebase that genuinely needs `Remove`, and it is already wrapped for you. Reach for `remove_app_state_action` directly only when adding support for a new action of this shape. **Example:** ```rust theme={null} use whatsapp_rust::schemas; use whatsapp_rust::waproto::whatsapp as wa; let value = wa::SyncActionValue { contact_action: Some(Default::default()), timestamp: Some(1_700_000_000_000), ..Default::default() }; client .remove_app_state_action(&schemas::CONTACT, &["15551234567@s.whatsapp.net"], &value) .await?; ``` ## App state sync All chat actions are synced across devices using WhatsApp's app state synchronization: | Action | Collection | | --------------------- | ---------------------- | | Archive | `regular_low` | | Pin | `regular_low` | | Mark chat as read | `regular_low` | | Mute | `regular_high` | | Star | `regular_high` | | Delete chat | `regular_high` | | Delete message for me | `regular_high` | | Clear chat | `regular_high` | | Status mute | `regular_high` | | Save contact | `critical_unblock_low` | | Remove contact | `critical_unblock_low` | App state sync requires encryption keys to be available. These are typically obtained during initial sync after authentication. Actions may fail if called immediately after pairing before sync completes. As of PR [#1158](https://github.com/oxidezap/whatsapp-rust/pull/1158), losing an app-state version race no longer silently drops your mutation. A version race happens when another linked device already advanced the same collection. The server now answers with the winning patches instead of an error. The client applies them, rebuilds your mutation on the new base, and resends. It retries up to 5 times, matching WhatsApp Web's own cap. Previously, the call returned `Ok(())` even though the conflict response was ignored and the mutation never took effect. This conflict-resolution path only returns `Err` after those 5 attempts are exhausted, or if the server rejects the patch outright (not a version conflict). The other failure causes in [Error handling](#error-handling) below — invalid input, missing sync keys, network errors — are unrelated to this change and still apply as before. ## Events Chat action changes are emitted as events that you can handle: ```rust theme={null} use wacore::types::events::Event; .on_event(|event, _client| async move { match &*event { Event::MuteUpdate(update) => { println!("Chat {} muted: {:?}", update.jid, update.action.muted); } Event::PinUpdate(update) => { println!("Chat {} pinned: {:?}", update.jid, update.action.pinned); } Event::ArchiveUpdate(update) => { println!("Chat {} archived: {:?}", update.jid, update.action.archived); } Event::StarUpdate(update) => { println!("Message {} starred: {:?}", update.message_id, update.action.starred); } Event::MarkChatAsReadUpdate(update) => { println!("Chat {} marked as read: {:?}", update.jid, update.action.read); } Event::DeleteChatUpdate(update) => { println!("Chat {} deleted (media: {})", update.jid, update.delete_media); } Event::ClearChatUpdate(update) => { println!("Chat {} cleared (media: {})", update.jid, update.delete_media); } Event::UserStatusMuteUpdate(update) => { println!("Status of {} muted: {}", update.jid, update.muted); } Event::DeleteMessageForMeUpdate(update) => { println!("Message {} in {} deleted for me", update.message_id, update.chat_jid); } Event::ContactRemoved(update) => { println!("Contact {} removed", update.jid); } _ => {} } }) ``` `Event::ContactRemoved` is distinct from `Event::ContactUpdate` — see [ContactRemoved](/concepts/events#contactremoved). ## Error handling All methods return `Result<(), AppStateError>`: ```rust theme={null} #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum AppStateError { #[error("invalid app-state request: {0}")] InvalidRequest(String), #[error("{0}")] Internal(#[from] anyhow::Error), } ``` You'll encounter these most often: * `InvalidRequest` — you passed an invalid timestamp to `mute_chat_until` or omitted `participant_jid` for a group operation * `Internal` — no app state sync key is available yet (sync not complete), a network error occurred, or (as of PR #1158) an app-state version conflict with another device could not be resolved after 5 rebuild-and-resend attempts ```rust theme={null} use whatsapp_rust::AppStateError; match client.chat_actions().mute_chat_until(&jid, 0).await { Ok(_) => println!("Muted"), Err(AppStateError::InvalidRequest(msg)) => eprintln!("Validation error: {}", msg), Err(e) => eprintln!("Failed: {}", e), } ``` ## Complete example ```rust theme={null} use whatsapp_rust::Client; use wacore_binary::jid::Jid; use chrono::{Utc, Duration}; use std::sync::Arc; async fn organize_chats(client: &Arc) -> anyhow::Result<()> { let important_chat: Jid = "15551234567@s.whatsapp.net".parse()?; let noisy_group: Jid = "123456789@g.us".parse()?; let old_chat: Jid = "15559876543@s.whatsapp.net".parse()?; // Pin important conversations client.chat_actions().pin_chat(&important_chat).await?; // Mute noisy group for 1 week let mute_until = Utc::now() + Duration::weeks(1); client.chat_actions() .mute_chat_until(&noisy_group, mute_until.timestamp_millis()) .await?; // Archive old conversations client.chat_actions().archive_chat(&old_chat, None).await?; // Star an important message client.chat_actions() .star_message(&important_chat, None, "IMPORTANT_MSG_ID", false) .await?; // Mark chat as read across all devices client.chat_actions() .mark_chat_as_read(&important_chat, true, None) .await?; // Delete a message locally (not for everyone) client.chat_actions() .delete_message_for_me(&old_chat, None, "OLD_MSG_ID", false, true, None) .await?; // Delete an old chat and its media client.chat_actions().delete_chat(&old_chat, true, None).await?; Ok(()) } ``` ## See also * [Events](/concepts/events) - Handle chat action update events * [Groups](/api/groups) - Group management operations * [Client](/api/client) - Core client API * [Labels](/api/labels) - Chat and message label operations built on the same app-state send path * [Quick Replies](/api/quick-replies) - Saved reply shortcuts built on the same app-state send path # Chatstate Source: https://whatsapp-rust.jlucaso.com/api/chatstate Typing indicators and chat state notifications The `Chatstate` struct provides methods for sending typing indicators and recording notifications to recipients. ## Access Access chatstate operations through the client: ```rust theme={null} let chatstate = client.chatstate(); ``` ## Methods ### send Send a chat state update to a recipient. ```rust theme={null} pub async fn send( &self, to: &Jid, state: ChatStateType, ) -> Result<(), ChatStateError> ``` **Parameters:** * `to` - Recipient JID (user or group) * `state: ChatStateType` - Type of chat state to send **Returns:** * `Result<(), ChatStateError>` — `Ok(())` on success; a `ChatStateError` if the client is disconnected or the JID is invalid **Example:** ```rust theme={null} use whatsapp_rust::features::chatstate::ChatStateType; let recipient: Jid = "15551234567@s.whatsapp.net".parse()?; // Send typing indicator client.chatstate().send(&recipient, ChatStateType::Composing).await?; // Send recording indicator client.chatstate().send(&recipient, ChatStateType::Recording).await?; // Send paused (stopped typing) client.chatstate().send(&recipient, ChatStateType::Paused).await?; ``` ### send\_composing Convenience method to send typing indicator. ```rust theme={null} pub async fn send_composing(&self, to: &Jid) -> Result<(), ChatStateError> ``` **Example:** ```rust theme={null} let recipient: Jid = "15551234567@s.whatsapp.net".parse()?; client.chatstate().send_composing(&recipient).await?; ``` ### send\_recording Convenience method to send audio recording indicator. ```rust theme={null} pub async fn send_recording(&self, to: &Jid) -> Result<(), ChatStateError> ``` **Example:** ```rust theme={null} let recipient: Jid = "15551234567@s.whatsapp.net".parse()?; client.chatstate().send_recording(&recipient).await?; println!("Showing 'recording audio' indicator"); ``` ### send\_paused Convenience method to send paused/stopped typing indicator. ```rust theme={null} pub async fn send_paused(&self, to: &Jid) -> Result<(), ChatStateError> ``` **Example:** ```rust theme={null} let recipient: Jid = "15551234567@s.whatsapp.net".parse()?; client.chatstate().send_paused(&recipient).await?; println!("Cleared typing indicator"); ``` ## Receiving chat state events ### ChatStateEvent Incoming chatstate stanzas (typing indicators from other users) are dispatched as `ChatStateEvent` values. Register a handler with [`register_chatstate_handler`](/api/client#register_chatstate_handler) to receive them. ```rust theme={null} #[derive(Debug, Clone)] pub struct ChatStateEvent { /// The chat where the event occurred (user JID for 1:1, group JID for groups) pub chat: Jid, /// For group chats, the participant who triggered the event pub participant: Option, /// The chat state (typing, recording_audio, or idle) pub state: ReceivedChatState, } ``` The chat where the event occurred. For direct messages this is the sender's JID; for groups this is the group JID. For group chats, the participant who triggered the event. `None` for 1:1 chats. The parsed chat state — see `ReceivedChatState` below. **Example:** ```rust theme={null} use whatsapp_rust::ChatStateEvent; use std::sync::Arc; client.register_chatstate_handler(Arc::new(|event: ChatStateEvent| { match event.state { ReceivedChatState::Typing => { println!("{} is typing in {}", event.participant.as_ref().unwrap_or(&event.chat), event.chat); } ReceivedChatState::RecordingAudio => { println!("{} is recording audio", event.chat); } ReceivedChatState::Idle => { println!("{} stopped typing", event.chat); } _ => {} } })); ``` ### ReceivedChatState The state values for incoming chatstate events, aligned with WhatsApp Web's `WAChatState` constants. ```rust theme={null} #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReceivedChatState { Typing, // User is typing text RecordingAudio, // User is recording a voice message Idle, // User stopped typing/recording (default) } ``` `ReceivedChatState` is `#[non_exhaustive]`, so match statements should include a wildcard arm. **Wire format mapping:** | XML stanza | ReceivedChatState | | ---------------------------- | ----------------- | | `` | `Typing` | | `` | `RecordingAudio` | | `` | `Idle` | ## Sending chat state updates ## ChatStateType Enum ```rust theme={null} #[non_exhaustive] pub enum ChatStateType { Composing, // Typing text Recording, // Recording audio Paused, // Stopped typing/recording } ``` `ChatStateType` is `#[non_exhaustive]`, so match statements should include a wildcard arm to handle future variants. **Methods:** * `as_str()` - Returns `"composing"`, `"recording"`, or `"paused"` **String conversion:** ```rust theme={null} use whatsapp_rust::features::chatstate::ChatStateType; let state = ChatStateType::Composing; assert_eq!(state.as_str(), "composing"); assert_eq!(state.to_string(), "composing"); // Parse from string let parsed = ChatStateType::try_from("recording")?; assert_eq!(parsed, ChatStateType::Recording); ``` ## Wire Format ### Composing (Typing) ```xml theme={null} ``` ### Recording (Voice) ```xml theme={null} ``` Note: Recording uses `` rather than a separate tag. ### Paused (Stopped) ```xml theme={null} ``` ## Usage Patterns ### Typing indicator lifecycle ```rust theme={null} use whatsapp_rust::features::chatstate::ChatStateType; let recipient: Jid = "15551234567@s.whatsapp.net".parse()?; // User starts typing client.chatstate().send_composing(&recipient).await?; // User is typing... (you may want to throttle these) std::thread::sleep(std::time::Duration::from_secs(2)); // User stopped typing (optional, will auto-clear) client.chatstate().send_paused(&recipient).await?; // Send the actual message let message = wa::Message { conversation: Some("Hello!".to_string()), ..Default::default() }; client.send_message(recipient.clone(), message).await?; ``` ### Recording Audio ```rust theme={null} let recipient: Jid = "15551234567@s.whatsapp.net".parse()?; // Start recording client.chatstate().send_recording(&recipient).await?; // Record audio... let audio_data = record_audio(); // Stop indicator (optional) client.chatstate().send_paused(&recipient).await?; // Upload and send audio message let upload = client.upload(audio_data, MediaType::Audio, Default::default()).await?; let message = wa::Message { audio_message: buffa::MessageField::some(wa::message::AudioMessage { url: Some(upload.url), direct_path: Some(upload.direct_path), media_key: Some(upload.media_key_vec()), file_enc_sha256: Some(upload.file_enc_sha256_vec()), file_sha256: Some(upload.file_sha256_vec()), file_length: Some(upload.file_length), mimetype: Some("audio/ogg; codecs=opus".to_string()), ptt: Some(true), ..Default::default() }), ..Default::default() }; client.send_message(recipient.clone(), message).await?; ``` ### Throttling To avoid spamming chat state updates: ```rust theme={null} use std::time::{Duration, Instant}; struct ChatStateThrottler { last_sent: Option, interval: Duration, } impl ChatStateThrottler { fn new() -> Self { Self { last_sent: None, interval: Duration::from_secs(3), } } fn should_send(&mut self) -> bool { if let Some(last) = self.last_sent { if last.elapsed() < self.interval { return false; } } self.last_sent = Some(Instant::now()); true } } // Usage let mut throttler = ChatStateThrottler::new(); let recipient: Jid = "15551234567@s.whatsapp.net".parse()?; // In your typing event handler if throttler.should_send() { client.chatstate().send_composing(&recipient).await?; } ``` ## Group Chats Chat state indicators work in group chats as well: ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; // Show typing in group client.chatstate().send_composing(&group_jid).await?; // Send message let message = wa::Message { conversation: Some("Hello everyone!".to_string()), ..Default::default() }; client.send_message(group_jid.clone(), message).await?; ``` ## Error Handling All methods return `Result<(), ChatStateError>`. You'll encounter these most often: * **Not connected** — you are not connected to WhatsApp * **Invalid JID** — the recipient JID is malformed * **Network errors** — connection dropped mid-send ```rust theme={null} use whatsapp_rust::ChatStateError; let recipient: Jid = "15551234567@s.whatsapp.net".parse()?; match client.chatstate().send_composing(&recipient).await { Ok(_) => println!("Sent typing indicator"), Err(ChatStateError::Client(e)) => { eprintln!("Client error: {}", e); } Err(e) => eprintln!("Error: {}", e), } ``` ## Best Practices 1. **Throttle updates**: Don't send chat state updates more than once every 2-3 seconds 2. **Clear on send**: Send `Paused` before sending the actual message 3. **Auto-timeout**: Consider auto-clearing typing indicators after 10-15 seconds of inactivity 4. **Don't spam**: Only send when state actually changes 5. **Groups**: Be mindful that all group members will see the indicator ## Complete Example ```rust theme={null} use whatsapp_rust::features::chatstate::ChatStateType; use std::time::Duration; let recipient: Jid = "15551234567@s.whatsapp.net".parse()?; // Simulate user typing println!("User starts typing..."); client.chatstate().send_composing(&recipient).await?; // Simulate typing delay tokio::time::sleep(Duration::from_secs(2)).await; // User still typing (throttled) println!("Still typing..."); // User finishes and sends message client.chatstate().send_paused(&recipient).await?; let message = wa::Message { conversation: Some("Hello there!".to_string()), ..Default::default() }; client.send_message(recipient.clone(), message).await?; println!("Message sent"); // Or simulate voice recording println!("\nUser starts recording..."); client.chatstate().send_recording(&recipient).await?; tokio::time::sleep(Duration::from_secs(3)).await; client.chatstate().send_paused(&recipient).await?; println!("Recording stopped (would send audio here)"); ``` # Client Source: https://whatsapp-rust.jlucaso.com/api/client Core client for WhatsApp connectivity and protocol operations The `Client` struct is the core of whatsapp-rust, managing connections, encryption, state, and all protocol-level operations. ## Overview The Client handles: * WebSocket connection lifecycle and automatic reconnection * Noise Protocol handshake and encryption * Signal Protocol E2E encryption for messages * App state synchronization * Device state persistence * Event dispatching Most users should use the [`Bot`](/api/bot) builder instead of creating a Client directly. The Bot provides a simplified API with sensible defaults. ## Creating a Client ```rust theme={null} use whatsapp_rust::Client; use whatsapp_rust::TokioRuntime; use std::sync::Arc; let (client, sync_receiver) = Client::new( Arc::new(TokioRuntime), persistence_manager, transport_factory, http_client, None, // version override ).await; ``` Async runtime for spawning tasks, sleeping, and blocking operations State manager for device credentials, sessions, and app state Factory for creating WebSocket connections HTTP client for media operations and version fetching Optional WhatsApp version override (primary, secondary, tertiary) Returns the client Arc and a receiver for history/app state sync tasks ### Creating with custom cache configuration ```rust theme={null} use whatsapp_rust::{Client, CacheConfig, CacheEntryConfig}; use whatsapp_rust::TokioRuntime; use std::time::Duration; let cache_config = CacheConfig { group_cache: CacheEntryConfig::new(None, 500), // No TTL ..Default::default() }; let (client, sync_receiver) = Client::new_with_cache_config( Arc::new(TokioRuntime), persistence_manager, transport_factory, http_client, None, cache_config, ).await; ``` See [Bot - Cache Configuration Reference](/api/bot#cache-configuration-reference) for available cache options. *** ## Connection Management ### run ```rust theme={null} pub async fn run(self: &Arc) ``` Main event loop that manages connection lifecycle with automatic reconnection. Runs indefinitely until: * `disconnect()`, `logout()`, or `signal_shutdown_sync()` is called * Auto-reconnect is disabled and connection fails * Client receives a fatal stream error (401 unauthorized, 409 conflict, or 516 device removed) If you call `disconnect()`, `logout()`, or `signal_shutdown_sync()` while `run()` is waiting out its reconnect backoff, the client interrupts the wait immediately. The backoff can otherwise run up to the 900s cap (see [Auto-Reconnection](#auto-reconnection)). So without this, awaiting `run()` right after requesting a stop could look like a 15-minute hang. For example, [`bot.run().await?`](/api/bot#run) returns a `BotHandle` immediately — awaiting that handle waits on this same `run()`. A per-connection shutdown does not cut the backoff short; that's the kind `run()` itself reconnects from. Only the three terminal calls above interrupt the wait. **Example:** ```rust theme={null} let client_clone = client.clone(); tokio::spawn(async move { client_clone.run().await; }); ``` ### connect ```rust theme={null} pub async fn connect(self: &Arc) -> Result, ConnectError> ``` Establishes WebSocket connection and performs Noise Protocol handshake. Both the transport connection and the version fetch run in parallel under a **20-second timeout** (`TRANSPORT_CONNECT_TIMEOUT`), matching WhatsApp Web's MQTT and DGW connect timeout defaults. Without this, a dead network would block on the OS TCP SYN timeout (\~60-75s). The Noise handshake response also has a separate **20-second timeout** (`NOISE_HANDSHAKE_RESPONSE_TIMEOUT`). **Breaking change in PR #1258:** `connect()` now resolves to `Result, ConnectError>` instead of `Result<(), ConnectError>`. Establishing the socket is all `connect()` does — the server's frames queue in the transport channel until the returned [`Connection`](#connection) is driven with [`read_until_disconnected()`](#connection). `Connection` is `#[must_use]`, so the old `client.connect().await?;` now reports an `unused_must_use` warning instead of silently doing nothing (a client that connects and then just waits for events used to wait forever, with no timeout, error, or log to explain why). Migration: ```rust theme={null} // Before client.connect().await?; // After: drive this one connection directly... client.connect().await?.read_until_disconnected().await; // ...or, for a session that reconnects, use run() instead of connect() client.run().await; ``` **Errors (`ConnectError`):** * `AlreadyConnected` - a connection is already up, or another `connect()` attempt is already in flight * `NotActivated` - construction never activated (only reachable with the `client-lifecycle` feature) * `Shutdown` - added in PR #1258. The client has already been shut down (`disconnect()`, `logout()`, or `signal_shutdown_sync()`); shutdown is final, so build a new client rather than reconnecting this one * `Paused` - added in PR #1265. [`pause()`](#pause) is in effect. Unlike `Shutdown` this is not final — [`resume()`](#resume) lifts it and `connect()` works again. `connect()` rechecks the pause at every checkpoint of the connect graph, so an attempt already in flight when `pause()` lands is retracted rather than published. * `Timeout { stage, timeout }` - the version fetch or the transport open ran out of time, independently, each under the same 20s budget (`ConnectStage::VersionFetch`/`Transport`). `connect()` itself never reports `ConnectStage::Socket` or `Ready` — those are only produced by `wait_for_socket()`/`wait_for_connected()` below. * `Version(anyhow::Error)` / `Transport(anyhow::Error)` - app version resolution or transport open failed outright * `Handshake(HandshakeError)` - the Noise handshake failed after the transport was up; check `HandshakeError::is_transient()` to decide whether a retry is worthwhile See [`ConnectError`](/api/errors#connecterror) for the full variant reference. ### Connection ```rust theme={null} #[must_use] pub struct Connection<'a> { /* ... */ } impl Connection<'_> { pub async fn read_until_disconnected(self) -> Option } ``` Added in PR #1258. An established connection that nothing is reading yet — `connect()` stops right after the handshake, so the frames the server sends next just sit in the transport channel until something drives them. `read_until_disconnected()` is that read: it decodes frames into nodes and events until the connection ends, tears the connection down, and returns the reason an unexpected end carried (the same reason dispatched as `Event::Disconnected` just before returning), or `None` when the end was not one to report — a requested disconnect, or a protocol step like the 515 that follows pairing. [`run()`](#run) performs the same read inside its reconnect loop; reach for `Connection` directly only when a session must not outlive its first connection. Dropping a `Connection` without reading it logs a warning and leaves it unread — the socket stays open and the client keeps reporting itself connected, so the next `connect()` is refused until `disconnect()` releases it. Dropping the `read_until_disconnected()` future mid-read (e.g. a wrapping timeout) stops reading without tearing the connection down either, matching what dropping `run()`'s future has always done. ### logout ```rust theme={null} pub async fn logout(self: &Arc) ``` Deregisters this companion device from WhatsApp and disconnects. This sends a device removal IQ to the server, disables auto-reconnect, disconnects the transport, and emits a `LoggedOut` event. As of PR #1090, `logout()` is infallible (`()`, not `Result<()>`). The deregistration IQ is best-effort — it cannot be sent at all while offline — and the local teardown runs either way, so there was nothing for a caller to branch on. A failed IQ is logged at `warn`. This does **not** wipe stored keys or credentials. To fully clear session data, delete the storage backend after calling `logout()`. **Example:** ```rust theme={null} // Logout and clean up client.logout().await; // Optionally delete stored credentials // std::fs::remove_dir_all("./whatsapp_data")?; ``` **Behavior:** 1. Disables auto-reconnect 2. Sends a `RemoveCompanionDeviceSpec` IQ to deregister the companion device (if connected); a failure here is logged, not returned 3. Disconnects the transport 4. Emits `Event::LoggedOut` with `reason: ConnectFailureReason::LoggedOut` ### disconnect ```rust theme={null} pub async fn disconnect(self: &Arc) ``` Disconnects gracefully and disables auto-reconnect. It signals shutdown (sets the expected-disconnect and stop flags, fires the shutdown notifiers), flushes pending outbound receipts and device state, closes the transport, and then runs `cleanup_connection_state()`. That cleanup resets all connection-scoped state — invalidating per-chat message queues so stale workers exit, flushing then clearing the signal cache (so pending sender-key/identity writes are persisted, not lost), draining pending IQ waiters, and resetting offline sync state. The same `cleanup_connection_state()` also runs from `run()` after the message loop exits; it is idempotent and race-tolerant, so whichever path wins, connection-scoped state is reset once in effect. See [disconnect cleanup](/concepts/architecture#disconnect-cleanup) for the full list of resources cleaned up. ### signal\_shutdown\_sync ```rust theme={null} pub fn signal_shutdown_sync(&self) ``` Synchronous, flag-only variant of `disconnect()` for places where you can't `await`. It flips `expected_disconnect`, clears `is_running`, fires the terminal `shutdown_notifier`, and notifies the per-connection shutdown so spawned tasks exit on their next poll. It does **not** flush, close the transport, or touch persistence — prefer `disconnect()` whenever you can await. Intended for `Drop` impls on FFI wrappers (e.g. the WASM client) that need to release the runtime without blocking. ### reconnect ```rust theme={null} pub async fn reconnect(self: &Arc) ``` Drops the current connection and triggers auto-reconnect with a deliberate \~5s offline window (Fibonacci backoff step 4). The run loop stays active. Use this for: * Handling network changes (e.g., Wi-Fi to cellular) * Forcing a fresh server session * Testing offline message delivery **Example:** ```rust theme={null} // Force reconnection with backoff delay client.reconnect().await; // Wait for the new connection to be ready client.wait_for_connected(Duration::from_secs(30)).await?; ``` ### reconnect\_immediately ```rust theme={null} pub async fn reconnect_immediately(self: &Arc) ``` Drops the current connection and reconnects immediately with no delay. Unlike `reconnect()`, this sets the expected disconnect flag so the run loop skips the backoff delay. **Example:** ```rust theme={null} // Force immediate reconnection (no backoff) client.reconnect_immediately().await; ``` ### pause ```rust theme={null} pub async fn pause(self: &Arc) ``` Added in PR #1265. Drops the current connection and keeps it down until [`resume()`](#resume). It is the middle of the range between [`reconnect()`](#reconnect), which comes back on the library's schedule, and [`disconnect()`](#disconnect), which does not come back at all. The [`run()`](#run) supervision loop stays alive and parked, so the future a caller is awaiting keeps running. The client is **not** terminal; it is between connections, on purpose — provided `enable_auto_reconnect` is still set, see the warning below. Once `pause()` returns, the socket is closed and pending receipts and Signal state are flushed on the same terms as `disconnect()`. **No connection will be opened by anyone** until `resume()`: `connect()` rechecks the pause at every step of the connect graph — version fetch, transport open, handshake, and the final publish — and refuses with `ConnectError::Paused` for as long as it holds. An attempt already in flight when `pause()` lands is retracted rather than published, not merely refused as of the next attempt. `pause()` is idempotent — pausing an already-paused client just tears down again. `pause()` dispatches no `Event::Disconnected` (the application ended this connection, so the teardown is not news — the same reasoning `reconnect()` applies), and it is not a protocol-level presence change (the account stays registered, other devices see nothing). `pause()` does not override `enable_auto_reconnect`. If it was already `false` when `pause()` tore down a live connection (or interrupted an in-flight `connect()`), the run loop's own auto-reconnect check runs before its pause handling — so the loop exits entirely, the same as an ordinary auto-reconnect-disabled disconnect, and the client becomes terminal. `resume()` afterward only clears the pause flag; there is no running loop left for it to wake. **Example:** ```rust theme={null} // Go offline and stay offline until the application says otherwise client.pause().await; // ... later ... client.resume(); client.wait_for_connected(Duration::from_secs(30)).await?; ``` ### resume ```rust theme={null} pub fn resume(&self) ``` Added in PR #1265. Releases a [`pause()`](#pause): the run loop reconnects at once, with no backoff owed for the offline window the application chose — provided `run()` is still driving the client with `enable_auto_reconnect` set (see the warning under [`pause()`](#pause); a pause that landed while auto-reconnect was disabled has already ended the loop, and `resume()` has no loop left to restart). Returns once the loop has been told, not once it is connected — wait for that with [`wait_for_connected()`](#wait_for_connected). A true no-op — no state change, no log, no notification — only on a client that was not paused to begin with. Calling it on a client that has since been [`disconnect()`](#disconnect)ed still clears the pause (`is_paused()` becomes `false`) and fires the session-state notifier, but that is all it does: it does not undo the shutdown or bring back a connection. `is_terminal()` stays `true`, and the next `connect()` still refuses with `ConnectError::Shutdown`. Safe to call while a `pause()` is still tearing down; it does not wait for the teardown to finish, since that teardown ends in an untimed socket close. ### is\_paused ```rust theme={null} pub fn is_paused(&self) -> bool ``` Added in PR #1265. Whether [`pause()`](#pause) is in effect and no connection will be opened by [`run()`](#run) until [`resume()`](#resume). ### wait\_for\_socket ```rust theme={null} pub async fn wait_for_socket( &self, timeout: std::time::Duration ) -> Result<(), ConnectError> ``` Waits for the Noise socket to be ready (before login). Useful for pair code flows. Maximum time to wait Ok if socket ready, `ConnectError::Timeout { stage: ConnectStage::Socket, timeout }` on timeout ### wait\_for\_connected ```rust theme={null} pub async fn wait_for_connected( &self, timeout: std::time::Duration ) -> Result<(), ConnectError> ``` Waits for full connection and authentication to complete, including offline sync. Post-login tasks (presence, background queries) are gated behind offline sync completion, which resolves either when the server sends the end marker, all expected items arrive, or the 60-second timeout fires. Maximum time to wait Ok once fully ready, `ConnectError::Timeout { stage: ConnectStage::Ready, timeout }` on timeout As of PR #1090, both methods return [`ConnectError`](/api/errors#connecterror) instead of `anyhow::Error`. ### pair\_with\_code ```rust theme={null} pub async fn pair_with_code( self: &Arc, options: PairCodeOptions, ) -> Result ``` Initiates pair code authentication as an alternative to QR code pairing. The returned 8-character code should be displayed to the user, who enters it on their phone under **WhatsApp > Linked Devices > Link a Device > Link with phone number instead**. This can run concurrently with QR code pairing — whichever completes first wins. **One code at a time.** Fails with `PairCodeError::CodeAlreadyOutstanding` while a previous code is still outstanding, instead of silently replacing it. "Outstanding" means either the previous code's validity window hasn't elapsed yet, *or* its `primary_hello` was already accepted and a `pair-success` for it is still pending — that second case can outlast the validity window by up to a minute, and `remaining` reads as `0` for it since there's no window left to report. A second code does not replace the first for the phone: the server routes `primary_hello` by number and never sees the code itself, so whoever is still reading the older one reaches stage 2 regardless. Call [`cancel_pair_code`](#cancel_pair_code) first when the replacement is intentional. Do not call this on a schedule driven by QR-code rotation — the two flows have unrelated lifetimes. See [One code at a time](/concepts/authentication#one-code-at-a-time). On any failure other than `CodeAlreadyOutstanding` or `Cancelled`, this also dispatches [`Event::PairingCodeError`](/concepts/authentication#pair-code-failure-events) before returning the `Err` — the only surface [`BotBuilder::with_pair_code`](/api/bot#with_pair_code) can report through, since that path drives this call from a detached task. A direct caller sees the failure both ways: as the returned `Err` and, unless it's one of those two exclusions, on the event bus. Configuration for pair code authentication: * `phone_number` — Phone number in international format (e.g., `"15551234567"`) * `show_push_notification` — Whether to show a push notification on the phone (default: `true`) * `custom_code` — Optional custom 8-character code using Crockford Base32 alphabet * `platform_id` — `Option` override for ``. `None` derives the wire id from `Device.device_props.platform_type` (typically `Chrome`; Android `PlatformType`s also map to `Chrome` because the server requires attestation for the Android letter codes). The matching `` is always derived; web variants emit ` ()`, and explicit `AndroidPhone`/`AndroidTablet`/`AndroidAmbiguous` overrides emit `Android ()`. The 8-character pairing code to display to the user **Errors (`PairError`):** `PairError::PairCode(PairCodeError)` covers validation and crypto failures; `PairError::RequestFailed(IqError)` covers the IQ transport. | Variant | Cause | | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PairCode(PhoneNumberRequired)` | Empty phone number | | `PairCode(PhoneNumberTooShort)` | Fewer than 7 digits | | `PairCode(PhoneNumberNotInternational)` | Starts with `0` (not international format) | | `PairCode(InvalidCustomCode)` | Custom code is not 8 valid Crockford Base32 characters | | `PairCode(CodeAlreadyOutstanding { remaining })` | A previous code is still outstanding (within its validity window, or its `primary_hello` was accepted and `pair-success` is still pending) — call `cancel_pair_code` first | | `PairCode(Cancelled)` | `cancel_pair_code` was called while `companion_hello` (stage 1) was still in flight | | `PairCode(InvalidPrimaryEphemeralKey)` / `InvalidPrimaryIdentityKey` | Peer key parsing failed (typed `CurveError` source) | | `PairCode(EphemeralKeyAgreement)` / `IdentityKeyAgreement` | Diffie–Hellman failed (typed `CurveError` source) | | `PairCode(AdvSecretKeyDerivation)` / `BundleKeyDerivation` | HKDF expand failed | | `PairCode(BundleAead)` | AES-GCM encryption of the key bundle failed (typed `CryptoProviderError` source) | | `PairCode(MissingPairingRef)` | Server response missing pairing ref | | `RequestFailed` | Server IQ request failed (typed `IqError` source). `Display` renders exactly what it wraps (e.g. `429 (rate-overlimit)`) | `PairError` also exposes the server's refusal as a typed status, so a consumer doesn't have to match the message: ```rust theme={null} impl PairError { /// The server's refusal, classified from its `code`/`text` pair. An /// unrecognized code still comes back as `Some(PairCodeRejection::Unknown)`. /// `None` only when nothing was refused (local validation, no connection, /// timeout), or the server paired a *named* code with a contradicting `text`. pub fn rejection(&self) -> Option; /// The server's requested retry delay, when it named one. pub fn backoff(&self) -> Option; /// `true` for `CodeAlreadyOutstanding` and `Cancelled` — failures that /// don't mean "no code is coming", because another flow may still own /// the slot. `Event::PairingCodeError` is not dispatched for these. pub fn lost_the_flow_to_another_request(&self) -> bool; } ``` See [Pair code failure events](/concepts/authentication#pair-code-failure-events) for `PairCodeRejection`'s five named variants (`BadRequest`, `Forbidden`, `RateOverlimit`, `FeatureNotAvailable`, `InternalServerError`) plus its `Unknown(i32)` fallback, and for `is_throttled()`. **Example:** ```rust theme={null} use whatsapp_rust::pair_code::PairCodeOptions; let options = PairCodeOptions { phone_number: "15551234567".to_string(), show_push_notification: true, custom_code: None, // Generate random code ..Default::default() }; let code = client.pair_with_code(options).await?; println!("Enter this code on your phone: {}", code); ``` *** ### cancel\_pair\_code ```rust theme={null} pub async fn cancel_pair_code(self: &Arc) ``` Abandons the outstanding pair-code flow, if any — the explicit reset [`pair_with_code`](#pair_with_code) requires before it will mint a replacement (WA Web's `initializeAltDeviceLinking()`). A no-op when no flow is outstanding. **Reliable on both sides of `primary_hello`.** If no `primary_hello` has been accepted yet, cancellation is immediate and complete — a later `primary_hello` for the cancelled ref is dropped rather than answered. Stage 2 (deriving the key bundle and sending `companion_finish`) runs under the same lock `cancel_pair_code` takes, so the two never interleave mid-derivation: either `cancel_pair_code` wins and stage 2 finds the flow gone before sending anything, or stage 2 has already sent `companion_finish` and released the lock by the time `cancel_pair_code` gets a turn. In that second case, cancelling also **re-mints the device's `adv_secret_key`** — the value stage 2 derived and persisted is keyed to a primary that was just told to stop, so a `pair-success` that still arrives for it now fails signature verification instead of silently completing the link. A flow that already reached `PairCodeState::Completed` is left untouched, since that secret belongs to a device that did pair. **Example:** ```rust theme={null} client.cancel_pair_code().await; let code = client.pair_with_code(new_options).await?; ``` *** ### set\_passkey\_authenticator Enable the `passkey` feature before calling `set_passkey_authenticator`, `send_passkey_response`, or `send_passkey_confirmation` below — they don't exist without it (opt-in, off by default as of the next release after 0.7.0). See [Feature flags](/installation#feature-flags) and [Authentication — Passkey linking](/concepts/authentication#passkey-linking-shortcake_passkey). ```rust theme={null} pub async fn set_passkey_authenticator(&self, authenticator: Arc) ``` Registers a [`PasskeyAuthenticator`](/concepts/authentication#passkeyauthenticator-trait) for [passkey (SHORTCAKE\_PASSKEY) linking](/concepts/authentication#passkey-linking-shortcake_passkey). Once set, the client auto-drives the flow end-to-end: it calls `get_assertion` when the server requests one, sends the response, and auto-confirms a re-link whose `skip_handoff_ux` is `true`. Leave it unset to drive every step manually from the `Event::PairPasskey*` events. Produces a WebAuthn assertion for the server's challenge — typically backed by Android Credential Manager, hybrid/caBLE, or a software vault. Use `whatsapp_rust::passkey::CallbackAuthenticator::new(f)` to wrap an async closure. ### send\_passkey\_response ```rust theme={null} pub async fn send_passkey_response(&self, assertion: Assertion) -> Result<(), PasskeyError> ``` Sends the WebAuthn assertion as `` and opens the ephemeral-identity handshake. Call after an [`Event::PairPasskeyRequest`](/concepts/events#pairpasskeyrequest). Returns `PasskeyError::Flow` if a passkey open is already in progress. ### send\_passkey\_confirmation ```rust theme={null} pub async fn send_passkey_confirmation(&self) -> Result<(), PasskeyError> ``` Finishes the link: encrypts the rotated ADV secret under the derived key, sends ``, and commits the secret rotation. For a fresh link, call this only after the user confirms the code from an [`Event::PairPasskeyConfirmation`](/concepts/events#pairpasskeyconfirmation) — a proven re-link (`skip_handoff_ux: true`) can call it immediately, and the automatic driver does so itself. Returns `PasskeyError::Flow` if called before the confirmation stage or without an active session. **Errors (`PasskeyError`):** | Variant | Cause | | ------------------------ | ------------------------------------------------------------------ | | `NoCredential` | No passkey registered for this account on the authenticator | | `Cancelled` | User cancelled or the WebAuthn ceremony timed out | | `InvalidOptions(String)` | Malformed `PublicKeyCredentialRequestOptions` JSON from the server | | `Backend(String)` | Authenticator backend error | | `Flow(String)` | Protocol/state error (wrong stage, no active session, IQ failure) | *** ## Connection State ### is\_connected ```rust theme={null} pub fn is_connected(&self) -> bool ``` Returns `true` if the Noise socket is established. This method uses an internal `AtomicBool` flag (with `Acquire` ordering) instead of probing the noise socket mutex, making it lock-free and immune to false negatives under mutex contention. Prior to this design, connection checks used `try_lock()` on the noise socket mutex. Under contention (e.g., during frame encryption), `try_lock()` would fail and incorrectly report the client as disconnected — silently dropping receipt acks. The `AtomicBool` approach eliminates this race condition entirely. ### is\_logged\_in ```rust theme={null} pub fn is_logged_in(&self) -> bool ``` Returns `true` if authenticated with WhatsApp servers. ### reachability ```rust theme={null} pub fn reachability(&self) -> Reachability ``` Added in PR #1332. What the client's connection state means for work handed to it right now — see [`Reachability`](#reachability-enum) below. It's the single place that state is turned into an answer. A caller no longer has to assemble one from [`is_connected()`](#is_connected), [`is_logged_in()`](#is_logged_in), and [`is_paused()`](#is_paused) individually, because those flags don't compose safely on their own. A stream-error teardown sets its terminal condition before it clears the session. `is_logged_in` becomes true one instruction before the connection generation it authenticates. And a rate-limited (429) session keeps its socket open while no longer being reachable. Reads flags only — no lock, no allocation — so it costs the same whether the answer is acted on or discarded. A refused call (e.g. `ClientError::NotConnected`) and `reachability()` answer different questions. The error is a fact about one attempt, and it may already be stale by the time it's read — the connection could be lost right after a call was admitted, or restored right after one was refused. `reachability()` is state, re-read on demand. It's what tells "this comes back on its own" (`Reachability::Reconnecting`) apart from "stop trying" (`Reachability::Finished`) — a distinction the error alone can't make, since a client between connections and a finished one refuse a call with the identical message. ### wait\_until\_reachable ```rust theme={null} pub async fn wait_until_reachable(&self) -> Reachability ``` Added in PR #1332. Waits until the client can reach the server again, or until waiting stops being the right answer. Returns the [`Reachability`](#reachability-enum) that ended the wait: `Reachability::Reachable` once a connection arrives, otherwise the reason none will without the caller doing something about it. `Reachability::Reconnecting` is never returned — it's the one state this call waits out. Bounded by the client's own lifetime rather than by a duration. The reconnect backoff is jittered, capped at 900s, and followed by a handshake, so no constant here would be honest. Cancellation is the caller's to apply: drop the future, or wrap it in your own `tokio::time::timeout`. Waiting restores the ability to ask. It never re-sends the request that was refused. Issue the call again after this returns. The answer can already be stale by the time that next call reaches the socket. Safe from an [`on_event`](/api/bot#on_event)/`on_event_for` closure: those run off a spawned or drainer task, never on the read loop. Not safe from a [`with_event_handler`](/api/bot#with_event_handler)/`EventHandler::handle_event` implementation — that dispatches synchronously, inline on the read loop, so blocking on this call there (e.g. via a nested runtime) blocks the very connection it's waiting for. A [`pause()`](#pause) ends this wait rather than being sat through. `resume()` is what actually brings the client back, and that's the application's call to make — not something to park through indefinitely while holding the `Arc` whose drop would otherwise have been the only other way out. **Example:** `wait_until_reachable()` only has something to offer when the connection is what's wrong. Gate the retry on [`ErrorChainExt::is_transport_unavailable()`](/api/errors#error-chain-recovery) rather than on any `Err`, so a validation or authorization failure gets handled or propagated directly instead of parked behind a connection wait that can't resolve it: ```rust theme={null} use whatsapp_rust::{ErrorChainExt, Reachability}; match client.contacts().get_user_info(&[jid.clone()]).await { Ok(info) => { /* ... */ } Err(e) if e.is_transport_unavailable() => match client.wait_until_reachable().await { Reachability::Reachable => { // retry the call now that a connection is back } other => eprintln!("not retrying: {other:?}"), }, Err(e) => eprintln!("request failed: {e}"), // not a connectivity problem — waiting won't help } ``` ### Reachability enum ```rust theme={null} #[non_exhaustive] pub enum Reachability { Reachable, Reconnecting, Paused, Unsupervised, Finished, } impl Reachability { pub fn is_reachable(self) -> bool; pub fn recovers_on_its_own(self) -> bool; } ``` Added in PR #1332. Reported by [`reachability()`](#reachability) and settled by [`wait_until_reachable()`](#wait_until_reachable). Re-exported from the crate root as `whatsapp_rust::Reachability`. **Variants:** * `Reachable` — a request sent now has a socket, an authenticated session, and a reader to decode the answer. * `Reconnecting` — between connections, with something driving the client back to one: the normal auto-reconnect backoff, a [429's extended backoff](#rate-limiting-429), or a 503's normal backoff (see the [stream error table](#stream-error-handling) — 503 is service-unavailable, not rate limiting). Covers a first connection that hasn't landed yet as well as a session being restored, and deliberately doesn't separate them: every marker the client holds (`connection_generation`, `login_counter`, and the rest) is a fact about its past, and the question is about its future, and those don't line up in either direction — a device that authenticated yesterday and has since been revoked reports as "restoring a session" by every persistent marker and will never connect again, while a client whose first attempt lands during a brief outage reports as "never connected" and connects on the next. So this state asserts only that an attempt is being made, never that one will land — a Noise handshake torn down before `` arrives reads identically to a server that's momentarily unreachable and is retried the same way, so in principle a wait on this can run for as long as the process does. The one state `wait_until_reachable()` waits out; only `reachability()` ever reports it. * `Paused` — [`pause()`](#pause) is in effect. Like `Reconnecting` the client is not finished, but what ends it is [`resume()`](#resume), and only the application knows when that comes. So `wait_until_reachable()` returns here instead of sitting through it. The connect path's own internal wait — used by things like app-state resync — does sit through a pause, since nothing on the next connection would re-issue that work otherwise. * `Unsupervised` — nothing is reading this client: no [`run()`](#run) loop is driving it. No answer would ever be decoded and no reconnect will be attempted. Not terminal — the application may still use the connection directly — but waiting cannot fix it. Outranks `Paused` where both hold, since a paused client with no reader has nothing to reconnect it either way. * `Finished` — the session is over for good: shut down, logged out, replaced, or refused in a way no reconnect recovers. `is_reachable()` is `true` only for `Reachable`. `recovers_on_its_own()` is `true` only for `Reconnecting` — `false` for `Paused`, since that client does come back, but on `resume()` rather than by itself. It's an expectation rather than a promise, for the same reason given above: `true` says something is driving the client back, not that the cause is one a retry actually fixes, since the client has no way to tell those apart from where it sits. *** ## Auto-Reconnection The client includes automatic reconnection handling with Fibonacci backoff. ### How it works 1. **On disconnect**: The client detects unexpected disconnections and automatically attempts to reconnect 2. **Fibonacci backoff**: Each failed attempt increases the delay following the Fibonacci sequence (1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s...) with a maximum of 900 seconds (15 minutes) and +/-10% jitter 3. **Expected disconnects**: Protocol-expected disconnects (e.g., 515 stream error after pairing) trigger immediate reconnection without backoff 4. **Keepalive monitoring**: A keepalive loop sends periodic pings (every 15-30s) and forces reconnection if the socket appears dead (no data received for 20s after a send) ### Controlling auto-reconnect ```rust theme={null} // Disable auto-reconnect (default: enabled) client.enable_auto_reconnect.store(false, Ordering::Relaxed); // Check current state let enabled = client.enable_auto_reconnect.load(Ordering::Relaxed); ``` The consecutive-failure counter used for Fibonacci backoff is internal; read it via [`client.stats().reconnect_errors`](#diagnostics) instead of a public field. ### Stream error handling The client handles specific `` codes from the WhatsApp server: | Stream error code | Meaning | Event emitted | Auto-reconnect | | ----------------- | ---------------------------------------- | ---------------------------------- | ------------------------------------- | | **401** | Session invalidated (unauthorized) | `LoggedOut` | Disabled — must re-pair | | **409** | Another client connected (conflict) | `StreamReplaced` | Disabled — prevents displacement loop | | **429** | Rate limited (too many connections) | `StreamError`, then `Disconnected` | Yes, with extended backoff (+5 steps) | | **503** | Service unavailable | None | Yes, normal backoff | | **515** | Expected disconnect (e.g., post-pairing) | None | Yes, immediate (no backoff) | | **516** | Device removed | `LoggedOut` | Disabled — must re-pair | | Unknown | Unrecognized code | `StreamError` | Disabled | When you receive a `LoggedOut` or `StreamReplaced` event, auto-reconnect is permanently disabled for that session. You must create a new client and re-pair to continue. ### Rate limiting (429) When the server returns a 429 stream error, the client bumps the internal backoff counter by 5 Fibonacci steps before reconnecting. This means the reconnection delay jumps significantly (e.g., from \~1s to \~13s on the first rate limit) to respect the server's throttling. As of [#1263](https://github.com/oxidezap/whatsapp-rust/pull/1263), the client also dispatches an `Event::StreamError` for 429 (code `"429"`). WhatsApp Web's own handler gives no UI signal for this case — it only special-cases `500..600`. An embedder has no UI to fall back on, so 429 is now reported the same way every other coded stream error is. This event fires in addition to `Event::Disconnected`, not instead of it. The 429 handler never marks the disconnect as expected, so the shared connection-loss path still dispatches `Disconnected` once the socket closes, the same as every other unexpected drop. ### General reconnection behavior | Scenario | Behavior | | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | Unexpected disconnect | Reconnect with Fibonacci backoff | | 515 stream error (after pairing) | Immediate reconnect | | Keepalive dead socket (20s) | Force disconnect and reconnect | | `disconnect()` called | No reconnect attempt | | `logout()` called | No reconnect attempt, device deregistered | | Auto-reconnect disabled | No reconnect attempt | | You call `disconnect()` / `logout()` / `signal_shutdown_sync()` during a backoff wait | The client interrupts the wait immediately; `run()` returns without waiting out the remaining delay | | `pause()` called, `enable_auto_reconnect` set | No reconnect attempt; `run()` parks instead of connecting, no backoff owed | | `pause()` called, `enable_auto_reconnect` disabled | Run loop exits entirely — same as an ordinary auto-reconnect-disabled disconnect, not a park | | `resume()` called (loop parked from a pause) | Reconnects immediately, no backoff owed for the paused window | *** ## Messaging ### send\_message ```rust theme={null} pub async fn send_message( &self, to: Jid, message: wa::Message ) -> Result ``` Sends an encrypted message to a chat. Recipient JID ([user@s.whatsapp.net](mailto:user@s.whatsapp.net) or [group@g.us](mailto:group@g.us)) Protobuf message content A [`SendResult`](/api/send#sendresult) containing the `message_id` and destination `to` JID **Example:** ```rust theme={null} use waproto::whatsapp as wa; let message = wa::Message { conversation: Some("Hello!".to_string()), ..Default::default() }; let result = client.send_message(jid, message).await?; println!("Sent message ID: {}", result.message_id); ``` ### send\_message\_with\_options ```rust theme={null} pub async fn send_message_with_options( &self, to: Jid, message: wa::Message, options: SendOptions ) -> Result ``` Sends a message with advanced options like a custom message ID, extra stanza nodes, or ephemeral expiration. Configuration for message sending behavior. Supports `message_id` (override the auto-generated ID), `extra_stanza_nodes` (custom XML nodes on the stanza), and `ephemeral_expiration` (disappearing message duration in seconds). See [Send API reference](/api/send#sendoptions) for available options. ### edit\_message ```rust theme={null} pub async fn edit_message( &self, to: Jid, original_id: impl Into, new_content: wa::Message ) -> Result ``` Edits a previously sent message. `SendResult::message_id` is the edit's own fresh id, and `message` the `protocolMessage` this crate built. See [Send API reference](/api/send#edit_message). ID of the message to edit New message content ### edit\_message\_with\_options ```rust theme={null} pub async fn edit_message_with_options( &self, to: impl Into, original_id: impl Into, new_content: wa::Message, options: EditOptions ) -> Result ``` Edit-path counterpart of `send_message_with_options`. Accepts an [`EditOptions`](/api/send#editoptions) (built via `EditOptions::default().with_stanza_id(id)`) to pin the outer stanza id to an existing message's id (best-effort — server/client dependent) instead of the fresh id `edit_message` generates. See [Send API reference](/api/send#edit_message_with_options) for the full `EditOptions` type and its side-effect notes. ### revoke\_message ```rust theme={null} pub async fn revoke_message( &self, to: Jid, message_id: impl Into, revoke_type: RevokeType ) -> Result ``` Deletes a message. Use `Sender` to revoke your own message, or `Admin` to revoke another user's message as group admin. `SendResult::message_id` is the revoke's own fresh id, and `message` the `protocolMessage` it carried. See [Send API reference](/api/send#revoke_message). `RevokeType::Sender` (delete your own message) or `RevokeType::Admin { original_sender: Jid }` (admin revoke in groups) *** ## Feature APIs The Client provides namespaced access to feature-specific operations: ### blocking ```rust theme={null} pub fn blocking(&self) -> Blocking<'_> ``` Access blocking operations. **Methods:** * `block(jid: &Jid)` - Block a contact * `unblock(jid: &Jid)` - Unblock a contact * `get_blocklist()` - Get all blocked contacts * `is_blocked(jid: &Jid)` - Check if contact is blocked **Example:** ```rust theme={null} client.blocking().block(&jid).await?; let blocked = client.blocking().get_blocklist().await?; ``` ### bots ```rust theme={null} pub fn bots(&self) -> Bots<'_> ``` Access the server's directory of first-party AI bots. **Methods:** * `list()` - Fetch the bot directory See [Bots API](/api/bots) for full documentation. ### groups ```rust theme={null} pub fn groups(&self) -> Groups<'_> ``` Access group management operations. **Methods:** * `query_info(jid: &Jid)` - Get cached group info * `get_metadata(jid: &Jid)` - Fetch group metadata from server * `get_participating()` - List all groups you're in * `create_group(options: GroupCreateOptions)` - Create a new group * `set_subject(jid: &Jid, subject: GroupSubject)` - Change group name * `set_description(jid: &Jid, desc: Option, prev: PreviousDescription<'_>)` - Change description; `prev` is an optimistic-concurrency token ([`PreviousDescription::Resolve`](/api/groups#previousdescription) reads the current one for you) * `leave(jid: &Jid)` - Leave a group * `add_participants(jid: &Jid, participants: &[Jid])` - Add members * `remove_participants(jid: &Jid, participants: &[Jid])` - Remove members * `promote_participants(jid: &Jid, participants: &[Jid])` - Make members admins * `demote_participants(jid: &Jid, participants: &[Jid])` - Remove admin status * `get_invite_link(jid: &Jid, reset: bool)` - Get/reset invite link * `join_with_invite_code(code: &str)` - Join a group via invite code or URL * `join_with_invite_v4(group_jid, code, expiration, admin_jid)` - Accept a V4 invite message * `get_invite_info(code: &str)` - Preview group metadata from invite code * `set_locked(jid: &Jid, locked: bool)` - Lock/unlock group info editing * `set_announce(jid: &Jid, announce: bool)` - Enable/disable announcement mode * `set_ephemeral(jid: &Jid, expiration: u32)` - Set disappearing messages timer * `set_membership_approval(jid: &Jid, mode: MembershipApprovalMode)` - Require admin approval * `get_membership_requests(jid: &Jid)` - Get pending membership requests * `approve_membership_requests(jid: &Jid, participants: &[Jid])` - Approve pending requests * `reject_membership_requests(jid: &Jid, participants: &[Jid])` - Reject pending requests * `set_member_add_mode(jid: &Jid, mode: MemberAddMode)` - Set who can add members * `set_no_frequently_forwarded(jid: &Jid, restrict: bool)` - Restrict forwarding of frequently forwarded messages * `set_allow_admin_reports(jid: &Jid, allow: bool)` - Allow or disallow admin reports * `set_group_history(jid: &Jid, enabled: bool)` - Enable or disable group history for new members * `set_member_link_mode(jid: &Jid, mode: MemberLinkMode)` - Set member link mode * `set_member_share_history_mode(jid: &Jid, mode: MemberShareHistoryMode)` - Set history sharing mode for new members * `set_limit_sharing(jid: &Jid, enabled: bool)` - Limit sharing within the group * `cancel_membership_requests(jid: &Jid, participants: &[Jid])` - Cancel pending membership requests * `revoke_request_code(jid: &Jid, participants: &[Jid])` - Revoke request codes for participants * `acknowledge(jid: &Jid)` - Acknowledge a group * `batch_get_info(jids: Vec)` - Batch fetch group metadata for multiple groups * `get_profile_pictures(group_jids: Vec, picture_type: PictureType)` - Batch fetch group profile pictures **Example:** ```rust theme={null} use whatsapp_rust::features::groups::{GroupCreateOptions, GroupParticipantOptions}; let options = GroupCreateOptions::builder() .subject("My Group") .participants(vec![GroupParticipantOptions::new(participant_jid)]) .build(); let result = client.groups().create_group(options).await?; ``` ### presence ```rust theme={null} pub fn presence(&self) -> Presence<'_> ``` Access presence operations. **Methods:** * `set(status: PresenceStatus)` - Set presence status * `set_available()` - Set status to available/online * `set_unavailable()` - Set status to unavailable/offline * `subscribe(jid: &Jid)` - Subscribe to contact's presence updates * `unsubscribe(jid: &Jid)` - Unsubscribe from contact's presence updates Subscriptions are automatically tracked and re-subscribed on reconnect. **Example:** ```rust theme={null} client.presence().set_available().await?; client.presence().subscribe(&jid).await?; // Later, stop receiving updates client.presence().unsubscribe(&jid).await?; ``` ### chatstate ```rust theme={null} pub fn chatstate(&self) -> Chatstate<'_> ``` Access chat state (typing indicator) operations. **Methods:** * `send(to: &Jid, state: ChatStateType)` - Send a chat state update * `send_composing(to: &Jid)` - Send typing indicator * `send_recording(to: &Jid)` - Send recording indicator * `send_paused(to: &Jid)` - Send paused/stopped typing indicator **Example:** ```rust theme={null} // Send typing indicator client.chatstate().send_composing(&jid).await?; // Send recording indicator client.chatstate().send_recording(&jid).await?; // Stop typing indicator client.chatstate().send_paused(&jid).await?; ``` ### contacts ```rust theme={null} pub fn contacts(&self) -> Contacts<'_> ``` Access contact operations. **Methods:** * `is_on_whatsapp(jids: &[Jid])` - Check if JIDs are registered on WhatsApp (supports PN and LID JIDs) * `get_user_info(jids: &[Jid])` - Get profile info for users by JID * `get_profile_picture(jid: &Jid, preview: bool)` - Get profile picture URL (preview or full size) ### tc\_token ```rust theme={null} pub fn tc_token(&self) -> TcToken<'_> ``` Access trust/privacy token operations. **Methods:** * `issue_tokens(jids: &[Jid])` - Request tokens for contacts * `prune_expired()` - Remove expired tokens * `get(jid: &str)` - Get a stored token by JID * `get_all_jids()` - List all JIDs with stored tokens ### chat\_actions ```rust theme={null} pub fn chat_actions(&self) -> ChatActions<'_> ``` Access chat management actions. Operations sync across all linked devices via app state sync. **Methods:** * `archive_chat(jid: &Jid, message_range: Option)` - Archive a chat * `unarchive_chat(jid: &Jid, message_range: Option)` - Unarchive a chat * `pin_chat(jid: &Jid)` - Pin a chat * `unpin_chat(jid: &Jid)` - Unpin a chat * `mute_chat(jid: &Jid)` - Mute a chat indefinitely * `mute_chat_until(jid: &Jid, mute_end_timestamp_ms: i64)` - Mute until a specific time * `unmute_chat(jid: &Jid)` - Unmute a chat * `star_message(chat_jid: &Jid, participant_jid: Option<&Jid>, message_id: &str, from_me: bool)` - Star a message * `unstar_message(chat_jid: &Jid, participant_jid: Option<&Jid>, message_id: &str, from_me: bool)` - Unstar a message * `mark_chat_as_read(jid: &Jid, read: bool, message_range: Option)` - Mark a chat as read or unread across devices * `delete_chat(jid: &Jid, delete_media: bool, message_range: Option)` - Delete a chat from all linked devices * `delete_message_for_me(chat_jid: &Jid, participant_jid: Option<&Jid>, message_id: &str, from_me: bool, delete_media: bool, message_timestamp: Option)` - Delete a message locally (not for the other party) ### quick\_replies ```rust theme={null} pub fn quick_replies(&self) -> QuickReplies<'_> ``` Access WhatsApp Business quick reply operations. Operations sync across all linked devices via app state sync. **Methods:** * `set_quick_reply(id: &str, shortcut: &str, message: &str, keywords: Vec, count: i32)` - Create or update a quick reply * `delete_quick_reply(id: &str)` - Delete a quick reply See [Quick Replies API](/api/quick-replies) for full documentation. ### app\_state\_settings ```rust theme={null} pub fn app_state_settings(&self) -> AppStateSettings<'_> ``` Access account-wide settings synced via app state rather than the `set_privacy` IQ namespace. **Methods:** * `set_link_previews_disabled(disabled: bool)` - Turn outgoing link previews off or on for the whole account See [Privacy API — App-state settings](/api/privacy#app-state-settings-syncd) for full documentation. ### status ```rust theme={null} pub fn status(&self) -> Status<'_> ``` Access status/story operations. **Methods:** * `send_text(text, background_argb, font, recipients, options)` - Post a text status * `send_image(upload, thumbnail, caption, recipients, options)` - Post an image status * `send_video(upload, thumbnail, duration_seconds, caption, recipients, options)` - Post a video status * `send_raw(message, recipients, options)` - Post any message type as a status * `revoke(message_id, recipients, options)` - Delete a posted status * `send_reaction(status_owner, server_id, reaction)` - React to a status update See [Status API](/api/status) for full documentation. ### mex ```rust theme={null} pub fn mex(&self) -> Mex<'_> ``` Access Meta Exchange (GraphQL) operations. **Methods:** * `query(request: MexRequest)` - Execute a GraphQL query * `mutate(request: MexRequest)` - Execute a GraphQL mutation * `fetch_new_chat_message_capping_info()` - Fetch the new-chat message cap for the current cycle See [MEX API](/api/mex) for full documentation. ### profile ```rust theme={null} pub fn profile(&self) -> Profile<'_> ``` Access profile operations. **Methods:** * `set_push_name(name: &str)` - Set display name (syncs across devices) * `set_status_text(text: &str)` - Set profile "About" text * `set_profile_picture(image_data: Vec)` - Set profile picture (JPEG, 640x640 recommended) * `remove_profile_picture()` - Remove profile picture ### newsletter ```rust theme={null} pub fn newsletter(&self) -> Newsletter<'_> ``` Access newsletter (channel) operations. **Methods:** * `list_subscribed()` - List all subscribed newsletters * `get_metadata(jid: &Jid)` - Get newsletter metadata * `get_metadata_by_invite(invite: &str)` - Get metadata via invite link * `create(name, description)` - Create a new newsletter * `join(jid: &Jid)` - Join a newsletter * `leave(jid: &Jid)` - Leave a newsletter * `update(jid, options)` - Update newsletter settings * `send_reaction(jid, msg_server_id, reaction)` - React to a newsletter message * `get_messages(jid, count, before)` - Fetch newsletter messages * `subscribe_live_updates(jid: &Jid)` - Subscribe to real-time updates Newsletter message sending is handled by the unified [`client.send_message()`](#send_message) method — pass a newsletter JID and the message is sent as plaintext automatically. See the [Send API](/api/send#send_message) for details. **Example:** ```rust theme={null} let newsletters = client.newsletter().list_subscribed().await?; let metadata = client.newsletter().get_metadata(&newsletter_jid).await?; ``` See [Newsletter API](/api/newsletter) for full documentation. ### community ```rust theme={null} pub fn community(&self) -> Community<'_> ``` Access community operations. **Methods:** * `create(options: CreateCommunityOptions)` - Create a community * `deactivate(jid: &Jid)` - Deactivate a community * `link_subgroups(jid: &Jid, subgroups: &[Jid])` - Link groups to a community * `unlink_subgroups(jid: &Jid, subgroups: &[Jid], remove_orphan_members: bool)` - Unlink groups from a community * `get_subgroups(jid: &Jid)` - List community subgroups * `get_subgroup_participant_counts(jid: &Jid)` - Get participant counts per subgroup * `query_linked_group(community_jid: &Jid, subgroup_jid: &Jid)` - Query a linked group's community metadata * `join_subgroup(community_jid: &Jid, subgroup_jid: &Jid)` - Join a community subgroup * `get_linked_groups_participants(jid: &Jid)` - Get participants across linked groups **Example:** ```rust theme={null} let subgroups = client.community().get_subgroups(&community_jid).await?; ``` See [Community API](/api/community) for full documentation. ### polls ```rust theme={null} pub fn polls(&self) -> Polls<'_> ``` Access poll operations. **Methods:** * `create(to: &Jid, name: &str, options: &[String], selectable_count: u32)` - Create a poll (returns message ID and secret) * `vote(chat_jid, poll_msg_id, poll_creator_jid, message_secret, option_names)` - Cast a vote on a poll * `decrypt_vote(enc_payload, enc_iv, message_secret, poll_msg_id, poll_creator_jid, voter_jid)` - Decrypt a vote (static method) * `aggregate_votes(poll_options, votes, message_secret, poll_msg_id, poll_creator_jid)` - Tally all votes (static method) **Example:** ```rust theme={null} let options = vec!["Yes".to_string(), "No".to_string()]; let (msg_id, secret) = client.polls().create(&chat_jid, "Agree?", &options, 1).await?; ``` See [Polls API](/api/polls) for full documentation. ### media\_reupload ```rust theme={null} pub fn media_reupload(&self) -> MediaReupload<'_> ``` Access media reupload operations. Use this when a media download fails because the URL has expired. **Methods:** * `request(req: &MediaReuploadRequest)` - Request the server to re-upload expired media **Example:** ```rust theme={null} let result = client.media_reupload().request(&MediaReuploadRequest { msg_id: "ABCD1234", chat_jid: &chat_jid, media_key: &media_key_bytes, is_from_me: false, participant: Some(&sender_jid), }).await?; ``` The request sends a `server-error` receipt and waits up to 30 seconds for a `mediaretry` notification with an updated download path. ### signal ```rust theme={null} pub fn signal(&self) -> Signal<'_> ``` Access low-level Signal protocol operations for direct encryption, decryption, and session management. **Methods:** * `encrypt_message(jid: &Jid, plaintext: &[u8])` - Encrypt plaintext for a single recipient * `decrypt_message(jid: &Jid, enc_type: EncType, ciphertext: &[u8])` - Decrypt a Signal protocol message * `encrypt_group_message(group_jid: &Jid, plaintext: &[u8])` - Encrypt plaintext for a group using sender keys * `decrypt_group_message(group_jid: &Jid, sender_jid: &Jid, ciphertext: &[u8])` - Decrypt a group message * `validate_session(jid: &Jid)` - Check whether a Signal session exists * `delete_sessions(jids: &[Jid])` - Delete Signal sessions and identity keys * `create_participant_nodes(recipient_jids: &[Jid], message: &Message)` - Create encrypted participant nodes * `assert_sessions(jids: &[Jid])` - Ensure E2E sessions exist * `get_user_devices(jids: &[Jid])` - Get all device JIDs for users **Example:** ```rust theme={null} // Check if a session exists, then encrypt let has_session = client.signal().validate_session(&jid).await?; if !has_session { client.signal().assert_sessions(&[jid.clone()]).await?; } let (enc_type, ciphertext) = client.signal().encrypt_message(&jid, plaintext).await?; ``` These are low-level APIs that bypass the high-level message sending pipeline. Most users should use [`send_message()`](#send_message) which handles encryption automatically. See [Signal API](/api/signal) for full documentation. ### `query_usync` ```rust theme={null} pub async fn query_usync(&self, query: UsyncQuery) -> Result ``` Executes a typed USync ("user sync") query directly — the same protocol engine that powers [`contacts()`](#contacts) and [`signal().get_user_devices()`](#signal) under the hood. Use it for protocol combinations not covered by a specialized helper (bot profile lookup, username resolution, `disappearing_mode`/`text_status`, feature flags). This is a neutral operation: it only returns decoded wire data, with no cache or persistence side effects. See [USync API](/api/usync) for the full `UsyncQuery`/`UsyncResponse` model and examples. *** ## Public fields ### http\_client ```rust theme={null} pub http_client: Arc ``` The HTTP client used for media operations, version fetching, and other HTTP requests. This field is public and can be used directly for custom HTTP operations that share the same client configuration. ### enable\_auto\_reconnect ```rust theme={null} pub enable_auto_reconnect: Arc ``` Controls whether the client automatically reconnects after an unexpected disconnection. Defaults to `true`. Set to `false` to disable auto-reconnect. ### custom\_enc\_handlers ```rust theme={null} pub custom_enc_handlers: std::sync::OnceLock>> ``` Custom handlers for encrypted message types. Set once at `Bot::build` and immutable afterward; read lock-free via `.get()`. Register handlers exclusively through `BotBuilder::with_enc_handler()` — direct mutation after build is not possible. ### RECONNECT\_BACKOFF\_STEP ```rust theme={null} pub const RECONNECT_BACKOFF_STEP: u32 = 4; ``` The number of Fibonacci steps added to the backoff counter when `reconnect()` is called, creating an approximately 5-second offline window before the next connection attempt. This prevents tight reconnect loops after intentional disconnects. *** ## Client Profile The noise-handshake `ClientPayload.UserAgent` identity that this client presents to WhatsApp servers. The default is [`ClientProfile::web()`](/concepts/authentication#clientprofile), which matches the legacy desktop-web payload (platform `Web`, device `Desktop`, OS version `0.1.0`, and an attached `web_info` field). This is independent of `DeviceProps` — `device_props` controls what is reported during companion registration (e.g., the entry shown under **Linked Devices** on the phone), while `ClientProfile` controls the user agent fields used during the Noise handshake on every connect. ### set\_client\_profile ```rust theme={null} pub async fn set_client_profile(&self, profile: wacore::client_profile::ClientProfile) ``` Sets the noise-handshake `ClientPayload` profile. The profile is held in-memory only (`#[serde(skip)]` on `Device.client_profile`), so you must call this before each `connect()` on a fresh process. The profile to apply. Use the constructors on [`ClientProfile`](/concepts/authentication#clientprofile) — `web()`, `android(os_version)`, `smb_android(os_version)`, `ios(os_version)`, `macos(os_version)`, `windows(os_version)`. **Example:** ```rust theme={null} use whatsapp_rust::ClientProfile; // Present as Android 13 on the next connect client.set_client_profile(ClientProfile::android("13")).await; client.connect().await?.read_until_disconnected().await; ``` Native profiles (`android`, `smb_android`, `ios`, `macos`, `windows`) automatically omit `web_info` from the `ClientPayload`. Only `web()` includes it. *** ## Device State ### push\_name ```rust theme={null} pub fn push_name(&self) -> String ``` Returns the current push name (display name). Renamed from `get_push_name` — the `get_` prefix was dropped to match the neighboring accessors. ### pn ```rust theme={null} pub fn pn(&self) -> Option ``` Returns the phone number JID, or `None` before pairing completes. Renamed from `get_pn`. ### lid ```rust theme={null} pub fn lid(&self) -> Option ``` Returns the LID (Linked Identity), or `None` before pairing completes. Renamed from `get_lid`. ### is\_lid\_migrated ```rust theme={null} pub async fn is_lid_migrated(&self) -> bool ``` Whether the account is 1:1-LID-migrated on WhatsApp's servers. This gates outbound DM wire addressing (the stanza `to`/`` namespace) — an unmigrated account keeps DMs on PN even when a LID mapping is cached, since the server rejects LID-addressed DMs from unmigrated accounts with `ack error="400"` ([#941](https://github.com/oxidezap/whatsapp-rust/issues/941)). Signal session addressing is unaffected either way. Returns `true` if the persisted `Device.lid_migrated` flag is set, or (as a fallback for accounts paired before the flag existed) if the `lid_one_on_one_migration_enabled` ab prop is currently enabled. See [Signal Protocol — DM wire namespace vs. Signal session addressing](/advanced/signal-protocol#dm-wire-namespace-vs-signal-session-addressing) and [Authentication — one-to-one LID migration state](/concepts/authentication#one-to-one-lid-migration-state). This is normally handled automatically by the send path — you don't need to call it yourself before sending. It's exposed for diagnostics/telemetry. ### get\_lid\_pn\_entry ```rust theme={null} pub async fn get_lid_pn_entry(&self, jid: &Jid) -> Option ``` Unified LID-PN lookup that auto-routes based on the JID type. Pass a phone number JID (`@s.whatsapp.net`) to look up its LID, or a LID JID (`@lid`) to look up its phone number. Returns `None` for non-user JIDs (groups, newsletters, etc.) or if no mapping is cached. The JID to look up — either a PN JID or a LID JID Contains `lid` (`Arc`), `phone_number` (`Arc`), `created_at` (i64 Unix timestamp), and `learning_source` ([`LearningSource`](#learningsource)). Use `&*entry.lid` for `&str` comparisons or pass directly to anything that accepts `AsRef`. **Example:** ```rust theme={null} use wacore_binary::jid::Jid; // Look up by phone number JID let pn_jid: Jid = "15551234567@s.whatsapp.net".parse()?; if let Some(entry) = client.get_lid_pn_entry(&pn_jid).await { println!("LID: {}", entry.lid); println!("Phone: {}", entry.phone_number); } // Look up by LID JID let lid_jid: Jid = "100000012345678@lid".parse()?; if let Some(entry) = client.get_lid_pn_entry(&lid_jid).await { println!("Phone: {}", entry.phone_number); } ``` This replaces the previous `get_phone_number_from_lid` method. The new API accepts a full `Jid` instead of a raw string and supports bidirectional lookup — pass either a PN or LID JID to resolve the mapping in either direction. #### LearningSource The `LearningSource` enum indicates how a LID-PN mapping was discovered. The source is not mere provenance — it also selects the **write policy** applied when the pair reaches the cache, mirroring WhatsApp Web's `createLidPnMappings` (`WAWebDBCreateLidPnMappings`) `switch (learningSource)`: * **Directed sources** (`Usync`, `PeerPnMessage`, `PeerLidMessage`, `RecipientLatestLid`, `MigrationSyncLatest`, `MigrationSyncOld`, `BlocklistActive`, `BlocklistInactive`) overwrite the cache on any change from what's already stored. * **Observational bulk sources** (`Other`, `Pairing`, `DeviceNotification`) only seed a LID that isn't cached yet. If the pair conflicts with an already-known LID for that phone, the observational pair is **not** applied — the client instead fires one background live LID query (`LidQuerySpec`) and learns the authoritative result under `Usync`, which can never itself trigger another reconcile. * **Known-stale sources** (`MigrationSyncOld`, `BlocklistInactive`) are additionally stamped with `created_at = 0`, so a fresher mapping for the same phone always outranks them in the cache's most-recent-wins (PN→LID) resolution. This only guards the forward direction — the LID→PN reverse map always takes the latest write. | Variant | Description | Write policy | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | `Usync` | From a device sync query response | Directed — overwrites on conflict | | `PeerPnMessage` | From an incoming message with `sender_lid` attribute (sender is PN) | Directed — overwrites on conflict | | `PeerLidMessage` | From an incoming message with `sender_pn` attribute (sender is LID) | Directed — overwrites on conflict | | `RecipientLatestLid` | From looking up a recipient's latest LID | Directed — overwrites on conflict | | `MigrationSyncLatest` | From the live 1:1 LID migration flow | Directed — overwrites on conflict | | `MigrationSyncOld` | From old history sync records | Directed — overwrites on conflict; `created_at = 0` (stale) | | `BlocklistActive` | From an active blocklist entry | Directed — overwrites on conflict | | `BlocklistInactive` | From an inactive blocklist entry | Directed — overwrites on conflict; `created_at = 0` (stale) | | `Pairing` | From device pairing (own JID to LID) | Observational — seeds new LIDs only | | `DeviceNotification` | From a device notification with `lid` attribute | Observational — seeds new LIDs only | | `Other` | From an unknown/bulk source (e.g. history sync's PN/LID harvest — the bulk `phoneNumberToLidMappings` block and each conversation's own `pnJid`/`lidJid` fields) | Observational — seeds new LIDs only | A pair that already matches the cache's current mapping always re-affirms durability regardless of source — it is never treated as a conflict. This includes an exact match, and also a reverse-only match: the LID-PN cache is capacity-bounded (see [`lid_pn_cache`](/api/bot#cache-configuration-reference)), so the PN→LID entry can be evicted while the LID→PN entry survives, and a re-learn of that surviving pair still counts as self-consistent. History sync's own harvesting logic collapses its two mapping sources to one pair per LID before either reaches this policy. The bulk block outranks a conversation-derived pair naming the same LID or phone number. Ties within one source keep the first occurrence. See [PN/LID mapping cache — LID-PN mappings](/concepts/storage#lid-pn-mappings) ([#1345](https://github.com/oxidezap/whatsapp-rust/pull/1345)). #### Refresh on `refresh_lid` acks A send-message ack can carry `refresh_lid="true"`, the server's way of telling the client that the LID-PN mapping it holds for the acking peer is stale. It's the only invalidation the client gets for this mapping: lookups are cache-aside, so nothing else re-checks an entry just because it might have gone stale. Without acting on the flag, a stale mapping would stay stale for the process lifetime, and every Signal address derived from it would keep resolving to the wrong identity ([#1313](https://github.com/oxidezap/whatsapp-rust/pull/1313)). On a flagged ack, the client re-resolves the peer with the same authoritative live query an observational `LearningSource` conflict uses: `LidQuerySpec`, requesting only ``. It persists the corrected pair through `add_lid_pn_mapping` under `LearningSource::Usync`. A few details worth knowing if you're watching this happen: * The query is addressed by **phone number**, not by the LID under suspicion. Asking about the LID itself would look the answer up under the very key that might be wrong. A peer with no cached mapping yet has nothing stale to correct, so it triggers no query. * Hosted peers (`@hosted`/`@hosted.lid`) are refreshed the same way as `@lid`/`@s.whatsapp.net` peers, since they resolve their Signal address from the same cache. * A burst of sends to one stale peer acks one message at a time, and every ack repeats the flag. Refreshes for the same number are coalesced, so a second flagged ack while one is already in flight does not start a duplicate query. The coalescing key is scoped to the connection that started the refresh, so a task still parked on a dead socket's IQ can never suppress the next connection's refresh for the same peer. * In-flight refresh reservations are visible via [`pending_lid_refreshes`](#memory_report) in `memory_report()`. ### persistence\_manager ```rust theme={null} pub fn persistence_manager(&self) -> Arc ``` Access to the persistence manager for multi-account scenarios. *** ## History Sync History sync transfers chat history from the phone to the linked device. The client processes history sync notifications through a RAM-optimized pipeline that minimizes peak memory usage. ### Processing pipeline When a history sync notification arrives, the client: 1. Sends a `HistorySync` receipt immediately (so the phone knows delivery succeeded) 2. Retrieves the data — either from an inline payload (moved via `.take()`, not cloned) or by stream-decrypting an external blob in 8KB chunks 3. Extracts a `compressed_size_hint` from the notification's `file_length` field, which the decompressor uses with a 4x multiplier for better buffer pre-allocation (avoids repeated `Vec` reallocation) 4. Runs decompression and protobuf parsing on a blocking thread (`tokio::task::spawn_blocking`) to avoid stalling the async runtime 5. Wraps the decompressed blob in a [`LazyHistorySync`](/concepts/events#lazyhistorysync) with cheap metadata (sync type, chunk order, progress) and dispatches it as `Event::HistorySync(Box)`. Full protobuf decoding is deferred until the event handler calls `.get()` If no event handlers are registered, the blob is not retained — only internal data (pushname, NCT salt, TC tokens) is extracted. ### process\_sync\_task ```rust theme={null} pub async fn process_sync_task(self: &Arc, task: MajorSyncTask) ``` Processes a `MajorSyncTask` received from the sync channel returned by `Client::new`. This is the public entry point for handling history sync and app state sync tasks. The method dispatches to the appropriate internal handler based on the task variant: * `MajorSyncTask::HistorySync` — downloads and processes history sync data * `MajorSyncTask::AppStateSync` — synchronizes app state (contacts, mutes, pins, etc.) **Example:** ```rust theme={null} let (client, mut sync_receiver) = Client::new(/* ... */).await; // Process sync tasks from the channel tokio::spawn({ let client = client.clone(); async move { while let Ok(task) = sync_receiver.recv().await { client.process_sync_task(task).await; } } }); ``` If you use the [`Bot`](/api/bot) builder, sync task processing is handled automatically. You only need this method when building a custom client setup. ### set\_skip\_history\_sync ```rust theme={null} pub fn set_skip_history_sync(&self, enabled: bool) ``` Enable or disable skipping of history sync notifications at runtime. When skipping is enabled, the client sends a receipt (so the phone stops retrying uploads) but does not download or process any data. ### skip\_history\_sync\_enabled ```rust theme={null} pub fn skip_history_sync_enabled(&self) -> bool ``` Returns `true` if history sync is currently being skipped. ### set\_wanted\_pre\_key\_count ```rust theme={null} pub fn set_wanted_pre_key_count(&self, count: usize) ``` Sets the number of one-time pre-keys generated and uploaded per batch. Mirrors WhatsApp Web's `UPLOAD_KEYS_COUNT`. Default: `812`. Intended for consumers that construct `Client` directly (rather than via `Bot::builder().with_wanted_pre_key_count(...)`). Set this before calling `connect()`. The value is clamped at upload time to `5..=65_535`; out-of-range values log a `warn!`. Pre-keys per upload batch. Clamped to `5..=65_535`. **Example:** ```rust theme={null} // On a memory-constrained host, upload smaller batches client.set_wanted_pre_key_count(256); client.connect().await?.read_until_disconnected().await; ``` The floor of 5 prevents an empty-but-flagged pool and a re-upload loop. The ceiling of 65,535 matches the upload IQ's `u16` list-length encoding — larger batches would generate keys locally and then fail to encode. ### wanted\_pre\_key\_count ```rust theme={null} pub fn wanted_pre_key_count(&self) -> usize ``` Returns the currently configured pre-key upload batch size. ### set\_force\_active\_delivery\_receipts ```rust theme={null} pub fn set_force_active_delivery_receipts(&self, active: bool) ``` Force the client to send active delivery receipts (matching the recipient pattern WA Web uses for foreground chats) regardless of the local `delivery_receipt_active` setting. v0.6 added this knob so consumers can opt every incoming message into active receipts during a known foreground session. When `active` is `true`, the client emits `` stanzas without the silent flag for every successful decrypt. When `false` (default), behavior follows the existing per-chat heuristic. The setting is mirrored across offline-resume so the post-resume ack pattern matches the live one. ### send\_history\_sync\_server\_error\_receipt ```rust theme={null} pub async fn send_history_sync_server_error_receipt( &self, message_id: &str, media_key: &[u8], ) -> Result<()> ``` Ask the phone to re-upload a history-sync blob whose download failed (corrupt body, mismatched HMAC, missing CDN file, etc.). The client emits a `` to the companion device carrying an encrypted retry payload, mirroring WA Web's `WAWebSendHistSyncServerErrorReceiptJob`. **Parameters:** * `message_id` — the `MessageInfo::id` of the failed history-sync notification * `media_key` — the 32-byte key carried by the original `` element **When to call:** Typically inside your `Event::HistorySync` (or upstream download) error path, once you've determined the blob can't be recovered locally. The phone will then retry the upload, producing a fresh `HistorySync` notification. ```rust theme={null} if let Err(err) = download_history_blob(&lazy_sync).await { tracing::warn!(?err, "history sync download failed; asking phone to retry"); client .send_history_sync_server_error_receipt(&info.id, &media_key) .await?; } ``` *** ## Offline sync The client automatically manages offline message sync when reconnecting. During sync, message processing is restricted to sequential mode (1 concurrent task) to preserve ordering. ### Semaphore transition safety When offline sync completes, the concurrency semaphore is swapped from 1 permit to 64 permits. Tasks that were already waiting on the old semaphore use a **generation-checked re-acquire loop** to safely transition — they detect the swap via an atomic generation counter, drop the stale permit, and re-acquire from the new semaphore. This prevents `pkmsg` messages (which carry SKDM for group decryption) from being silently dropped during the transition. See [Concurrency gating](/concepts/architecture#offline-sync) for details. ### Stall timeout If the server advertises offline messages but stops sending stanzas before the end marker arrives, while the connection itself stays up, an inactivity watchdog forces completion instead of leaving the drain open indefinitely (added in PR #1380, mirroring WhatsApp Web's own stall timer). The watchdog re-arms on every offline stanza, so it fires somewhere between 60 and 120 seconds after the last one, not at a fixed 60 seconds. On expiry: 1. A warning is logged with the number of processed vs. expected items 2. Offline sync is marked complete 3. `OfflineSyncCompleted` event is emitted 4. Message processing switches from sequential to parallel (64 concurrent tasks) ### Interrupted resume If the connection ends before the drain finishes — rather than the end marker arriving or the stall timer firing — the resume is never left silent. It is reported once as `OfflineSyncInterrupted { total, delivered }` instead of `OfflineSyncCompleted` (added in PR #1380). The event doesn't by itself say what gets redelivered — that follows the pre-existing commit-batch ack contract; see [Inbound Durability → Batching](/advanced/inbound-durability#batching) for exactly which batch a mid-drain disconnect does and doesn't redeliver the next time the client reconnects and gets a fresh `OfflineSyncPreview`. ### State reset on reconnect All offline sync state (counters, timing, concurrency semaphore) is fully reset on reconnect so stale state does not carry over to the next connection — reported first as an interrupted resume, above, if a drain was still active. **Related events:** [`OfflineSyncPreview`](/concepts/events#offlinesyncpreview), [`OfflineSyncCompleted`](/concepts/events#offlinesynccompleted), [`OfflineSyncInterrupted`](/concepts/events#offlinesyncinterrupted) *** ## App State ### fetch\_props ```rust theme={null} pub async fn fetch_props(&self) -> Result<(), IqError> ``` Fetches A/B experiment properties from WhatsApp servers and updates the in-memory `AbPropsCache`. When a stored props hash exists **and** the cache has been seeded (at least one full fetch has occurred), the request includes the hash for a delta update — the server only returns changed props. Otherwise, a full fetch is performed and all cached props are replaced. After the response is applied to the cache, the new hash (if present) is persisted for future delta requests. Features like group privacy token attachment query the `AbPropsCache` to check whether specific experiment flags are enabled. See [AB props cache](#ab-props-cache) for details. ### AB props cache The client maintains an in-memory `AbPropsCache` that stores server-side A/B experiment properties. The cache is populated each time `fetch_props()` runs (automatically on connect) and is **not persisted** — props are re-fetched on every connection. Features query the cache by passing a typed `AbProp` constant from the vendored [`wacore::iq::abprops`](/api/wacore#a-b-props-registry) registry. A bool prop is considered enabled when its value is `"1"`, `"true"`, or `"enabled"` (case-insensitive), falling back to the registry default when the server didn't send it. ```rust theme={null} use wacore::iq::abprops::web; if client.ab_props().is_enabled(web::PRIVACY_TOKEN_SENDING_ON_GROUP_CREATE).await { // gated behavior } let bucket_duration = client .ab_props() .get_int(web::TCTOKEN_DURATION) .await; ``` The cache exposes: | Method | Purpose | | ------------------ | ----------------------------------------------------------------- | | `is_enabled(prop)` | Truthy check for bool flags, falling back to the registry default | | `get(prop)` | Raw `Option` value if the server sent it | | `get_int(prop)` | Parsed `i64` for int flags, falling back to the registry default | | `is_seeded()` | `true` after the first full (non-delta) fetch has been applied | #### Watching additional flags Only flags in the cache's *interest set* are retained when props come in — every other server prop is discarded to avoid allocating for the \~2,000+ flags WhatsApp ships. The interest set is pre-seeded with the flags the library itself reads (see `wacore::iq::props::WATCHED`). If you need to gate your own code on a flag the library doesn't already watch, register it before the first `fetch_props()`: ```rust theme={null} use wacore::iq::abprops::web; client.ab_props().watch(web::ADMIN_REVOKE_RECEIVER).await; // or in bulk: client.ab_props().watch_many(&[ web::ADD_MEMBER_SYSTEM_MESSAGE, web::ADMIN_ONLY_MENTION_EVERYONE_GROUP_SIZE, ]).await; ``` Flags retain their `code`, `value_type`, and `default` straight from the WA Web bundle, so behavior tracks WhatsApp Web without hand-maintained config tables. The AB props cache is internal to the client. You don't need to interact with it directly — the library automatically checks relevant flags when performing group operations like `create_group` and `add_participants`. #### Disabling the fetch [`ClientBuilder::with_ab_props_fetch(bool)`](/api/bot#with_ab_props_fetch) controls whether `fetch_props()` runs at all on connect; the default is `true`. The catalog fetch is now streamed (see [`execute_streaming`](#execute_streaming)), so on a host it costs nothing worth turning off. The switch exists for a target whose whole heap can't afford even the compressed frame plus the inflate state at the moment it arrives. Turned off, no `abt` request is sent — whatsmeow never sends one either, and the server accepts both. Every flag then reads as its registry default: an account the server has 1:1-LID-migrated is not recognized as such, and the privacy-token and trusted-contact-token gates run on their defaults instead of the server's values. ### fetch\_privacy\_settings ```rust theme={null} pub async fn fetch_privacy_settings(&self) -> Result ``` Fetches privacy settings (last seen, profile photo, about, etc.). See [Privacy API](/api/privacy) for types and details. ### set\_privacy\_setting ```rust theme={null} pub async fn set_privacy_setting( &self, category: PrivacyCategory, value: PrivacyValue, ) -> Result ``` Sets a privacy setting for a specific category using type-safe enums. Privacy category enum: `Last`, `Online`, `Profile`, `Status`, `GroupAdd`, `ReadReceipts`, `CallAdd`, `Messages`, or `DefenseMode` Privacy value enum: `All`, `Contacts`, `None`, `ContactBlacklist`, `MatchLastSeen`, `Known`, `Off`, or `OnStandard` **Example:** ```rust theme={null} use whatsapp_rust::privacy_settings::{PrivacyCategory, PrivacyValue}; // Hide last seen from everyone client.set_privacy_setting(PrivacyCategory::Last, PrivacyValue::None).await?; // Show profile photo only to contacts client.set_privacy_setting(PrivacyCategory::Profile, PrivacyValue::Contacts).await?; ``` See [Privacy API](/api/privacy) for all categories, values, and valid combinations. ### set\_privacy\_disallowed\_list ```rust theme={null} pub async fn set_privacy_disallowed_list( &self, category: PrivacyCategory, update: DisallowedListUpdate, ) -> Result ``` Updates a privacy category's disallowed list (contacts-except-specific-users mode). Only available for `Last`, `Profile`, `Status`, and `GroupAdd`. See [Privacy API](/api/privacy#set_privacy_disallowed_list) for details and examples. ### set\_default\_disappearing\_mode ```rust theme={null} pub async fn set_default_disappearing_mode( &self, duration: u32, ) -> Result<(), IqError> ``` Sets the default disappearing messages duration for new chats. Timer duration in seconds. Common values: `86400` (24 hours), `604800` (7 days), `7776000` (90 days). Pass `0` to disable. **Example:** ```rust theme={null} // Enable 7-day default disappearing messages client.set_default_disappearing_mode(604800).await?; // Disable default disappearing messages client.set_default_disappearing_mode(0).await?; ``` ### get\_business\_profile ```rust theme={null} pub async fn get_business_profile( &self, jid: &Jid, ) -> Result, IqError> ``` Fetches the business profile for a WhatsApp Business account. Returns `None` if the account is not a business account or has no business profile. JID of the business account to query **Example:** ```rust theme={null} let jid: Jid = "15551234567@s.whatsapp.net".parse()?; if let Some(profile) = client.get_business_profile(&jid).await? { println!("Description: {}", profile.description); println!("Email: {:?}", profile.email); println!("Website: {:?}", profile.website); println!("Address: {:?}", profile.address); for category in &profile.categories { println!("Category: {} ({})", category.name, category.id); } } else { println!("Not a business account"); } ``` See [Business API](/api/business) for full type details. ### business ```rust theme={null} pub fn business(&self) -> Business<'_> ``` Access business catalog, collections, order lookup, and business-profile writes. Reading a business's profile stays on `get_business_profile` above — `business()` covers everything else. **Methods:** * `get_catalog(jid: &Jid, options: &CatalogOptions)` - Fetch one page of a business's product catalog (MEX) * `get_collections(jid: &Jid, options: &CollectionOptions)` - Fetch one page of a business's collections, products inline (MEX) * `get_order(jid: &Jid, order_id: &str, token: &str)` - Look up an order's line items and totals (MEX) * `update_profile(update: &BusinessProfileUpdate)` - Apply a delta to your own business profile (IQ) * `set_cover_photo(upload: CoverPhotoUpload)` - Point the profile at an already-uploaded cover photo (IQ) * `remove_cover_photo(id: &str)` - Remove the profile's cover photo (IQ) **Example:** ```rust theme={null} let catalog = client.business().get_catalog(&jid, &CatalogOptions::default()).await?; ``` See [Business API](/api/business) for full documentation. ### clean\_dirty\_bits ```rust theme={null} pub async fn clean_dirty_bits( &self, bit: wacore::iq::dirty::DirtyBit ) -> Result<(), IqError> ``` Cleans app state dirty bits. The `DirtyBit` struct contains a `dirty_type` (e.g., `AccountSync`, `Groups`, `SyncdAppState`, `NewsletterMetadata`) and an optional `timestamp`. ### request\_syncd\_snapshot\_recovery ```rust theme={null} pub async fn request_syncd_snapshot_recovery( self: &Arc, collection: &str, ) -> Result ``` Asks the primary device to resend an app-state collection whose snapshot failed local MAC verification here — the escalation the client also triggers automatically on a `SnapshotMACMismatch`/`SnapshotMACMissing` apply failure. See [Peer snapshot recovery](/concepts/architecture#peer-snapshot-recovery) for the full mechanism, including why this is safe against a collection whose value encryption can't be followed. Fire-and-forget: the primary's reply arrives later as an ordinary peer message and is applied then, dispatching mutations through the same events an ordinary sync uses. On success returns the request id the reply will be correlated by (or an empty string if a request for this collection was already outstanding); an `Err` means the ask itself was refused or couldn't be sent — not that the primary declined to answer. App-state collection name (e.g. `"regular"`, `"regular_low"`). Refused with an `Err` for `"critical_block"` (never recovered this way — rebuilding the block list from a possibly-behind primary risks messaging someone who was blocked) and for any name this client doesn't recognize. Also refused when the account has the `enable_peer_snapshot_recovery` ab-prop explicitly turned off. *** ## Protocol Operations ### send\_node ```rust theme={null} pub async fn send_node(&self, node: Node) -> Result<(), ClientError> ``` Sends a raw protocol node (advanced usage). Binary protocol node to send **Errors:** * `ClientError::NotConnected` - Not connected * `ClientError::EncryptSend` - Encryption/send failure ### send\_raw\_bytes ```rust theme={null} pub async fn send_raw_bytes(&self, plaintext: Vec) -> Result<(), ClientError> ``` Send a pre-marshaled stanza through the noise socket. The bytes must be a **packed payload** — the format byte followed by the node bytes — which is what every `wacore_binary::marshal::marshal*` function (`marshal`, `marshal_to_vec`, `marshal_exact`, `marshal_auto`) writes. A stanza that came off the wire isn't already in that shape: `OwnedNodeRef::backing_bytes()` returns node bytes only — the format byte is gone, and if the frame arrived `FORMAT_COMPRESSED` those are the decompressed bytes, not a fixed number of bytes shorter than what was on the wire. Forward it through `wacore_binary::util::pack` first — see [Binary Protocol: the format byte](/advanced/binary-protocol#the-format-byte). A packed payload (format byte + node bytes) as produced by marshal **Errors:** * `ClientError::NotConnected` - Not connected * `ClientError::Socket` - `plaintext` is not a packed payload, wrapping `SocketError::Marshal`: `BinaryError::UnexpectedFormatByte` for a compressed or otherwise unrecognized leading byte (most often node bytes handed in directly instead of packed ones), or `BinaryError::EmptyData` for an empty buffer or a lone format byte with nothing behind it * `ClientError::EncryptSend` - Encryption/send failure As of PR #1259, `plaintext`'s shape — leading byte plus at least one more — is checked before it reaches the socket. That catches an empty buffer, a lone format byte, or node bytes handed in directly (wrong leading byte); it does not decode the node bytes, so a payload with the right shape but a malformed node inside it still reaches the socket, same as before. This bypasses node logging and `wait_for_sent_node` waiter resolution. Use [`send_node`](#send_node) for normal stanza sending. This method is intended for performance-critical paths where you already have marshaled bytes. ### flush\_pending\_signal\_state ```rust theme={null} pub async fn flush_pending_signal_state(&self) -> Result<(), SignalMaintenanceError> ``` As of PR #1090, this returns [`SignalMaintenanceError`](/api/errors#signalmaintenanceerror) instead of `anyhow::Error` (a `Storage` failure keeps the typed backend cause reachable via `source()`). Forces any pending write-behind Signal cache state to the backend, returning once the flush completes (or fails). Ordinarily — without calling this method — the backend trails the in-memory cache. Most sends — DM, group, and status alike — are already covered by a durable lease (`SessionRecord`'s sender-chain counter lease for DMs, `SenderKeyRecord`'s chain iteration lease for group/status), so they only schedule the coalesced write-behind; only the roughly-1-in-64 send that exhausts the current lease flushes synchronously — and because the pre-wire flush check is global, a pending flush on an unrelated session or sender key can force a synchronous flush too. Status reactions are the DM-branch exception and follow the DM lease behavior instead of the group/status one. The live receive path always schedules a coalesced flush, on a \~25ms window (see [flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive)). A successful call to `flush_pending_signal_state()` closes that gap deterministically: everything dirty as of the call is persisted by the time it returns `Ok`. The call has **no hard wall-clock bound** — it can wait on locks, or on slow or failing storage, and a backend outage extends it until the retry loop succeeds. Check the returned `Result`: a failure means the flush did not complete, and state is still pending, not persisted. Never call this from inside an [`InboundDurabilityHook`](/advanced/inbound-durability) — during an offline-sync drain it runs while the processing permit is held, and settling routes through that same permit, so re-entering it would deadlock. The same risk applies to a custom `EventHandler::handle_event` implementation that itself blocks synchronously inline (dispatch is synchronous). It does **not** apply to ordinary [`Bot`](/api/bot) closure handlers (`.on_message()`, etc.) — both the default concurrent and ordered delivery modes run your callback in a detached task that never holds the permit, so calling `flush_pending_signal_state()` from inside one of those is safe. **Example:** ```rust theme={null} // Force durability before reading Signal state directly, or before a // non-graceful shutdown (process kill, container stop). client.flush_pending_signal_state().await?; ``` See [Signal Protocol — flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive) for the full durability model. ### generate\_message\_id ```rust theme={null} pub fn generate_message_id(&self) -> String ``` Generates a unique WhatsApp-protocol-conformant message ID. Combines timestamp, user JID, and random components for uniqueness. This is intended for advanced users who need to build custom protocol interactions or manage message IDs manually. Most users should use `send_message` which handles ID generation automatically. ### send\_iq ```rust theme={null} pub async fn send_iq(&self, query: InfoQuery<'_>) -> Result, IqError> ``` Sends a custom IQ (Info/Query) stanza to the WhatsApp server. IQ query containing stanza type, namespace, content, and optional timeout **Example:** ```rust theme={null} use wacore::request::{InfoQuery, InfoQueryType}; use wacore_binary::builder::NodeBuilder; use wacore_binary::node::NodeContent; use wacore_binary::jid::{Jid, SERVER_JID}; let query_node = NodeBuilder::new("presence") .attr("type", "available") .build(); let server_jid = Jid::new("", SERVER_JID); let query = InfoQuery { query_type: InfoQueryType::Set, namespace: "presence", to: server_jid, target: None, content: Some(NodeContent::Nodes(vec![query_node])), id: None, timeout: None, }; let response = client.send_iq(query).await?; ``` This bypasses higher-level abstractions and safety checks. You should be familiar with the WhatsApp protocol and IQ stanza format before using this. ### execute ```rust theme={null} pub async fn execute(&self, spec: S) -> Result ``` Executes a typed IQ specification. This is the preferred way to send IQ stanzas — each spec type handles building the request and parsing the response. A typed IQ specification that defines the request structure and response parsing **Example:** ```rust theme={null} // Fetch group metadata using a typed spec let metadata = client.execute(GroupQueryIq::new(&group_jid)).await?; ``` ### execute\_streaming ```rust theme={null} pub async fn execute_streaming(&self, spec: S) -> Result where S: IqStreamSpec + Send + 'static, S::Response: Send + 'static, ``` The streaming counterpart to [`execute`](#execute), for a spec whose response is consumed as it is decoded rather than handed over as a tree. The A/B props catalog (`fetch_props`) — a response of a few thousand small children — is what this exists for. The response is walked on the read loop, inside the frame's decode, so it never exists as more than the [inflate window and one child](/advanced/binary-protocol#streaming-decode-nodestream) at a time. An error response, or one that arrives while a raw-node observer is attached, is still decoded whole and goes through the spec's `IqSpec::parse_response` instead — which is why `IqStreamSpec` requires both. A typed IQ specification whose response is consumed from a `NodeStream` as it decodes, rather than parsed from an already-materialized tree **Example:** ```rust theme={null} // Fetch the A/B props catalog, keeping only the codes the cache watches let response = client.execute_streaming(PropsSpec::new().retaining(watched_codes)).await?; ``` ### wait\_for\_node ```rust theme={null} pub fn wait_for_node(&self, filter: NodeFilter) -> oneshot::Receiver> ``` Waits for a specific incoming protocol node matching the given filter. Returns a receiver that resolves when a matching node arrives. Filter specifying which node to wait for (by tag and attributes) **Example:** ```rust theme={null} // Wait for a group notification let waiter = client.wait_for_node( NodeFilter::tag("notification").attr("type", "w:gp2"), ); // Perform the action that triggers the node client.groups().add_participants(&group_jid, &[jid]).await?; // Receive the notification let node = waiter.await.expect("notification arrived"); ``` Register the waiter **before** performing the action that triggers the expected node. When no waiters are active, this has zero cost (single atomic load per incoming node). #### NodeFilter Builder for matching incoming protocol nodes: ```rust theme={null} // Match by tag let filter = NodeFilter::tag("notification"); // Match by tag and attributes let filter = NodeFilter::tag("notification") .attr("type", "w:gp2"); // Match by tag and source JID let filter = NodeFilter::tag("notification") .from_jid(&group_jid); ``` ### wait\_for\_sent\_node ```rust theme={null} pub fn wait_for_sent_node(&self, filter: NodeFilter) -> oneshot::Receiver> ``` Waits for a specific **outgoing** protocol node matching the given filter. Returns a receiver that resolves when a matching node is sent by the client. This is the outbound counterpart to `wait_for_node`. Filter specifying which outgoing node to intercept (by tag and attributes) **Example:** ```rust theme={null} // Assert that a privacy token was attached to an outgoing message stanza let waiter = client.wait_for_sent_node( NodeFilter::tag("message").attr("to", "15551234567@s.whatsapp.net"), ); client.send_message(jid, message).await?; let sent_node = waiter.await.expect("message stanza sent"); // Inspect the stanza to verify tctoken or cstoken was attached ``` Register the waiter **before** performing the action that produces the outgoing node. When no sent-node waiters are active, this has zero cost (single atomic load per outgoing node). Useful for testing whether `` or `` was attached to a sent stanza. ### register\_handler ```rust theme={null} pub fn register_handler(&self, handler: Arc) ``` Registers an event handler for protocol events. Handler implementing the EventHandler trait **Example:** ```rust theme={null} use wacore::types::events::{Event, EventHandler, InboundMessage}; use std::sync::Arc; struct MyHandler; impl EventHandler for MyHandler { fn handle_event(&self, event: Arc) { match &*event { Event::Messages(batch) => { for InboundMessage { message: msg, info, .. } in batch.iter() { println!("New message from {}: {:?}", info.source.sender, msg); } } Event::Connected(_) => println!("Connected!"), _ => {} } } } client.register_handler(Arc::new(MyHandler)); ``` **Using ChannelEventHandler:** For channel-based event processing (useful for testing and custom event loops), use the built-in `ChannelEventHandler`: ```rust theme={null} use wacore::types::events::{ChannelEventHandler, Event}; let (handler, event_rx) = ChannelEventHandler::new(); client.register_handler(handler); // Process events asynchronously via async-channel (runtime-agnostic) // event_rx yields Arc while let Ok(event) = event_rx.recv().await { match &*event { Event::Connected(_) => break, _ => {} } } ``` See [ChannelEventHandler](/concepts/events#channeleventhandler) for details. ### register\_chatstate\_handler ```rust theme={null} pub fn register_chatstate_handler(&self, handler: Arc) ``` Register a handler for chat state events (typing indicators). Pass the handler in an `Arc` so the event dispatcher can share it across threads. Copy-on-write registration lets event dispatch continue while you register another handler. When no handler is registered, the client uses a lock-free fast path. **Breaking change (as of PR #1227):** `register_chatstate_handler` is no longer `async`. Drop the `.await` at call sites: `client.register_chatstate_handler(handler)`. ### set\_raw\_node\_forwarding ```rust theme={null} pub fn set_raw_node_forwarding(&self, enabled: bool) ``` Enable or disable raw node forwarding. When enabled, `Event::RawNode` is emitted for every decoded stanza before the stanza router dispatches it. Disabled by default to avoid overhead. Whether to emit `Event::RawNode` for every incoming stanza **Example:** ```rust theme={null} // Enable raw protocol access (e.g. for voice call stanzas) client.set_raw_node_forwarding(true); // Handle raw nodes in your event handler bot.on_event(|event, _client| async move { if let Event::RawNode(node) = &*event { println!("Raw stanza: {} {:?}", node.tag, node.attrs); } }); ``` Only enable this when you need raw protocol access. Every decoded stanza triggers the event, which adds overhead to the message processing pipeline. ### add\_stanza\_interceptor ```rust theme={null} pub fn add_stanza_interceptor( self: &Arc, interceptor: Arc, ) -> InterceptorHandle ``` Register an interceptor that sees each decoded stanza before the built-in pipeline, and may take it. This is the seam for acting on a stanza this client does not model — `StanzaRouter::register` panics on a duplicate tag, so even an existing tag can't be handled differently any other way — instead of watching it get nacked. The interceptor to register. A plain closure of type `Fn(&OwnedNodeRef) -> Interception` implements `StanzaInterceptor` too, so `client.add_stanza_interceptor(Arc::new(|node: &OwnedNodeRef| { .. }))` works without a named type. RAII token for the registration. Dropping it removes the interceptor. The handle holds only a weak client reference, so a forgotten handle cannot keep the client alive, and dropping one after its client is already gone is a no-op rather than a panic. ```rust theme={null} use wacore::sync_marker::MaybeSendSync; pub trait StanzaInterceptor: MaybeSendSync + 'static { fn intercept(&self, node: &OwnedNodeRef) -> Interception; } pub enum Interception { /// Leave the stanza to the client. The default. Pass, /// The interceptor took the stanza; the built-in pipeline is skipped. Handled, } ``` `MaybeSendSync` is `Send + Sync` on native targets and carries no bounds on `wasm32`, matching the convention used by `EventHandler`, `Transport`, and `HttpClient`. Interceptors run in registration order; the first one to return `Interception::Handled` wins and the rest — including the built-in pipeline — are skipped. Registration order is therefore priority order: an earlier registration can shadow a later one. **What an interceptor never sees:** `success`, `failure`, `stream:error`, and `ack` settle connection state (authentication, shutdown/reconnection, and the waiters a send blocks on), and a server-initiated `` ping is withheld for the same reason — a claimed ping is a pong never sent, and the server drops the connection over it. Offline-sync tracking and response-waiter resolution run before dispatch and keep running whether or not a stanza is claimed. Every other stanza, including `` traffic the client already answers on its own, is offered. **What claiming owes the server:** a claim doesn't change what the server is owed. Where the client would have acked a stanza, it still acks; where it would have nacked a tag it doesn't model, the claim turns that into an ack instead, since something did handle it — answering nothing would leave the stanza in the offline queue. A tag the client models but answers some other way (a delivery `` for a direct ``, an ``) gets nothing from the claimed-stanza path — the claimant owes that reply itself. Cost while unused: one relaxed atomic load on the read loop, checked before any lock. Registering is what turns the check into a walk over the registered interceptors. **Example:** ```rust theme={null} use whatsapp_rust::client::interceptor::{Interception, StanzaInterceptor}; use wacore_binary::node::OwnedNodeRef; use std::sync::Arc; struct Vendor; impl StanzaInterceptor for Vendor { fn intercept(&self, node: &OwnedNodeRef) -> Interception { if node.tag() == "vendor:thing" { // ... act on it ... Interception::Handled } else { Interception::Pass } } } let handle = client.add_stanza_interceptor(Arc::new(Vendor)); // Dropping `handle` removes it. ``` Interception runs before the built-in pipeline — including Signal decryption — so a claimed `` was never decrypted and a claimed prekey-bearing `` never tops up prekeys. The ack that follows tells the server not to redeliver, so that work never happens again. Match narrowly. See the `whatsapp_rust::client::interceptor` module for the full contract, and [Native plugins](/advanced/plugins#stanza-interception) for the capability-gated version available to plugins. ### acquire\_decrypted\_payload\_forwarding ```rust theme={null} pub fn acquire_decrypted_payload_forwarding(self: &Arc) -> DecryptedPayloadLease ``` Acquire a lease that keeps [`Event::DecryptedPayload`](/concepts/events#decryptedpayload) enabled for one consumer. The lease is necessary but not sufficient: a handler that has narrowed its `interest()` away from the default `EventInterest::ALL` also needs `EventKind::DecryptedPayload` added back in, or it won't see the event even while a lease is held. The event carries a message's plaintext *before* it is decoded into a `wa::Message` — the only way to recover a payload that decrypts successfully but fails to decode (a field a build predates, a message type it doesn't model). Nothing can ask for those bytes again: opening them already consumed state that won't recur — the Signal ratchet advances, or (for a bot's `message_secret` payload) the single-use secret is spent — so the same ciphertext will never open a second time. RAII lease. `Event::DecryptedPayload` stays enabled until every acquired lease is dropped — hold it for as long as you want the event forwarded. The lease holds only a weak client reference, so it cannot keep the client alive. While no lease is held, nothing is emitted and nothing is cloned — the path costs one relaxed atomic load. Under a lease, the forwarded payload is the same `bytes::Bytes` the decoder receives, so forwarding it is a refcount bump rather than a copy. **Example:** ```rust theme={null} use wacore::types::events::{ChannelEventHandler, Event}; let (handler, event_rx) = ChannelEventHandler::new(); client.register_handler(handler); // Register the handler before acquiring the lease: forwarding activates // immediately, so a payload decrypted between the two calls would otherwise // dispatch to no one. let _lease = client.acquire_decrypted_payload_forwarding(); while let Ok(event) = event_rx.recv().await { if let Event::DecryptedPayload(payload) = &*event { // `payload.payload` is the unpadded plaintext, exactly as decoding // will receive it — record it, or inspect it when decoding later // fails for this message. println!( "enc #{} ({}) for {}: {} bytes", payload.enc_index, payload.enc_type, payload.info.id, payload.payload.len(), ); } } // Drop `_lease` to stop forwarding. ``` ### acquire\_enc\_decrypt\_failed\_forwarding ```rust theme={null} pub fn acquire_enc_decrypt_failed_forwarding(self: &Arc) -> EncDecryptFailedLease ``` Acquire a lease that keeps [`Event::EncDecryptFailed`](/concepts/events#encdecryptfailed) enabled for one consumer. The lease is necessary but not sufficient: a handler that has narrowed its `interest()` away from the default `EventInterest::ALL` also needs `EventKind::EncDecryptFailed` added back in, or it won't see the event even while a lease is held. This is the failing counterpart of [`acquire_decrypted_payload_forwarding`](#acquire_decrypted_payload_forwarding) — same per-`` granularity, same `enc_index` numbering — but tracked by a separate counter on purpose: a consumer that wants both halves of a stanza's decryption holds both leases, one that wants only failures does not make the success path clone plaintext, and one that wants only successes pays nothing extra on the failure paths. RAII lease. `Event::EncDecryptFailed` stays enabled until every acquired lease is dropped — hold it for as long as you want the event forwarded. The lease holds only a weak client reference, so it cannot keep the client alive. While no lease is held, nothing is emitted and nothing is built — each failure branch costs one relaxed atomic load. **Example:** ```rust theme={null} use wacore::types::events::{ChannelEventHandler, Event}; let (handler, event_rx) = ChannelEventHandler::new(); client.register_handler(handler); // Register the handler before acquiring the lease: forwarding activates // immediately, so a failure between the two calls would otherwise dispatch // to no one. let _lease = client.acquire_enc_decrypt_failed_forwarding(); while let Ok(event) = event_rx.recv().await { if let Event::EncDecryptFailed(failed) = &*event { // Not a display signal and not a loss report — see the caveats on // `Event::EncDecryptFailed` — but useful for attributing a failure // inside a fan-out or measuring session health per peer. println!( "enc #{} ({:?}) for {} failed: {:?}", failed.enc_index, failed.enc_type, failed.info.id, failed.reason, ); } } // Drop `_lease` to stop forwarding. ``` ### acquire\_sent\_frame\_forwarding ```rust theme={null} pub fn acquire_sent_frame_forwarding(self: &Arc) -> SentFrameLease ``` Acquire a lease that keeps [`Event::SentFrame`](/concepts/events#sentframe) enabled for one consumer. The lease is necessary but not sufficient: a handler that has narrowed its `interest()` away from the default `EventInterest::ALL` also needs `EventKind::SentFrame` added back in, or it won't see the event even while a lease is held. This is the outbound counterpart of [`acquire_decrypted_payload_forwarding`](#acquire_decrypted_payload_forwarding): the event carries the marshaled plaintext of every frame the transport accepted, and — unlike [`wait_for_sent_node`](#wait_for_sent_node) — it is neither filtered nor one-shot, and covers every send path, including acks, delivery receipts, and direct-encoded IQs that never build a `Node` at all. RAII lease. `Event::SentFrame` stays enabled until every acquired lease is dropped — hold it for as long as you want the event forwarded. The lease holds only a weak client reference, so it cannot keep the client alive. While no lease is held, nothing is emitted and nothing is cloned — the path costs one relaxed atomic load on the noise sender task. Under a lease, the forwarded frame is the same `bytes::Bytes` the caller handed to the socket, so forwarding it is a refcount bump rather than a copy. **Example:** ```rust theme={null} use wacore::types::events::{ChannelEventHandler, Event}; let (handler, event_rx) = ChannelEventHandler::new(); client.register_handler(handler); // Register the handler before acquiring the lease: forwarding activates // immediately, so a frame sent between the two calls would otherwise dispatch // to no one. let _lease = client.acquire_sent_frame_forwarding(); while let Ok(event) = event_rx.recv().await { if let Event::SentFrame(frame) = &*event { println!("sent {} bytes", frame.plaintext.len()); } } // Drop `_lease` to stop forwarding. ``` *** ## Call management ### reject\_call ```rust theme={null} pub async fn reject_call( &self, call_id: &str, call_from: &Jid, ) -> Result<(), anyhow::Error> ``` Reject an incoming call. This is fire-and-forget — no server response is expected. Sends a `` stanza to the WhatsApp server. The ID of the incoming call to reject. Must not be empty. The JID of the caller. **Example:** ```rust theme={null} bot.on_event(|event, client| async move { if let Event::Notification(node) = &*event { // Handle incoming call notification and reject it if let Some(call_id) = extract_call_id(&node) { let caller = extract_caller(&node); client.reject_call(&call_id, &caller).await.ok(); } } }); ``` *** ## Spam Reporting ### send\_spam\_report ```rust theme={null} pub async fn send_spam_report( &self, request: SpamReportRequest ) -> Result ``` Send a spam report to WhatsApp for messages or groups. The spam report request containing: * `message_id` - ID of the message being reported * `message_timestamp` - Timestamp of the message * `spam_flow` - Context where report was initiated (MessageMenu, GroupInfoReport, etc.) * `from_jid` - Optional sender JID * `group_jid` - Optional group JID for group spam * `group_subject` - Optional group name/subject for group reports * `participant_jid` - Optional participant JID in group context * `raw_message` - Optional raw message bytes * `media_type` - Optional media type if reporting media * `local_message_type` - Optional local message type **Returns:** `SpamReportResult` indicating success or failure **Example:** ```rust theme={null} use whatsapp_rust::{SpamReportRequest, SpamFlow}; // Report a spam message let result = client.send_spam_report(SpamReportRequest { message_id: "MESSAGE_ID".to_string(), message_timestamp: 1234567890, from_jid: Some(sender_jid), spam_flow: SpamFlow::MessageMenu, ..Default::default() }).await?; ``` **SpamFlow variants:** * `MessageMenu` - Reported from message context menu * `GroupInfoReport` - Reported from group info screen * `GroupSpamBannerReport` - Reported from group spam banner * `ContactInfo` - Reported from contact info screen * `StatusReport` - Reported from status view *** ## Passive Mode ### set\_passive ```rust theme={null} pub async fn set_passive(&self, passive: bool) -> Result<(), IqError> ``` Sets passive mode. When `false` (active), the server starts sending offline messages. *** ## Prekeys ### refresh\_pre\_keys ```rust theme={null} pub async fn refresh_pre_keys(&self) -> Result<(), anyhow::Error> ``` Force-refreshes the server's one-time pre-key pool with a fresh batch. This is intended for device migration scenarios where you restore a device from an external source (e.g., migrating a Baileys session into an `InMemoryBackend`) and the server may still hold pre-key IDs whose private key material you cannot reconstruct. Any `pkmsg` referencing those old IDs will fail permanently with `InvalidPreKeyId`. Calling `refresh_pre_keys()` uploads a fresh batch that the caller *does* have locally, and old unmatched IDs drain as peers consume them. **Behavior:** * Acquires the internal `prekey_upload_lock` so this force-upload cannot race with the count-based and digest-repair upload paths * Uploads a full batch of [`Client::wanted_pre_key_count()`](#wanted_pre_key_count) pre-keys (default 812, configurable via [`BotBuilder::with_wanted_pre_key_count`](/api/bot#with-wanted-pre-key-count) or [`set_wanted_pre_key_count`](#set_wanted_pre_key_count)) with Fibonacci retry backoff (1s, 2s, 3s, 5s, 8s, ... capped at 610s) * Retries until success or the connection is lost Only call this after restoring a device from an external session store. Under normal operation, the client manages pre-key uploads automatically. **Example:** ```rust theme={null} // After migrating a session from another library (e.g., Baileys) client.refresh_pre_keys().await?; ``` ### send\_digest\_key\_bundle ```rust theme={null} pub async fn send_digest_key_bundle(&self) -> Result<(), IqError> ``` Validates that the server's copy of the Signal Protocol key bundle matches local keys by querying a digest endpoint and comparing SHA-1 hashes. This matches WhatsApp Web's `WAWebDigestKeyJob.digestKey()` flow. **Behavior:** * Queries the server for the current key bundle digest (identity key, signed pre-key, pre-key IDs, and a SHA-1 hash) * If the server returns **404** (no record), triggers a full pre-key re-upload * On success, loads local keys, computes the same SHA-1 digest, and compares * Hash mismatches or missing keys are logged but do not trigger re-upload — only 404 does See [Signal Protocol - Digest key validation](/advanced/signal-protocol#digest-key-validation) for the wire format and detailed validation process. ### Signed pre-key rotation Rotation itself runs automatically: once per connection, after the post-login pre-key upload, the client checks whether the signed pre-key is due for rotation (every 27 days, matching WA Web's own `ROTATE_KEY` cadence — not configurable) and, if so, generates a fresh one, uploads it via an `encrypt`/`` IQ, and retains the previous key so in-flight prekey messages still decrypt. As of [whatsapp-rust#1411](https://github.com/oxidezap/whatsapp-rust/pull/1411), the same check also runs on a \~6-hour keepalive maintenance tick for the rest of a connection's lifetime, so a session held open past the 27-day cadence without ever reconnecting still rotates. Automatic rotation failures are logged and retried according to their scheduled backoff — they never fail login. As of [#1237](https://github.com/oxidezap/whatsapp-rust/pull/1237), a failed upload also schedules its retry: a `5xx` gets a 24-hour backoff, while any other 4xx rejection the server actually issued (e.g. `406`, `409`, `429`) consumes the full cadence instead of re-running the upload on every reconnect; an ambiguous transport failure — the server may have already accepted the upload — retries on the next connect instead. ```rust theme={null} pub async fn rotate_signed_pre_key(&self) -> Result<(), SignalMaintenanceError> ``` `rotate_signed_pre_key()` is a public method — callers can force an out-of-cadence rotation directly instead of waiting for the 27-day check. It shares a lock with the automatic path (so a manual call can't race a background rotation) and, unlike the automatic path, propagates failures to the caller as [`SignalMaintenanceError`](/api/errors#signalmaintenanceerror) instead of only logging them. As of PR #1090 this replaces bare `anyhow::Error`. A failure from a manual call never reschedules the automatic cadence — only the automatic path's own failures do. See [Signal Protocol - Signed pre-key rotation (RotateKeyJob)](/advanced/signal-protocol#signed-pre-key-rotation-rotatekeyjob) for the full sequence, wire format, and error handling. *** ## Diagnostics Three on-`Client` surfaces answer "what does this session cost?" without any feature flag: always-on wire I/O counters via [`stats()`](#stats), an on-demand client-only memory breakdown via [`memory_report()`](#memory_report), and an on-demand unified estimate — client plus storage, transport, and HTTP — via [`resource_report()`](#resource_report). All three are dependency-free and safe to call once per client even when running many clients in one process. For CPU/custom attribution (e.g. per-session allocator tracking), see [`BotBuilder::with_task_instrument`](/api/bot#with_task_instrument) and [`BotBuilder::with_alloc_meter`](/api/bot#with_alloc_meter). A fourth, always-on surface answers a different question — "are the group-send device-list memos actually being hit?" — via [`device_memo_stats()`](#device_memo_stats). ### stats ```rust theme={null} pub fn stats(&self) -> StatsSnapshot ``` Cumulative wire I/O and activity counters for this client session, always recorded — no feature gate. Byte counts are post-noise wire bytes (frame headers and AEAD tags included; handshake and TLS/WebSocket overhead excluded), so sessions from different clients in the same process can be compared directly. Cheap to call: it's a copy of a handful of atomics. **`StatsSnapshot` fields** (`#[non_exhaustive]`): | Field | Type | Description | | ------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `bytes_sent` | `u64` | Post-noise wire bytes written to the transport | | `bytes_received` | `u64` | Wire bytes received from the transport | | `frames_sent` | `u64` | Encrypted frames written | | `frames_received` | `u64` | Decodable frames received | | `messages_sent` | `u64` | Outgoing message send attempts (DM/group/status) | | `messages_received` | `u64` | Incoming messages successfully decrypted and dispatched | | `events_dropped` | `u64` | Inbound events shed because a consumer's bounded delivery mailbox was full (opt-in [`EventDelivery::Ordered`](/api/bot#with_event_delivery)) — a non-zero, growing value flags a consumer that can't keep up | | `devices_unkeyed_no_bundle` | `u64` | Keying attempts where the prekey fetch came back with no bundle for the device and named no reason ([#1304](https://github.com/oxidezap/whatsapp-rust/pull/1304)) | | `devices_unkeyed_session_setup` | `u64` | Keying attempts the session phase could not turn into a session: the local session store failed to answer, or a bundle arrived and building the Signal session from it failed ([#1304](https://github.com/oxidezap/whatsapp-rust/pull/1304)) | | `devices_unkeyed_rejected` | `u64` | Keying attempts the server refused, by name or as part of a batch-wide refusal — the per-code and named-vs-batch split is on the `metrics` facade ([#1304](https://github.com/oxidezap/whatsapp-rust/pull/1304)) | | `devices_unkeyed_fetch_failed` | `u64` | Keying attempts whose prekey fetch never produced an answer at all — timeout, dropped socket, 429/5xx. The counter to watch during an outage ([#1304](https://github.com/oxidezap/whatsapp-rust/pull/1304)) | | `devices_unkeyed_encrypt` | `u64` | Keying attempts that had a session and still produced no ciphertext — points at a stored session that exists and cannot be used, which is what session repair operates on ([#1304](https://github.com/oxidezap/whatsapp-rust/pull/1304)) | | `reconnects` | `u64` | Reconnect attempts started by the auto-reconnect loop | | `reconnect_errors` | `u32` | Consecutive reconnect failures (resets on a disconnect that finds the connection was stable — see below) | | `resends_throttled` | `u64` | Outbound resends dropped by the per-chat rate limiter — surfaces storm chats | | `messages_suppressed_duplicate` | `u64` | Decrypted messages not dispatched because the same message (chat/id/sender) had already reached consumers — a sender's outbox retry resending one id as fresh ciphertext. The number to check first when a consumer reports a missing message ([#1352](https://github.com/oxidezap/whatsapp-rust/pull/1352)) | | `last_data_received_ms` | `u64` | Timestamp (ms since UNIX epoch) of the last received data | Most counters are monotonic over the client's lifetime and survive reconnects. `last_data_received_ms` is the exception: it resets on connection teardown. `reconnect_errors` also resets to `0`, but not until a *disconnect* finds that the connection it is ending stayed up for at least the 30s stability window (see [Fibonacci backoff](/advanced/websocket-handling#fibonacci-backoff)) — a successful reconnect alone does not clear it, since the check runs on the way out of a connection, not on the way in. It counts *consecutive* failures, not a lifetime total. Since [whatsapp-rust#1410](https://github.com/oxidezap/whatsapp-rust/pull/1410), it also saturates at 64 rather than growing without bound on a link that flaps for weeks — the reconnect delay itself is already pinned at its 900s cap well before that point, so the saturation only keeps the counter itself readable, without changing any wait. `StatsSnapshot::devices_unkeyed_total()` sums the five `devices_unkeyed_*` fields above — every keying attempt this client lost, whatever the reason ([#1304](https://github.com/oxidezap/whatsapp-rust/pull/1304)). Usually the device is dropped and the send continues to everyone else, which is unchanged behavior (parity with WA Web). A batch-wide `406` or a `Required` distribution that cannot reach every target aborts the send instead; those attempts are still counted here. Either way, these counters make the state measurable instead of visible only in a log line. They count **attempts, not distinct devices**: a cold DM runs session establishment twice, so one send can record the same device more than once, and a retry that fails the same way counts again. See [`wa_unkeyable_device_total`](/advanced/metrics#counters) for the per-reason breakdown by label. **Breaking**: `last_data_sent_ms` was removed — nothing internal ever read it, and stamping it cost a clock read on every frame written (the client's hottest path, and a call out of the module on wasm32/embedded targets). `frames_sent` answers "is it still sending?"; there is no drop-in replacement for "when did I last write?" — an embedder that needs that timestamp should stamp it at its own send call site rather than have the wire path pay for it. **Example:** ```rust theme={null} let stats = client.stats(); println!( "sent {} bytes / {} frames, received {} bytes / {} frames, {} reconnects", stats.bytes_sent, stats.frames_sent, stats.bytes_received, stats.frames_received, stats.reconnects ); ``` ### memory\_report ```rust theme={null} pub async fn memory_report(&self) -> MemoryReport ``` Entry counts plus estimated retained heap bytes for the client's internal collections. On-demand only — it walks the in-process caches under their locks when called, and costs nothing otherwise (unused report code is eliminated by fat LTO). Counts are approximate (caches may have pending evictions). Byte figures are honest estimates rather than byte-exact accounting — Signal records use their protobuf encoded size, other collections sum key/payload capacities — suitable for per-session attribution and leak detection. Store-backed caches (e.g. Redis-backed custom stores) report `bytes: 0`, since their entries don't live in this process's memory. **`MemoryReport` fields** (`#[non_exhaustive]`): | Field | Type | Description | | ----------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `group_cache` | `CollectionStats` | Group metadata cache | | `device_registry_cache` | `CollectionStats` | Device registry cache | | `lid_pn_lid_entries` | `CollectionStats` | LID-to-PN mapping entries | | `lid_pn_pn_entries` | `CollectionStats` | PN-to-LID mapping entries (shares payloads with `lid_pn_lid_entries`; bytes here cover only entries the LID map no longer holds) | | `recent_messages` | `CollectionStats` | Recent message dedup cache | | `sender_key_device_cache` | `CollectionStats` | Per-group sender key device tracking cache | | `group_devices_memo` | `CollectionStats` | Resolved group-devices memoization cache | | `dm_devices_memo` | `CollectionStats` | Resolved per-recipient DM-devices memoization cache (recipient + own-companion devices, partitioned, with their phash) | | `message_retry_counts` | `u64` | Message retry counter cache (count only) | | `undecryptable_dispatched` | `u64` | Undecryptable-message dedup cache (count only) | | `dispatched_messages` | `u64` | Dispatch-once gate for decrypted messages, i.e. the resend-collapse cache (count only) ([#1352](https://github.com/oxidezap/whatsapp-rust/pull/1352)) | | `pdo_pending_requests` | `u64` | PDO pending request cache (count only) | | `pdo_requested` | `u64` | PDO once-per-message memo cache (count only) | | `session_locks` | `u64` | Per-device session locks (count only) | | `ensure_inflight` | `u64` | Addresses whose `ensure_e2e_sessions` prekey fetch is in flight, so a concurrent caller for the same address waits on it instead of fetching the same bundle again (count only). Normally zero — it only holds an address for the span of one prekey fetch. A value that stays nonzero across repeated samples warrants investigation, though sustained request turnover across many addresses can also keep it nonzero without any single fetch being stuck ([#1315](https://github.com/oxidezap/whatsapp-rust/pull/1315)) | | `group_metadata_inflight` | `u64` | Groups whose [`get_metadata`](/api/groups#get_metadata) query is in flight, so a concurrent caller for the same group shares that round trip instead of sending its own (count only). Normally zero — it only holds a group for the span of one query. A value that stays nonzero across repeated samples warrants investigation, though sustained request turnover across many groups can also keep it nonzero without any single query being stuck ([#1316](https://github.com/oxidezap/whatsapp-rust/pull/1316)) | | `chat_lanes` | `u64` | Per-chat lanes — combined enqueue lock + message queue (count only) | | `group_distribution_locks` | `u64` | Live per-group sender-key distribution lanes (count only) | | `group_distribution_lock_evictions` | `u64` | Cumulative capacity evictions of cold (non-live) distribution lanes; poll successive reports to derive a rate | | `group_distribution_lock_eviction_blocks` | `u64` | Cumulative attempts that kept a live lane and temporarily exceeded `group_distribution_locks_capacity` instead of evicting it | | `resend_rate_limiter_chats` | `u64` | Chats tracked by the per-chat resend rate limiter (count only) | | `session_recreate_history` | `u64` | Peers whose Signal session was recently recreated, keyed to rate-limit the next recreate (count only) ([#1235](https://github.com/oxidezap/whatsapp-rust/pull/1235)) | | `skdm_warm_memo` | `u64` | Groups whose sender-key distribution is memoised as already warm (count only) ([#1235](https://github.com/oxidezap/whatsapp-rust/pull/1235)) | | `transport_ack_queue` | `usize` | Deferred transport acks queued for the ack worker. Each entry retains the full inbound node plus a flush guard, so a stalled transport shows up here as a growing backlog ([#1116](https://github.com/oxidezap/whatsapp-rust/pull/1116)) | | `delivery_receipt_queue` | `usize` | Delivery receipts queued for their worker, same shape as `transport_ack_queue` ([#1116](https://github.com/oxidezap/whatsapp-rust/pull/1116)) | | `response_waiters` | `usize` | Active IQ response waiters | | `node_waiters` | `usize` | Active node waiters | | `sent_node_waiters` | `usize` | Waiters parked on outgoing nodes, the pre-encryption counterpart of `node_waiters`. Each retains a filter and a oneshot sender ([#1235](https://github.com/oxidezap/whatsapp-rust/pull/1235)) | | `pending_retries` | `usize` | Pending message retries | | `pending_lid_refreshes` | `usize` | Numbers with a `refresh_lid` re-resolve in flight, keyed by `(connection_generation, PN-side JID)`. Counts active reservations, not distinct peers — the same phone number can hold more than one across a reconnect, since an old generation's entry is only released by the task that took it. A value that stays high across repeated samples points at refreshes not completing rather than at request volume alone — though sustained turnover across many peers can also hold it up, since a completed reservation can be replaced by a fresh one between samples ([#1313](https://github.com/oxidezap/whatsapp-rust/pull/1313)) | | `presence_subscriptions` | `usize` | Active presence subscriptions | | `app_state_key_requests` | `usize` | Pending app state key requests | | `app_state_key_cache` | `usize` | Expanded app-state keys the app-state processor holds in memory, one entry per distinct key id the server's patches reference. Bounded at 32 entries, oldest evicted first — a key id is a pure function of bytes the backend never rewrites, so a cached entry never goes stale and the only reason to drop one is memory. No longer emptied on reconnect (as of [#1405](https://github.com/oxidezap/whatsapp-rust/pull/1405); it previously was, which cost a backend read plus an HKDF expansion per key on the next sync even though nothing had changed). Zero until the first app-state sync builds the processor ([#1273](https://github.com/oxidezap/whatsapp-rust/pull/1273)) | | `app_state_recovery_requests` | `usize` | Peer [snapshot recovery](/concepts/architecture#peer-snapshot-recovery) requests outstanding — collections asked of the primary device after a local snapshot MAC failed to validate here, awaiting or being applied from its reply. Normally zero; a value that stays nonzero holds until the primary answers (up to 120s unanswered, longer once a reply is being applied) or the request's own retry window lapses ([#1375](https://github.com/oxidezap/whatsapp-rust/pull/1375)) | | `app_state_syncing` | `usize` | Active app state sync operations | | `signal_sessions` | `CollectionStats` | Cached Signal sessions | | `signal_identities` | `CollectionStats` | Cached Signal identities | | `signal_sender_keys` | `CollectionStats` | Cached sender keys | | `history_sync_tasks` | `CollectionStats` | Queued/running history-sync tasks and their logical compressed-payload byte sum. A shared `Bytes` slice may retain a larger backing allocation, whose capacity isn't exposed by the type | | `history_sync_tasks_peak` | `u64` | Lifetime high-water mark of queued/running history-sync tasks | | `history_sync_payload_bytes_peak` | `u64` | Lifetime high-water mark of logical compressed-payload bytes | | `inbound_commit_batch` | `CollectionStats` | Inbound messages accumulated for the next per-batch commit (400 messages / 4 MiB flush threshold), plus their encoded-byte sum. Under default cache configuration this is the largest per-client allocation this report names, by roughly two orders of magnitude — but a deployment running enlarged cache capacities (e.g. a larger `history_sync_tasks` payload) can push another field's bytes past it. "Accumulated", not "resident": a batch already handed to its commit is still in memory but no longer counted here. Live traffic commits immediately, so outside an offline drain this is normally zero ([#1273](https://github.com/oxidezap/whatsapp-rust/pull/1273)) | | `offline_receipt_buffer` | `CollectionStats` | Delivery receipts held back during an offline drain, to be flushed as aggregate `` stanzas (WA Web `sendAggregateOfflineReceipts`). Bounded by the same commit batch that fills it — flushed per batch snapshot, so it tops out at the batch's 400 messages — and empty outside the drain. Reported because it is the largest transient the drain retains after `inbound_commit_batch` itself ([#1405](https://github.com/oxidezap/whatsapp-rust/pull/1405)) | | `msg_secret_buffer` | `usize` | `messageSecret` captures buffered for write-behind persistence, from live receives and sends as well as an offline drain — a slow backend can saturate this with no drain in progress. The 4096-entry limit isn't a hard ceiling: a queueing future cancelled while backpressured force-buffers what it still holds, so this can read above the limit during teardown ([#1273](https://github.com/oxidezap/whatsapp-rust/pull/1273)) | | `pending_device_sync` | `usize` | Distinct users in the unknown-device refresh dedup set, added when a refresh is requested and suppressing a repeat request for the same user. A nonzero count doesn't mean a refresh is still queued: an online-path entry outlives its own refresh, staying until the next offline backlog drain or teardown removes it — so this counts dedup entries, not pending work. On a connection with no offline drain this grows with the distinct users seen with an unknown device ([#1273](https://github.com/oxidezap/whatsapp-rust/pull/1273)) | | `chatstate_handlers` | `usize` | Registered chat state handlers | | `custom_enc_handlers` | `usize` | Registered custom encryption handlers | | `stanza_interceptors` | `usize` | Registered stanza interceptors ([`add_stanza_interceptor`](#add_stanza_interceptor)). A handle that outlives its interest leaves one registered, and a leak here costs a walk on every stanza — which this count is what makes visible ([#1239](https://github.com/oxidezap/whatsapp-rust/pull/1239)) | | `plugin_stanza_interceptors` | `u64` | Behind the `plugins` feature: sum of every installed plugin's active [stanza interceptors](/advanced/plugins#stanza-interception) ([#1241](https://github.com/oxidezap/whatsapp-rust/pull/1241)) | | `subsystems` | `Vec` | What the optional subsystems attached to this build retain — one entry per `(subsystem, collection)` pair reported by an attached subsystem (currently only `voip`, behind the `voip-runtime` feature family). Empty when no such subsystem is compiled in, rather than a `cfg`'d field per subsystem — the report has one shape whatever features this build enables | `CollectionStats` carries both `entries: u64` and `bytes: u64`. `MemoryReport::total_estimated_bytes(&self) -> u64` sums `.bytes` across every byte-carrying field, `subsystems` included. `MemoryReport` implements `Display` for a pretty-printed, human-readable breakdown. This output includes an `--- In-flight history sync ---` section with the two peak fields above, followed by a `--- Transient retention ---` section for `inbound_commit_batch`, `offline_receipt_buffer` ([#1405](https://github.com/oxidezap/whatsapp-rust/pull/1405)), `msg_secret_buffer`, and `pending_device_sync` ([#1273](https://github.com/oxidezap/whatsapp-rust/pull/1273)). **Reading a subsystem's collections:** ```rust theme={null} pub fn subsystem(&self, which: SubsystemCollection) -> Option ``` Looks up one collection of one attached subsystem by `SubsystemCollection`, a compile-checked constant rather than a string — naming a collection that doesn't exist is a build error, and `None` means either the subsystem isn't attached to this build or it doesn't report that collection. The `voip` subsystem — enabled by `voip`, `voip-encoded`, `voip-mlow`, or `voip-libopus` (see [Feature flags](/installation#feature-flags)) — exposes its constants under `whatsapp_rust::voip::collections`: | Constant | Collection | | ----------------------------------------- | --------------------------------------------------------------------- | | `voip::collections::ACTIVE_CALLS` | Active and ringing calls, with their bounded pre-offer group controls | | `voip::collections::PENDING_OUTGOING` | Outgoing calls parked until the server sends the relay that owns them | | `voip::collections::PENDING_LINK_UPDATES` | Admission snapshots retained while a call-link join ACK is in flight | ```rust theme={null} use whatsapp_rust::voip; let report = client.memory_report().await; if let Some(active_calls) = report.subsystem(voip::collections::ACTIVE_CALLS) { println!("active calls: {} entries, {} bytes", active_calls.entries, active_calls.bytes); } ``` **Breaking change (unreleased):** `MemoryReport` used to carry three individual VoIP-gated fields (`active_calls`, `pending_outgoing_calls`, `pending_call_link_updates`) directly on the struct, each a plain `CollectionStats`. They're now reached through `subsystems` / `subsystem()` above instead, so a build with no optional subsystem attached pays no `cfg`'d field for one it doesn't use. Migration is not a direct field swap — `subsystem()` returns `Option`, `None` when the build has no `voip` subsystem attached: ```rust theme={null} // Before: report.active_calls (CollectionStats, always present when compiled) // After: if let Some(active_calls) = report.subsystem(voip::collections::ACTIVE_CALLS) { // use active_calls.entries / active_calls.bytes } ``` **Example:** ```rust theme={null} let report = client.memory_report().await; println!("{report}"); // pretty-prints every collection's count and bytes println!("total estimated bytes: {}", report.total_estimated_bytes()); println!( "signal sessions: {} entries, {} bytes", report.signal_sessions.entries, report.signal_sessions.bytes ); ``` `Client::stats()`, `MemoryReport`, `CollectionStats`, and `StatsSnapshot` were introduced to replace the old `debug-diagnostics`-gated `memory_diagnostics()` / `MemoryDiagnostics`, which have been removed. `CollectionStats`, `MemoryReport`, and `StatsSnapshot` are re-exported from the `whatsapp_rust` crate root. ### resource\_report ```rust theme={null} pub async fn resource_report(&self) -> ResourceReport ``` Unified per-session resource estimate: [`memory_report()`](#memory_report)'s client-only collections **plus** the components that live *outside* the `Client` and dominate real per-session RAM — the storage backend's page cache, the transport's buffers and TLS/noise state, and the HTTP client's connection pool. When an [`AllocMeter`](/api/bot#with_alloc_meter) is installed via [`BotBuilder::with_alloc_meter`](/api/bot#with_alloc_meter), the report also folds in an allocation-churn snapshot. On-demand only, no hot-path cost. Each out-of-client figure is best-effort — a component reports only what it can introspect, so absent (`None`) means "not reported", not "zero". `resource_report()`'s future is `Send`, so multi-session consumers can await it off a worker task (e.g. from an axum handler). **`ResourceReport` fields** (`#[non_exhaustive]`): | Field | Type | Description | | ----------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `client` | `MemoryReport` | Identical to [`memory_report()`](#memory_report)'s result | | `storage` | `StorageResourceReport` | Storage-backend footprint (e.g. SQLite page cache). All-`None` for backends that don't report | | `transport` | `Option` | Transport read/write buffers plus a TLS/noise state estimate, if the transport reports them | | `http` | `Option` | HTTP connection-pool and in-flight footprint, if the client reports it | | `alloc` | `Option` | Allocation churn attributed via an installed `AllocMeter`; excluded from `total_estimated_bytes()` since it's churn, not a retained figure | `StorageResourceReport` fields: `memory_bytes: Option` (retained bytes; `Some(0)` for remote/store-backed backends whose data isn't process memory), `pages: Option` (backing page/entry count), `free_pages: Option` (how many of `pages` sit on the store's free list — SQLite: `freelist_count` — the part of the file retention sweeps have already emptied and only a `VACUUM` returns to the filesystem), `wal_bytes: Option` (bytes the write-ahead log occupies on disk, showing whether a single large transaction has left it permanently big), `io_read_bytes` / `io_write_bytes: Option` (cumulative I/O, when counted). `free_pages` and `wal_bytes` were added in [whatsapp-rust#1411](https://github.com/oxidezap/whatsapp-rust/pull/1411); `StorageResourceReport` is not `#[non_exhaustive]`, so a struct-literal constructor outside the tree needs both fields. `TransportResourceReport` fields: `read_buffer_bytes`, `write_buffer_bytes`, `tls_state_bytes` — all `Option`. `HttpResourceReport` fields: `pool_connections: Option`, `pool_buffer_bytes: Option`, `inflight_bytes: Option`. `ResourceReport::total_estimated_bytes(&self) -> u64` sums the **retained** components (client + storage + transport + HTTP). Treat it as a best-effort retained estimate, not a strict lower bound: unreported fields (`None`) are treated as `0`, so components that cannot fully introspect their footprint are silently undercounted — but the storage figure is itself a `min(cache cap, db size)` *upper* bound on the SQLite page cache, and can overstate actual heap residency when [`mmap_size`](/api/store#database-configuration) is enabled (see the caveat there), so the total can run either high or low depending on configuration. `alloc` (churn) is deliberately excluded. `ResourceReport` implements `Display` for a pretty-printed breakdown alongside `memory_report()`'s. **Example:** ```rust theme={null} let report = client.resource_report().await; println!("{report}"); println!("estimated retained bytes: {}", report.total_estimated_bytes()); if let Some(alloc) = report.alloc { println!("allocated (churn): {} bytes", alloc.allocated_bytes); } ``` Storage, transport, and HTTP reports are supplied by the trait implementations behind `Client` — see [`DeviceStore::resource_report`](/api/store#resource_report), [`Transport::resource_report`](/api/transport#resource_report), and [`HttpClient::resource_report`](/api/http-client#resource_report). `AllocSnapshot`, `StorageResourceReport`, `TransportResourceReport`, and `HttpResourceReport` are re-exported from `wacore::stats`; all four are also re-exported from the `whatsapp_rust` crate root. ### device\_memo\_stats ```rust theme={null} pub fn device_memo_stats(&self) -> DeviceMemoStats ``` Per-term hit/miss counts for the two device-list memos the group-send path depends on — the group-devices memo and the SKDM-targets memo — cumulative since the client was built. Always on, no feature gate. Recording is one indexed relaxed atomic add per resolver call, with one exception: a call whose resolved SKDM target set can't be memoized also bumps the separate `not_stored` counter, so that call pays two adds (see `not_stored` below). The reporting types are dropped by LTO in a binary that never calls this method. The two memos are chained: `resolve_skdm_targets_memoized` compares the `Arc` that `resolve_group_devices_memoized` returned, so a group-memo recompute forces `skdm_targets.miss_devices` — but only when an SKDM entry already exists for the group. If none does yet (first send, or an eviction), that call reports `miss_absent` instead, regardless of what the group memo just did. Read `group_devices` first — `skdm_targets` only carries independent information once the group half is hitting. **`GroupDevicesMemoStats` fields** (`#[non_exhaustive]`): | Field | Type | Description | | ----------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `hits` | `u64` | Entry present, `GroupInfo` identity matched, generation unchanged | | `restamps` | `u64` | Generation moved but every change since provably missed this group; served the same devices a hit would, at the cost of the `unchanged_for` scan | | `miss_absent` | `u64` | No entry for the group — first send, or a capacity eviction. The memo holds `CacheConfig::group_devices_memo_capacity` groups (default 512, was a fixed 64 before [whatsapp-rust#1400](https://github.com/oxidezap/whatsapp-rust/pull/1400)), least-recently-used evicted first — see [Cache Configuration Reference](/api/bot#cache-configuration-reference) | | `miss_group_info` | `u64` | An entry existed but was built from a different `Arc` | | `miss_topology` | `u64` | The device topology changed in a way that could have touched this group | | `bypassed` | `u64` | Call didn't consult the memo at all (store-backed registry/mapping caches make its freshness contract unenforceable) | `calls()` sums all six fields; `served_rate()` returns `(hits + restamps) / calls()` — the share resolved without a per-member registry fan-out — or `None` before the first call. **`SkdmTargetsMemoStats` fields** (`#[non_exhaustive]`): | Field | Type | Description | | --------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `hits` | `u64` | Memo hit | | `miss_absent` | `u64` | No entry — first send for this group, or an eviction | | `miss_devices` | `u64` | The resolved device-set `Arc` differs from the memoized one — the cascade term a group-memo recompute always forces | | `miss_map` | `u64` | The sender-key device map was rebuilt (a warm-mark write invalidated it) | | `miss_map_generation` | `u64` | Same map `Arc`, advanced generation — an in-place cold flip, e.g. a retry receipt's `markForgetSenderKey` | | `miss_sender` | `u64` | A different sending identity (PN↔LID re-addressing, or a re-pair) | | `not_stored` | `u64` | A resolved target set that couldn't be memoized, so the *next* call can't hit — but doesn't guarantee that call reports `miss_absent`: a stale entry left in place can never become valid again (the map generation only moves forward) and is reported under whichever term still fails | | `bypassed` | `u64` | Call didn't consult the memo | | `resolve_failed` | `u64` | The device resolution this call depends on returned `Err`, so no memo term was evaluated | `calls()` sums every field except `not_stored`, which describes the store rather than a lookup outcome. `hit_rate()` returns `hits / calls()`. `resolve_failed` is included in that denominator on purpose — folding failed resolutions out would let a client whose group sends are failing upstream read a healthy rate. `hit_rate()` returns `None` before the first call. `DeviceMemoStats` implements `Display` for a one-line-per-memo summary, and `since(&self, earlier: &Self) -> Self` saturating-subtracts an earlier snapshot to scope a window without resetting the counters (a reset would race a send in flight). **Example:** ```rust theme={null} let before = client.device_memo_stats(); // ... send a batch of group messages ... let window = client.device_memo_stats().since(&before); println!("{window}"); if let Some(rate) = window.skdm_targets.hit_rate() { println!("SKDM memo hit rate: {:.1}%", rate * 100.0); } ``` `DeviceMemoStats`, `GroupDevicesMemoStats`, and `SkdmTargetsMemoStats` are public in `whatsapp_rust::client` but, unlike `StatsSnapshot`/`MemoryReport`/`ResourceReport`, not re-exported from the crate root. Added in [#1292](https://github.com/oxidezap/whatsapp-rust/pull/1292) as a characterization tool: measured against that PR's fixtures, both memos hit on every warm send at group sizes 8–512, so the instrumentation shipped without a corresponding fix. ## Error Types ```rust theme={null} pub enum ClientError { NotConnected, Socket(SocketError), EncryptSend(EncryptSendError), NotLoggedIn, } ``` As of PR #1090, `connect()`/`wait_for_socket()`/`wait_for_connected()` return [`ConnectError`](/api/errors#connecterror) (`ClientError::AlreadyConnected` was removed in favor of `ConnectError::AlreadyConnected`), and `rotate_signed_pre_key()`/`flush_pending_signal_state()` return [`SignalMaintenanceError`](/api/errors#signalmaintenanceerror). See [Error Types](/api/errors) for the full reference across the crate. *** ## See Also * [Bot](/api/bot) - High-level builder with event handlers * [Events](/concepts/events) - Event system and types * [Sending Messages](/guides/sending-messages) - Sending and receiving messages * [Group Management](/guides/group-management) - Working with groups # Community Source: https://whatsapp-rust.jlucaso.com/api/community Community operations — create, deactivate, and manage community subgroups The `Community` feature provides methods for managing WhatsApp communities, including creation, subgroup linking/unlinking, and metadata queries. Community mutations use IQ stanzas (`w:g2` namespace) while metadata queries use MEX (GraphQL). ## Access Access community operations through the client: ```rust theme={null} let community = client.community(); ``` ## Methods ### create Create a new community. ```rust theme={null} pub async fn create( &self, options: CreateCommunityOptions, ) -> Result ``` **Parameters:** * `options` — Community creation options (see [CreateCommunityOptions](#createcommunityoptions)) **Returns:** * `CreateCommunityResult` — Contains the full `metadata: GroupMetadata` for the created community parent group **Example:** ```rust theme={null} use whatsapp_rust::features::community::CreateCommunityOptions; let mut options = CreateCommunityOptions::new("My Community"); options.description = Some("A great community".to_string()); options.closed = true; let result = client.community().create(options).await?; println!("Created community: {} ({})", result.metadata.subject, result.metadata.id); if let Some(desc) = &result.metadata.description { println!("Description: {}", desc); } ``` Since v0.6, `community().create()` returns the full `GroupMetadata` instead of just the JID. The library inlines the community description directly into the create stanza (matching WA Web), so the returned metadata already contains it — no separate `set_description` round-trip is needed. If you previously read `result.gid`, switch to `result.metadata.id` (`GroupMetadata` uses `id: Jid`). ### get\_participating Fetch all parent/community groups the logged-in account currently participates in. ```rust theme={null} pub async fn get_participating(&self) -> Result, CommunityError> ``` **Returns:** * `HashMap` — Map of community JID to metadata **Example:** ```rust theme={null} let communities = client.community().get_participating().await?; for (jid, metadata) in communities { println!("Community: {} ({})", metadata.subject, jid); } ``` ### deactivate Deactivate (delete) a community. Subgroups are unlinked but not deleted. ```rust theme={null} pub async fn deactivate(&self, community_jid: &Jid) -> Result<(), CommunityError> ``` **Parameters:** * `community_jid` — JID of the community to deactivate **Example:** ```rust theme={null} client.community().deactivate(&community_jid).await?; ``` ### link\_subgroups Link existing groups as subgroups of a community. ```rust theme={null} pub async fn link_subgroups( &self, community_jid: &Jid, subgroup_jids: &[Jid], ) -> Result ``` **Parameters:** * `community_jid` — JID of the parent community * `subgroup_jids` — Array of group JIDs to link **Returns:** * `LinkSubgroupsResult` — Contains `linked_jids` (successfully linked) and `failed_groups` (JID + error code pairs) **Example:** ```rust theme={null} let subgroup_jids = vec![ "120363001111111@g.us".parse()?, "120363002222222@g.us".parse()?, ]; let result = client.community() .link_subgroups(&community_jid, &subgroup_jids) .await?; println!("Linked: {:?}", result.linked_jids); for (jid, error_code) in &result.failed_groups { eprintln!("Failed: {} (error {})", jid, error_code); } ``` ### create\_subgroup Create a new group that is already linked as a subgroup of a community, in one call. ```rust theme={null} pub async fn create_subgroup( &self, name: &str, participants: &[Jid], parent_jid: &Jid, ) -> Result ``` **Parameters:** * `name` — Name of the new subgroup * `participants` — Initial participant JIDs to add to the subgroup * `parent_jid` — JID of the parent community to link the new subgroup under **Returns:** * `CreateCommunityResult` — Contains the full `metadata: GroupMetadata` for the created subgroup **Example:** ```rust theme={null} let participants = vec![ "5511999999999@s.whatsapp.net".parse()?, ]; let result = client.community() .create_subgroup("My Subgroup", &participants, &community_jid) .await?; println!("Created subgroup: {} ({})", result.metadata.subject, result.metadata.id); ``` Equivalent to creating a group and then calling [`link_subgroups`](#link_subgroups), but done in a single round-trip. ### unlink\_subgroups Unlink subgroups from a community. ```rust theme={null} pub async fn unlink_subgroups( &self, community_jid: &Jid, subgroup_jids: &[Jid], remove_orphan_members: bool, ) -> Result ``` **Parameters:** * `community_jid` — JID of the parent community * `subgroup_jids` — Array of subgroup JIDs to unlink * `remove_orphan_members` — Whether to remove members who are only in the community through the unlinked subgroups **Returns:** * `UnlinkSubgroupsResult` — Contains `unlinked_jids` (successfully unlinked) and `failed_groups` (JID + error code pairs) **Example:** ```rust theme={null} let result = client.community() .unlink_subgroups(&community_jid, &subgroup_jids, true) .await?; println!("Unlinked: {:?}", result.unlinked_jids); ``` ### get\_subgroups Fetch all subgroups of a community via MEX (GraphQL). ```rust theme={null} pub async fn get_subgroups( &self, community_jid: &Jid, ) -> Result, CommunityError> ``` **Parameters:** * `community_jid` — JID of the community **Returns:** * `Vec` — List of subgroups with metadata **Example:** ```rust theme={null} let subgroups = client.community() .get_subgroups(&community_jid) .await?; for sg in &subgroups { println!("{}: {} (default: {}, general: {})", sg.id, sg.subject, sg.is_default_sub_group, sg.is_general_chat); } ``` ### get\_subgroup\_participant\_counts Fetch participant counts per subgroup via MEX (GraphQL). ```rust theme={null} pub async fn get_subgroup_participant_counts( &self, community_jid: &Jid, ) -> Result, CommunityError> ``` **Parameters:** * `community_jid` — JID of the community **Returns:** * `Vec<(Jid, u32)>` — Pairs of subgroup JID and participant count **Example:** ```rust theme={null} let counts = client.community() .get_subgroup_participant_counts(&community_jid) .await?; for (jid, count) in &counts { println!("{}: {} participants", jid, count); } ``` ### query\_linked\_group Query a linked subgroup's metadata from the parent community. ```rust theme={null} pub async fn query_linked_group( &self, community_jid: &Jid, subgroup_jid: &Jid, ) -> Result ``` **Parameters:** * `community_jid` — JID of the parent community * `subgroup_jid` — JID of the subgroup to query **Returns:** * `GroupMetadata` — Full group metadata (see [Groups API](/api/groups#get_metadata)) **Example:** ```rust theme={null} let metadata = client.community() .query_linked_group(&community_jid, &subgroup_jid) .await?; println!("Subject: {}", metadata.subject); ``` ### join\_subgroup Join a linked subgroup via the parent community. ```rust theme={null} pub async fn join_subgroup( &self, community_jid: &Jid, subgroup_jid: &Jid, ) -> Result ``` **Parameters:** * `community_jid` — JID of the parent community * `subgroup_jid` — JID of the subgroup to join **Returns:** * `GroupMetadata` — Metadata of the joined subgroup **Example:** ```rust theme={null} let metadata = client.community() .join_subgroup(&community_jid, &subgroup_jid) .await?; println!("Joined: {}", metadata.subject); ``` ### get\_linked\_groups\_participants Get all participants across all linked groups of a community. ```rust theme={null} pub async fn get_linked_groups_participants( &self, community_jid: &Jid, ) -> Result, CommunityError> ``` **Parameters:** * `community_jid` — JID of the community **Returns:** * `Vec` — List of participants across all subgroups **Example:** ```rust theme={null} let participants = client.community() .get_linked_groups_participants(&community_jid) .await?; for p in &participants { println!("{} (admin: {})", p.jid, p.is_admin); } ``` ### remove\_participants Remove participants from a community. ```rust theme={null} pub async fn remove_participants( &self, community_jid: &Jid, participants: &[Jid], ) -> Result, CommunityError> ``` **Parameters:** * `community_jid` — JID of the community * `participants` — Array of participant JIDs to remove **Returns:** * `Vec` — Result for each participant (see [`ParticipantChangeResponse`](/api/groups#participantchangeresponse)) **Example:** ```rust theme={null} let to_remove = vec!["15551234567@s.whatsapp.net".parse()?]; let results = client.community() .remove_participants(&community_jid, &to_remove) .await?; for result in results { println!("{}: status {:?}", result.jid, result.status); } ``` ## Types ### CreateCommunityOptions Options for creating a new community. Implements `PartialEq` and `Eq`. ```rust theme={null} #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateCommunityOptions { pub name: String, pub description: Option, pub closed: bool, pub allow_non_admin_sub_group_creation: bool, pub create_general_chat: bool, } ``` **Fields:** * `name` — Community name * `description` — Optional description. Since v0.6 it's inlined as a `` child of the create stanza, so the community is created with the description in a single round-trip (no follow-up `set_description` IQ needed). * `closed` — Whether the community requires approval to join (default: `false`) * `allow_non_admin_sub_group_creation` — Whether non-admin members can create subgroups (default: `false`) * `create_general_chat` — Whether to create a general chat subgroup (default: `true`) **Constructor:** ```rust theme={null} let options = CreateCommunityOptions::new("My Community"); ``` ### CreateCommunityResult Result of creating a community. ```rust theme={null} #[derive(Debug, Clone)] #[non_exhaustive] pub struct CreateCommunityResult { pub metadata: GroupMetadata, } ``` The `metadata` field carries the full community parent metadata from the server, with the inline `description: Option` already populated. See [`GroupMetadata`](/api/groups#groupmetadata) for the full field list. `CreateCommunityResult` is `#[non_exhaustive]`: struct-literal construction and exhaustive struct destructuring from outside the crate are both disallowed. Field reads are unaffected; add `..` to any exhaustive destructuring patterns. Prior to v0.6 this struct exposed only `gid: Jid` and derived `PartialEq, Eq`. The `Eq` derives were dropped because `GroupMetadata` does not implement them. ### CommunitySubgroup A subgroup within a community. Implements `PartialEq` and `Eq`. ```rust theme={null} #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct CommunitySubgroup { pub id: Jid, pub subject: String, pub participant_count: Option, pub is_default_sub_group: bool, pub is_general_chat: bool, pub creation: Option, pub owner: Option, } ``` `CommunitySubgroup` is `#[non_exhaustive]`: struct-literal construction and exhaustive struct destructuring from outside the crate are both disallowed. Field reads are unaffected; add `..` to any exhaustive destructuring patterns. **Fields:** * `id` — Subgroup JID * `subject` — Subgroup name * `participant_count` — Number of participants (if available) * `is_default_sub_group` — Whether this is the default announcement subgroup * `is_general_chat` — Whether this is the general chat subgroup * `creation` — Subgroup creation timestamp (Unix seconds), if available * `owner` — JID of the subgroup owner, if available ### LinkSubgroupsResult Result of linking subgroups to a community. Implements `PartialEq` and `Eq`. ```rust theme={null} #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct LinkSubgroupsResult { pub linked_jids: Vec, pub failed_groups: Vec<(Jid, u32)>, } ``` `LinkSubgroupsResult` is `#[non_exhaustive]`: struct-literal construction and exhaustive struct destructuring from outside the crate are both disallowed. Field reads are unaffected; add `..` to any exhaustive destructuring patterns. ### UnlinkSubgroupsResult Result of unlinking subgroups from a community. Implements `PartialEq` and `Eq`. ```rust theme={null} #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct UnlinkSubgroupsResult { pub unlinked_jids: Vec, pub failed_groups: Vec<(Jid, u32)>, } ``` `UnlinkSubgroupsResult` is `#[non_exhaustive]`: struct-literal construction and exhaustive struct destructuring from outside the crate are both disallowed. Field reads are unaffected; add `..` to any exhaustive destructuring patterns. ### GroupType Classification of a group within the community hierarchy. Implements `PartialEq` and `Eq`. ```rust theme={null} #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GroupType { Default, Community, LinkedSubgroup, LinkedAnnouncementGroup, LinkedGeneralGroup, } ``` `GroupType` is `#[non_exhaustive]`, so match statements should include a wildcard arm to handle future variants. **Variants:** * `Default` — Regular standalone group * `Community` — Community parent group * `LinkedSubgroup` — A subgroup linked to a community * `LinkedAnnouncementGroup` — The default announcement subgroup of a community * `LinkedGeneralGroup` — The general chat subgroup of a community Use the `group_type()` function to classify a group: ```rust theme={null} use whatsapp_rust::features::community::{group_type, GroupType}; let metadata = client.groups().get_metadata(&group_jid).await?; let gtype = group_type(&metadata); ``` ## Error handling All community methods return `Result`: ```rust theme={null} #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum CommunityError { #[error("{0}")] Iq(#[from] IqError), #[error("{0}")] Mex(#[from] MexError), #[error("{0}")] Group(#[from] GroupError), #[error("invalid community request: {0}")] InvalidRequest(String), } ``` ```rust theme={null} use whatsapp_rust::CommunityError; match client.community().deactivate(&community_jid).await { Ok(_) => println!("Community deactivated"), Err(CommunityError::Iq(e)) => eprintln!("Server error: {}", e), Err(CommunityError::Mex(e)) => eprintln!("MEX error: {}", e), Err(e) => eprintln!("Error: {}", e), } ``` # Contacts Source: https://whatsapp-rust.jlucaso.com/api/contacts Contact lookup, profile picture, and user info operations The `Contacts` struct provides methods for checking WhatsApp registration status and retrieving profile pictures and user information. ## Access Access contact operations through the client: ```rust theme={null} let contacts = client.contacts(); ``` ## Methods ### is\_on\_whatsapp Check if JIDs are registered on WhatsApp. Accepts both PN JIDs and LID JIDs. ```rust theme={null} pub async fn is_on_whatsapp( &self, jids: &[Jid], ) -> Result, ContactError> ``` **Parameters:** * `jids` - Array of JIDs to check. Supports `Jid::pn("phone_number")` for phone number lookups and `Jid::lid("lid_value")` for LID lookups. If you pass a non-PN/non-LID JID (groups, newsletters, etc.), the method returns an error immediately. **Returns:** * `Vec` - Registration status for each JID **IsOnWhatsAppResult fields:** * `jid: Jid` - WhatsApp JID for the user * `is_registered: bool` - Whether the JID is on WhatsApp * `lid: Option` - LID (Linked Identity) if available * `pn_jid: Option` - Phone number JID, present when the server returns LID as the primary JID * `is_business: bool` - Whether this is a WhatsApp Business account * `verified_name: Option` - Decoded verified business name certificate, when the account is a verified business * `username: Option` - Meta username, without the display-only `@` prefix. `None` means the server reported no username, which is also how it reports one that was deleted; it is never an empty string * `contact_error: Option` - Server error for the `contact` subprotocol (e.g. privacy-blocked lookup); `is_registered` will be `false` * `lid_error: Option` - Server error for the `lid` subprotocol; `lid` will be `None` * `business_error: Option` - Server error for the `business` subprotocol; `is_business` will be `false` * `username_error: Option` - Server error for the `username` subprotocol. Unlike the other `*_error` fields, this does not imply `username` is `None`. The server also publishes a username as a plain attribute of the `contact` result, and a failed subprotocol does not retract that value. So `username` can still be `Some` next to `username_error`. **VerifiedName fields:** * `name: Option` - Display name shown to other users (decoded from the certificate when the server omits the attribute) * `serial: Option` - Certificate serial number * `issuer: Option` - Certificate issuer * `certificate: Option>` - Raw `VerifiedNameCertificate` protobuf bytes, for callers that need to verify the signature themselves `IsOnWhatsAppResult` is marked `#[non_exhaustive]`, so new fields may be added in future versions without a breaking change. Both `is_on_whatsapp` and `get_user_info` now ask for the `username` subprotocol by default, matching WhatsApp Web's own existence check and background contact sync. This is not opt-in and adds one more child to the `` node of both requests. **Example — phone number lookup:** ```rust theme={null} let results = client.contacts().is_on_whatsapp(&[ Jid::pn("15551234567"), Jid::pn("15559876543"), ]).await?; for result in results { if result.is_registered { println!("{} is on WhatsApp", result.jid); if let Some(lid) = &result.lid { println!(" LID: {}", lid); } if result.is_business { println!(" Business account"); if let Some(vn) = &result.verified_name && let Some(name) = &vn.name { println!(" Verified name: {}", name); } } } else { println!("{} is NOT on WhatsApp", result.jid); } } ``` **Example — LID lookup:** ```rust theme={null} let results = client.contacts().is_on_whatsapp(&[ Jid::lid("100000001"), ]).await?; for result in results { if let Some(pn) = &result.pn_jid { println!("LID {} maps to phone {}", result.jid, pn); } } ``` PN and LID queries use different wire protocols (matching WhatsApp Web's ExistsJob), so mixed inputs are automatically split into separate requests. LID-PN mappings discovered from results are persisted to the local cache. ### get\_profile\_picture Get the profile picture URL for a JID. ```rust theme={null} pub async fn get_profile_picture( &self, jid: &Jid, preview: bool, ) -> Result, ContactError> ``` **Parameters:** * `jid` - Target JID (user, group, or newsletter) * `preview` - `true` for preview thumbnail, `false` for full-size image **Returns:** * `Option` - Picture info or `None` if not available **ProfilePicture fields:** * `id: String` - Picture ID * `url: String` - Download URL * `direct_path: Option` - Direct path for media download * `hash: Option` - SHA-256 hash for integrity and cache validation **Example:** ```rust theme={null} let jid: Jid = "15551234567@s.whatsapp.net".parse()?; // Get preview thumbnail if let Some(preview) = client.contacts().get_profile_picture(&jid, true).await? { println!("Preview URL: {}", preview.url); println!("Picture ID: {}", preview.id); } // Get full-size picture if let Some(full) = client.contacts().get_profile_picture(&jid, false).await? { println!("Full URL: {}", full.url); if let Some(path) = full.direct_path { println!("Direct path: {}", path); } } // Handle user with no profile picture let no_pic_jid: Jid = "15559999999@s.whatsapp.net".parse()?; if client.contacts().get_profile_picture(&no_pic_jid, true).await?.is_none() { println!("User has no profile picture"); } ``` **For groups:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; if let Some(pic) = client.contacts().get_profile_picture(&group_jid, true).await? { println!("Group picture: {}", pic.url); } ``` To override the default request timeout for a single fetch, use `get_profile_picture_with_timeout(jid, preview, timeout)`, which takes an extra `timeout: Option` argument. Internally the request is built via `ProfilePictureSpec`'s `with_timeout(...)` builder method; pass `None` to fall back to the default timeout. The system/announcements JID (`0@s.whatsapp.net`, and its legacy form `0@c.us`) never answers this IQ, so it's short-circuited client-side: both `get_profile_picture` and `get_profile_picture_with_timeout` return `Ok(None)` immediately for it instead of waiting out the full request timeout. This mirrors WhatsApp Web, which never sends the request for this JID in the first place. ### get\_user\_info Get user information by JID. ```rust theme={null} pub async fn get_user_info( &self, jids: &[Jid], ) -> Result, ContactError> ``` **Parameters:** * `jids` - Array of JIDs to query The system/announcements JID (`0@s.whatsapp.net`, and its legacy form `0@c.us`) is filtered out of the batch before it's sent — it's not usync-eligible and the server never answers for it. If it's the only JID passed in, `get_user_info` returns an empty map without making a request; if it's mixed with other JIDs, the remaining JIDs are still queried normally. **Returns:** * `HashMap` - Map of JID to user info **UserInfo fields:** * `jid: Jid` - WhatsApp JID * `lid: Option` - LID if available * `lid_error: Option` - Server error for the `lid` subprotocol; `lid` will be `None` * `status: Option` - Status message * `status_error: Option` - Server error for the `status` subprotocol (e.g. privacy-hidden status); `status` will be `None` * `picture_id: Option` - Profile picture ID * `picture_error: Option` - Server error for the `picture` subprotocol; `picture_id` will be `None` * `is_business: bool` - Whether business account * `business_error: Option` - Server error for the `business` subprotocol; `is_business` will be `false` * `verified_name: Option` - Decoded verified business name certificate, for verified business accounts (see [`is_on_whatsapp`](#is_on_whatsapp) for field details) * `devices: Vec` - Device IDs from the `` sublist the same usync query returns (device `0` is the primary). Empty when the server omits the sublist — no extra request is needed. * `devices_error: Option` - Server error for the `devices` subprotocol; `devices` will be empty * `username: Option` - Meta username, without the display-only `@` prefix. See [`IsOnWhatsAppResult::username`](#is_on_whatsapp) for how it's sourced and what `None` means * `username_error: Option` - Server error for the `username` subprotocol. See [`IsOnWhatsAppResult::username_error`](#is_on_whatsapp) — a value already read from the `contact` result's attribute survives next to this error `UserInfo` is `#[non_exhaustive]`, so new fields may be added in future versions without a breaking change. **UsyncSubprotocolError fields:** * `code: Option` - Numeric error code from the server (e.g. `403`, `404`) * `text: Option` - Human-readable error description * `backoff: Option` - Server-suggested retry delay in seconds The library preserves per-subprotocol errors on the result struct rather than failing the whole request. A privacy error from one user's status does not block retrieval of devices for other users in the same batch. Check the `*_error` fields when a corresponding `Option` field is `None` and you need to distinguish "not set" from "server error". ```rust theme={null} let user_info = client.contacts().get_user_info(&[jid]).await?; if let Some(info) = user_info.get(&jid) { if info.status.is_none() { if let Some(err) = &info.status_error { println!("Status hidden: code={:?} text={:?}", err.code, err.text); } } } ``` **Example:** ```rust theme={null} let jids = vec![ "15551234567@s.whatsapp.net".parse()?, "15559876543@s.whatsapp.net".parse()?, ]; let user_info = client.contacts().get_user_info(&jids).await?; for (jid, info) in user_info { println!("User: {}", jid); println!(" Business: {}", info.is_business); println!(" Devices: {:?}", info.devices); // e.g. [0, 1, 2] if let Some(vn) = &info.verified_name && let Some(name) = &vn.name { println!(" Verified name: {}", name); } if let Some(status) = info.status { println!(" Status: {}", status); } if let Some(pic_id) = info.picture_id { println!(" Picture ID: {}", pic_id); } } ``` ### find\_by\_username Resolve a Meta username to the account behind it. ```rust theme={null} pub async fn find_by_username( &self, username: &str, username_key: Option<&str>, ) -> Result ``` **Parameters:** * `username` - The handle to resolve. A leading `@` is display-only and is stripped automatically. The bare handle must be between `USERNAME_MIN_LENGTH` (3) and `USERNAME_MAX_LENGTH` (35) characters, or the call fails with `ContactError::Username` before any request is sent. * `username_key` - The account's numeric "username key" (WhatsApp Web calls it a pin). Some accounts require it before the server will disclose the identity behind their handle at all. **Returns:** `UsernameLookup`: * `NotFound` - No account answers to this username, or it's not reachable from here * `KeyRequired { username: Option }` - The username exists but the server withheld the identity; repeat the call with the account's username key * `Found(UsernameLookupUser)` - The username resolved to an account **UsernameLookupUser fields:** * `jid: Jid` - Identity the server returned. The query addresses contacts by LID, so this is normally a LID * `pn_jid: Option` - Phone-number JID, when the server disclosed one on `` * `username: Option` - Username as the server spelled it back * `is_business: bool` * `verified_name: Option` On `Found`, the discovered LID/PN pair is persisted to the local cache, the same way `is_on_whatsapp` and `get_user_info` do. **Experimental.** The request is built exactly as WhatsApp Web's `WAWebQueryExistsJob.queryUsernameExists` builds it. No capture of a live server answering it backs this implementation — only the request shape is verified against the official client. A server that rejects the query is not necessarily a bug here. **Example:** ```rust theme={null} use whatsapp_rust::UsernameLookup; match client.contacts().find_by_username("example.handle", None).await? { UsernameLookup::Found(user) => { println!("Resolved to {}", user.jid); if let Some(pn) = &user.pn_jid { println!(" Phone number: {}", pn); } } UsernameLookup::KeyRequired { .. } => { println!("This account requires its username key to resolve"); } UsernameLookup::NotFound => println!("No account answers to that username"), } ``` ## Privacy & TC tokens For user JIDs (not groups/newsletters), the library automatically includes TC tokens when fetching profile pictures. TC tokens are used for privacy-gated operations. The implementation automatically: * Looks up TC tokens for user JIDs * Includes tokens in profile picture requests * Skips tokens for groups and newsletters `get_user_info` does the same for status/about: when the `profile_scraping_privacy_token_in_about_usync` AB prop is on, each queried JID's TC token is attached to its `` node in the usync IQ, matching WhatsApp Web's `USyncStatusProtocol`. This is what lets `status`/`status_error` and `about` resolve correctly for a privacy-restricted contact instead of coming back hidden. See [TC Token](/api/tctoken#automatic-usage) for details. ## Async compatibility `is_on_whatsapp` and `get_user_info` work correctly when called from `#[async_trait]` implementations or any context that boxes the returned future (`Box`). Earlier versions produced a compile error (`"implementation of FnOnce is not general enough"`) that could not be worked around in user code. Fixed in [#826](https://github.com/oxidezap/whatsapp-rust/pull/826) with no API changes. ## Error handling All methods return `Result`: ```rust theme={null} #[non_exhaustive] pub enum ContactError { #[error("{0}")] Iq(#[from] IqError), #[error("unsupported contact JID: {0}")] InvalidJid(String), #[error("{0}")] Username(#[from] UsernameLookupError), } ``` `Username` is returned by [`find_by_username`](#find_by_username) when the handle can't be turned into a valid lookup — too short, too long, or a `username_key` that fails usync's own field-consistency validation. ```rust theme={null} #[non_exhaustive] pub enum UsernameLookupError { #[error("username length {length} is outside the {USERNAME_MIN_LENGTH}..={USERNAME_MAX_LENGTH} the server accepts")] InvalidLength { length: usize }, #[error("the username query is not a valid usync query: {0}")] Query(#[from] UsyncValidationError), } ``` ```rust theme={null} use whatsapp_rust::ContactError; match client.contacts().is_on_whatsapp(&[jid]).await { Ok(results) => { /* ... */ } Err(ContactError::Iq(e)) => eprintln!("Server error: {}", e), Err(e) => eprintln!("Error: {}", e), } ``` ## Batch operations All lookup methods support batch operations for efficiency: ```rust theme={null} // Check multiple JIDs at once let results = client.contacts().is_on_whatsapp(&[ Jid::pn("15551111111"), Jid::pn("15552222222"), Jid::pn("15553333333"), ]).await?; // Get info for multiple JIDs let jids = vec![ "15551111111@s.whatsapp.net".parse()?, "15552222222@s.whatsapp.net".parse()?, ]; let user_info = client.contacts().get_user_info(&jids).await?; ``` ## Contact notification events The server sends `contacts` notifications when contact data changes. These are emitted as events you can subscribe to: * **`ContactUpdated`** — a contact's profile changed (invalidate cached presence/profile picture) * **`ContactNumberChanged`** — a contact changed their phone number (includes old/new JID and optional LID mappings) * **`ContactSyncRequested`** — the server requests a full contact re-sync ```rust theme={null} Event::ContactUpdated(update) => { // Refresh cached profile data for update.jid } Event::ContactNumberChanged(change) => { // Migrate chat data from change.old_jid to change.new_jid } Event::ContactSyncRequested(sync) => { // Re-sync contacts (optionally filtered by sync.after timestamp) } ``` See the [events reference](/concepts/events#contact-notification-events) for full struct definitions and wire format details. These are server-push notifications, distinct from `ContactUpdate` and `ContactRemoved`, which come from app-state sync mutations made on a linked device. See [Chat actions — Save and remove contacts](/api/chat-actions#save-and-remove-contacts) for `save_contact`/`remove_contact` and their events. ## Empty input handling Passing empty arrays returns empty results without making network requests: ```rust theme={null} let empty: Vec = vec![]; let results = client.contacts().is_on_whatsapp(&empty).await?; assert!(results.is_empty()); ``` ## Migration from previous versions The `is_on_whatsapp` method previously accepted `&[&str]` (phone number strings). It now takes `&[Jid]`: ```rust theme={null} // Before let results = client.contacts().is_on_whatsapp(&["1234567890"]).await?; // After let results = client.contacts().is_on_whatsapp(&[Jid::pn("1234567890")]).await?; ``` The `get_info` method and `ContactInfo` type have been removed. Use `is_on_whatsapp` for registration checks (now includes `lid`, `pn_jid`, and `is_business` fields) or `get_user_info` for detailed profile data (status, picture ID). # download Source: https://whatsapp-rust.jlucaso.com/api/download Download and decrypt media from WhatsApp messages ## download Download and decrypt media from a message. Only use `download` when you need the plaintext bytes (processing, transcoding, re-upload). To forward existing media unchanged, reuse the original message's CDN fields directly — no download required. See [media forwarding via CDN reuse](/guides/media-handling#forwarding-media-via-cdn-reuse). `download` and its siblings need a connected `Client` for most media — they ask the server for CDN hosts and an auth token (cached and refreshed automatically, not fetched on every call). The exception is `static_url` media (newsletter/channel content), which skips that round trip entirely — see the note below. If you've persisted a message's CDN fields and want to download after the session has disconnected, use [`MediaDownloader`](#mediadownloader) instead — it needs no `Client` at all. ```rust theme={null} pub async fn download( &self, downloadable: &dyn Downloadable ) -> Result, anyhow::Error> ``` Any message type that implements the `Downloadable` trait. Includes: * `ImageMessage` * `VideoMessage` * `AudioMessage` * `DocumentMessage` * `StickerMessage` * `ExternalBlobReference` (app state) * `HistorySyncNotification` Decrypted media bytes. For encrypted media (E2EE), automatically decrypts using AES-256-CBC and verifies HMAC-SHA256. For plaintext media (newsletters/channels), validates SHA-256 hash. ### Example: download image ```rust theme={null} use waproto::whatsapp as wa; // From a received message if let Some(image_msg) = message.image_message { let image_bytes = client.download(image_msg.as_ref()).await?; std::fs::write("downloaded_image.jpg", image_bytes)?; } ``` ### Example: download with error handling ```rust theme={null} match client.download(downloadable).await { Ok(data) => { println!("Downloaded {} bytes", data.len()); // Process data... } Err(e) => { eprintln!("Download failed: {}", e); // Fallback logic... } } ``` ### Recovering the CDN status by type `download` still returns bare `anyhow::Error`, but as of PR #1195 the status the CDN refused is recoverable by type via [`ErrorChainExt::http_status()`](/api/errors#error-chain-recovery) instead of only by parsing the message: ```rust theme={null} use whatsapp_rust::ErrorChainExt; match client.download(downloadable).await { Ok(data) => { /* ... */ } Err(e) => { let cause: &(dyn std::error::Error + 'static) = e.as_ref(); match cause.http_status() { // 500/502/503/504 are the CDN statuses normally worth a retry; // 501/505 mean the request itself is unsupported and won't // succeed on a resend. Apply your own policy per status. Some(429) | Some(500) | Some(502) | Some(503) | Some(504) => { /* worth backing off and retrying later */ } Some(status) => eprintln!("CDN refused with {status}: {e}"), None => eprintln!("no HTTP refusal status recoverable, inspect the cause: {e}"), } } } ``` `None` doesn't mean no HTTP exchange took place — it means no *refused* status was attached. A socket that never connected reports `None`, but so does a body that downloaded successfully and then failed decryption or hash validation: neither case has a CDN status to recover, so there's nothing to forward upstream. Inspect the underlying cause (`cause.source()` or the message) rather than assuming either case is always yours to fix. ### Automatic retry and URL re-derivation All download methods handle three categories of CDN errors automatically: * **Auth errors (401/403):** The client invalidates the cached media connection, fetches fresh credentials, and retries the download once. * **Media not found (404/410):** When a media URL has expired or the file has been relocated, the CDN returns 404 or 410. The client treats this the same as an auth error — it invalidates the cached connection, re-derives download URLs with fresh credentials and hosts, and retries once. This matches WhatsApp Web's `MediaNotFoundError` handling. * **Other errors (e.g., 500):** The client tries the next available CDN host without refreshing credentials. Hosts are tried in priority order (primary first, then fallback). For streaming downloads (`download_to_writer`), every attempt — including the first — starts by truncating the writer to empty via [`DownloadWriter::truncate`](#downloadwriter-trait) and rewinding it, so a host that streamed out plaintext before failing its MAC can't leave a tail behind a shorter successful retry. If every host fails, the writer is truncated to empty one final time on a best-effort basis rather than left holding unverified bytes. For in-memory downloads (`download()`), each retry gets a fresh buffer rather than reusing the previous one, for the same reason. If every retry is exhausted, the status of the final refusal survives into the error `download`/`download_to_writer` return — recover it with `ErrorChainExt::http_status()` as shown [above](#recovering-the-cdn-status-by-type) rather than parsing the message. This classification reads `status_code` off a successfully completed HTTP exchange — it never sees a status that a custom `HttpClient` implementation turned into an `Err`. If you provide your own `HttpClient`, see the [non-2xx-as-`Ok` contract](/api/http-client#httpclient-trait) it must follow for this retry logic to work at all. `static_url` media (newsletter/channel content fetched from a fixed CDN URL) skips the media-conn round trip entirely: `download` and its siblings only ask the server for hosts when the downloadable has no `static_url`. *** ## download\_to\_writer Download media to a writer using streaming when available, with automatic buffered fallback. This is the method to use for downloading straight to a file — pass a `File` or `BufWriter`. When the HTTP client supports streaming (`supports_streaming()` returns `true`), the entire HTTP download, decryption, and file write happen in a single blocking thread with \~40KB memory usage regardless of file size. When streaming is not available, the method automatically falls back to a buffered download — fetching the full response into memory, then decrypting and writing to the writer. This ensures `download_to_writer` works with any `HttpClient` implementation. The writer must implement [`DownloadWriter`](#downloadwriter-trait) rather than plain `Write + Seek` — see that section for why, and for what a custom writer needs to add. ```rust theme={null} pub async fn download_to_writer( &self, downloadable: &dyn Downloadable, writer: W, ) -> Result ``` Message containing downloadable media Writer for streaming output. Must implement [`DownloadWriter`](#downloadwriter-trait) and be Send + 'static for use in blocking task. Returns the writer after a successful download, holding exactly the decrypted media and seeked back to position 0 — nothing a caller left in it beforehand, and no tail from a host that failed partway through, survives. ### Example: streaming download ```rust theme={null} use std::fs::File; let file = File::create("large_video.mp4")?; let file = client.download_to_writer(video_msg.as_ref(), file).await?; // File holds exactly the downloaded media, seeked to start, and can be reused ``` When using an HTTP client that supports streaming (like the default `UreqHttpClient`), memory usage is constant \~40KB (8KB read buffer + decryption state). HTTP clients that don't support streaming fall back to buffered downloads, which load the full file into memory before writing. If the download fails on every host, the writer is emptied too, on a best-effort basis: a sink that refuses to empty is logged rather than replacing the download's own error. This only matters to a caller who kept a separate handle to the writer (e.g. a shared/cloneable writer), since `download_to_writer` otherwise consumes it and returns nothing on failure. *** ## DownloadWriter trait **Breaking change (as of PR #1197):** `download_to_writer`, `download_from_params_to_writer`, and `MediaDownloader::download_to_writer` now take `W: DownloadWriter` instead of `W: Write + Seek`. `std::fs::File`, `std::io::Cursor>`, `std::io::Cursor<&mut Vec>`, `std::io::BufWriter`, and `&mut W where W: DownloadWriter` all implement it already, so call sites using those types need no changes. A custom writer type needs one additional method — see below. `download_to_writer` needs more than `Write + Seek` from its sink: it needs to be able to empty it. ```rust theme={null} pub trait DownloadWriter: std::io::Write + std::io::Seek { /// Shorten the sink to `len` bytes, discarding anything beyond it. /// Only ever called with a length the sink already reaches. fn truncate(&mut self, len: u64) -> std::io::Result<()>; } ``` Media is authenticated by a single MAC over the whole ciphertext, so decryption has necessarily streamed plaintext into the writer by the time a forged body is caught, and a retry against the next host may end up writing fewer bytes than the attempt it replaces. Rewinding with `seek` alone can't remove bytes that are already there — shortening a sink requires a concrete operation (`File::set_len`, `Vec::truncate`) that no `std` trait exposes. `DownloadWriter::truncate` names that operation, which is what lets `download_to_writer` guarantee: **exactly the media on success**, and **empty on failure on a best-effort basis** (a sink that refuses to empty during that final cleanup is logged, not treated as fatal — see the note above). Every attempt begins by truncating the writer to 0, which clears its length, and then seeking it to 0, which resets its position — two separate operations that both matter on an append-mode `File`: appending forces every write to the file's current end regardless of position, so truncating to 0 is what makes that end (and therefore the next write) land at the start; a bare `seek` would change the position but not the end, and appending would still write there. On success, `download_to_writer` performs one further `seek(0)` after the writer's own bytes are in place, which is the "seeked back to position 0" postcondition on the returned writer described above. ### Built-in implementations | Type | `truncate` behavior | | --------------------------------------- | ------------------------------------------------------------------------------------------------- | | `std::fs::File` | Calls `File::set_len` | | `std::io::Cursor>` | Calls `Vec::truncate`, saturating rather than failing if `len` doesn't fit in `usize` | | `std::io::Cursor<&mut Vec>` | Same as above, over the borrowed buffer | | `std::io::BufWriter` | Flushes first (buffered bytes count toward the sink's length), then delegates to the inner writer | | `&mut W where W: DownloadWriter` | Delegates to the wrapped writer | ### Implementing DownloadWriter for a custom writer Any writer type used with `download_to_writer` that isn't one of the built-ins above — including a wrapper around one, like a progress-reporting adapter — needs its own `DownloadWriter` impl. For a wrapper, this is normally a one-line delegation: ```rust theme={null} use wacore::download::DownloadWriter; impl DownloadWriter for ProgressWriter { fn truncate(&mut self, len: u64) -> std::io::Result<()> { self.inner.truncate(len) } } ``` See [Streaming with Progress](/guides/media-handling#streaming-with-progress) for the full `ProgressWriter` example, including its `Write` and `Seek` impls. *** ## download\_from\_params Download and decrypt media from raw CDN parameters without the original message. The parameters are bundled into a [`DownloadParams`](#downloadparams) struct. ```rust theme={null} pub async fn download_from_params( &self, params: &DownloadParams, ) -> Result, anyhow::Error> ``` The CDN/crypto fields needed to fetch and decrypt the media. Build one with [`DownloadParams::encrypted`](#downloadparams). Decrypted media bytes ### Example: download from stored metadata ```rust theme={null} use wacore::download::MediaType; use whatsapp_rust::download::DownloadParams; // If you stored media metadata separately let params = DownloadParams::encrypted( "/v/t62.7118-24/12345_67890", &media_key, &file_sha256, &file_enc_sha256, file_length, MediaType::Image, ); let image_bytes = client.download_from_params(¶ms).await?; ``` `DownloadParams` implements [`Downloadable`](#downloadable-trait), so you can also pass it straight to [`download`](#download): `client.download(¶ms).await?`. *** ## download\_from\_params\_to\_writer Streaming variant of `download_from_params` that writes to a writer. Same writer contract as [`download_to_writer`](#download_to_writer): the writer must implement [`DownloadWriter`](#downloadwriter-trait), and holds exactly the decrypted media on success. ```rust theme={null} pub async fn download_from_params_to_writer( &self, params: &DownloadParams, writer: W, ) -> Result ``` The CDN/crypto fields needed to fetch and decrypt the media. See [`DownloadParams`](#downloadparams). Writer for streaming output Returns the writer after a successful download, holding exactly the decrypted media *** ## DownloadParams A `Downloadable` built from raw CDN fields, for re-downloading media without the original message. ```rust theme={null} pub struct DownloadParams { pub direct_path: String, pub media_key: Option>, pub file_sha256: Vec, pub file_enc_sha256: Option>, pub file_length: u64, pub media_type: MediaType, } ``` | Field | Type | Description | | ----------------- | ----------------- | ------------------------------------------------------------------ | | `direct_path` | `String` | WhatsApp CDN path (e.g. `/v/t62.7118-24/12345_67890`) | | `media_key` | `Option>` | 32-byte media key. `None` for plaintext (newsletter/channel) media | | `file_sha256` | `Vec` | SHA-256 of the decrypted file | | `file_enc_sha256` | `Option>` | SHA-256 of the encrypted file (encrypted media only) | | `file_length` | `u64` | Original file size in bytes | | `media_type` | `MediaType` | `Image`, `Video`, `Audio`, `Document`, `Sticker`, … | ### DownloadParams::encrypted Convenience constructor for encrypted (E2EE) media — fills `media_key` and `file_enc_sha256` as `Some(...)`. ```rust theme={null} pub fn encrypted( direct_path: impl Into, media_key: &[u8], file_sha256: &[u8], file_enc_sha256: &[u8], file_length: u64, media_type: MediaType, ) -> Self ``` `DownloadParams` implements [`Downloadable`](#downloadable-trait), so it works with `download`, `download_to_writer`, `download_from_params`, `download_from_params_to_writer`, and [`MediaDownloader`](#mediadownloader). *** ## MediaDownloader Downloads and decrypts media from the CDN with **no connected `Client`**. Everything a download needs beyond the CDN hosts already lives in the [`Downloadable`](#downloadable-trait) itself — `MediaDownloader` takes the hosts (and, optionally, an auth token) up front instead of asking a live session for them, so a persisted reference (for example a stored [`DownloadParams`](#downloadparams)) stays downloadable after the client has disconnected. A live `Client` still needs a session to fetch its hosts (cached and refreshed automatically, not re-fetched on every call — see [Automatic retry and URL re-derivation](#automatic-retry-and-url-re-derivation)); `MediaDownloader` is the path for callers that have no session to ask with at all — a background worker, a queue consumer, or a CLI tool operating on data a paired client saved earlier. ```rust theme={null} pub struct MediaDownloader { /* private fields */ } impl MediaDownloader { pub fn new( http_client: Arc, runtime: Arc, route: MediaRoute, ) -> Self; pub fn with_default_hosts( http_client: Arc, runtime: Arc, ) -> Self; pub fn route(&self) -> &MediaRoute; pub async fn download( &self, downloadable: &dyn Downloadable, ) -> Result, MediaDownloadError>; pub async fn download_to_writer( &self, downloadable: &dyn Downloadable, writer: W, ) -> Result; } ``` The same `HttpClient` implementation you pass to `ClientBuilder`. See [HTTP Client Trait](/api/http-client). Runtime abstraction used to run blocking decrypt/streaming work. The CDN hosts to try, in order, and an optional auth token. See [`MediaRoute`](#mediaroute-and-mediahost) below. `download` and `download_to_writer` mirror `Client::download` and `Client::download_to_writer` — same streaming/buffered branching, same retry-on-another-host behavior across the route's hosts, same writer contract for the streaming variant. The one difference is the auth-refresh budget: a `Client` gets one retry with freshly fetched credentials after an auth or not-found error; `MediaDownloader` has no session to refresh credentials from, so that class of error is terminal on the first attempt. ### Example: download after the session is gone ```rust theme={null} use std::sync::Arc; use wacore::download::MediaType; use whatsapp_rust::download::{DownloadParams, MediaDownloader}; // Needs only an HttpClient + Runtime — no paired Client required. let downloader = MediaDownloader::with_default_hosts(http_client.clone(), runtime.clone()); // `params` came from CDN fields you saved to your own store earlier, while // the client was still connected. (`PersistenceManager` covers device/session // state only — it doesn't store message metadata like this for you.) let params = DownloadParams::encrypted( "/v/t62.7118-24/12345_67890", &media_key, &file_sha256, &file_enc_sha256, file_length, MediaType::Image, ); let bytes = downloader.download(¶ms).await?; ``` `with_default_hosts` routes through [`DEFAULT_MEDIA_HOSTS`](#mediaroute-and-mediahost) with no auth token, which matches how WhatsApp Web's own download URL builder works — auth is an upload concern, not a download one. Pass an explicit `MediaRoute` to `new` if you need specific hosts or want to carry a token you obtained another way. ### MediaDownloadError ```rust theme={null} #[non_exhaustive] pub enum MediaDownloadError { ReferenceRejected(anyhow::Error), HostsUnreachable(anyhow::Error), NoHosts, Other(anyhow::Error), } ``` | Variant | Meaning | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ReferenceRejected` | The CDN rejected the request with 401/403/404/410. On a route built with [`MediaRoute::authenticated`](#mediaroute-and-mediahost), a 401/403 can mean only the *auth token* is stale — downloads don't strictly need one, so retrying with [`route.without_auth()`](#mediaroute-and-mediahost) before giving up can still succeed. A 404/410, or a 401/403 on an already-unauthenticated route, means the `direct_path` itself is expired or revoked and no host can serve it — that case is terminal. | | `HostsUnreachable` | Every host in the route failed for a reason other than the reference (transport failure, unexpected status, a body that failed to decrypt/verify). | | `NoHosts` | The `MediaRoute` named no hosts, so nothing was ever contacted. | | `Other` | The `Downloadable` didn't have the fields needed to build a request at all (e.g. no `direct_path` and no `static_url`), so — unlike `NoHosts` — no host was ever *going* to be contacted. Fix the metadata, not the network. | `MediaDownloadError` is `#[non_exhaustive]`; match it with a wildcard (`_ => `) arm so a future variant doesn't break your build. `Client::download` and `Client::download_to_writer` keep returning `anyhow::Error`: they already try a credential refresh before the error would escape, so the extra classification has nothing left to add there. ### MediaRoute and MediaHost `MediaRoute` replaces the old `MediaConnection` type — it is the low-level input to [`DownloadUtils::prepare_download_requests`](#key-methods) and to `MediaDownloader`. Where `MediaConnection` required an `auth: String`, `MediaRoute` makes it `auth: Option`, because the CDN gates a download on the signed `direct_path` and its hash token, not on a session credential. ```rust theme={null} pub struct MediaHost { pub hostname: String, } impl MediaHost { pub fn new(hostname: impl Into) -> Self; } pub struct MediaRoute { pub hosts: Vec, pub auth: Option, } impl MediaRoute { pub fn authenticated(hosts: Vec, auth: String) -> Self; pub fn unauthenticated(hosts: Vec) -> Self; pub fn without_auth(self) -> Self; pub fn default_hosts() -> Self; } pub const DEFAULT_MEDIA_HOSTS: [&str; 2] = ["mmg.whatsapp.net", "mmg-fallback.whatsapp.net"]; ``` | Constructor | Use when | | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MediaRoute::authenticated(hosts, auth)` | You have both server-provided hosts and a media auth token (what a connected `Client` builds from its `MediaConn`). | | `MediaRoute::unauthenticated(hosts)` | You have hosts but no token — the common case for `MediaDownloader`. | | `route.without_auth()` | You have an authenticated route but want to keep using it past the token's lifetime; drops the token rather than sending a stale one. | | `MediaRoute::default_hosts()` | You have neither — routes through [`DEFAULT_MEDIA_HOSTS`](#mediaroute-and-mediahost), the CDN hosts WhatsApp Web itself carries in its binary-protocol token dictionary. This is only a convenience: a live session should still take its hosts from the server, which is what lets a test harness point downloads at itself. | `MediaRoute`'s `Debug` implementation is hand-written to print `auth: Some("")` / `auth: None` instead of the token itself, so a stray `{:?}` or tracing field can't leak a live credential into a log. **Breaking change (as of PR #1194):** `wacore::download::MediaConnection` (`{ hosts: Vec, auth: String }`) no longer exists. If you built one directly and passed it to `DownloadUtils::prepare_download_requests`, migrate by constructing the equivalent `MediaRoute` in its place: `MediaRoute::authenticated(hosts, auth)`. (The `From<&MediaConn> for MediaRoute` impl is unrelated to this migration — it converts `whatsapp_rust`'s own `mediaconn::MediaConn`, the server response a connected `Client` refreshes internally, and is what `Client::prepare_requests` uses under the hood.) `Client::download` and friends are unaffected either way; this only touches callers using the lower-level `DownloadUtils` type directly. *** ## fetch\_sticker\_pack Fetch first-party sticker pack metadata (and the per-sticker download handles) from the WhatsApp CDN. ```rust theme={null} pub async fn fetch_sticker_pack( &self, pack_id: &str, locale: &str, ) -> Result ``` The first-party sticker pack ID (typically extracted from a received `sticker_pack_message`). BCP-47 locale tag for localized name / publisher strings. Pass `"en"` to match whatsmeow's default. Pack metadata plus a `Vec` of individual stickers. Each `StickerPackItem` implements [`Downloadable`](#downloadable-trait), so you can pass it straight to `client.download(...)`. Under the hood the client GETs `https://static.whatsapp.net/sticker?lottie=1&cat=sticker_pack_data&id={pack_id}&lg={locale}`, parses the JSON envelope, and constructs the `StickerPack`. The endpoint is unauthenticated — the call works whether or not you are paired. A non-2xx response fails with the same contract as `download`: the status is recoverable via `ErrorChainExt::http_status()` (added in PR #1195), not only by parsing the error message. ```rust theme={null} use whatsapp_rust::Client; let pack = client.fetch_sticker_pack("3F9Z2…", "en").await?; println!("Pack '{}' by {}", pack.name, pack.publisher); for sticker in &pack.stickers { let bytes = client.download(sticker).await?; std::fs::write(format!("{}.webp", sticker.file_name), bytes)?; } ``` ### StickerPack ```rust theme={null} pub struct StickerPack { pub sticker_pack_id: String, pub name: String, pub publisher: String, pub description: Option, pub tray_image_file_name: Option, pub stickers: Vec, // additional CDN metadata: animated, tray-icon colors, … } ``` ### StickerPackItem ```rust theme={null} pub struct StickerPackItem { pub file_name: String, pub emojis: Vec, pub accessibility_text: Option, pub is_animated: bool, // plus all Downloadable fields: // direct_path, media_key, file_enc_sha256, file_sha256, file_length } impl Downloadable for StickerPackItem { /* … */ } ``` The CDN response is a JSON envelope, not a protobuf message — `StickerPack` / `StickerPackItem` live in `wacore::sticker_pack` and are independent of `waproto::whatsapp::StickerPackMessage` (which represents the inline pack-bubble in a chat). The struct mirrors whatsmeow's `FirstPartyStickerPack`. *** ## Downloadable Trait The `Downloadable` trait provides a generic interface for downloading media from any message type. ```rust theme={null} pub trait Downloadable: Sync + Send { fn direct_path(&self) -> Option<&str>; fn media_key(&self) -> Option<&[u8]>; fn file_enc_sha256(&self) -> Option<&[u8]>; fn file_sha256(&self) -> Option<&[u8]>; fn file_length(&self) -> Option; fn app_info(&self) -> MediaType; fn static_url(&self) -> Option<&str> { None } fn is_encrypted(&self) -> bool { self.media_key().is_some() } } ``` WhatsApp CDN path for the media file 32-byte encryption key. Present for E2EE media, `None` for plaintext (newsletter/channel) media. SHA-256 hash of the encrypted file. Used for encrypted media validation. SHA-256 hash of the decrypted file. Used for plaintext media validation. Original file size in bytes Media type for HKDF key derivation (`Image`, `Video`, `Audio`, `Document`, etc.) Static CDN URL for direct download. Present on newsletter/channel media, bypasses host construction. Returns `true` if media is encrypted (has `media_key`), `false` for plaintext media ### Built-in Implementations The `Downloadable` trait is automatically implemented for: * `wa::message::ImageMessage` * `wa::message::VideoMessage` * `wa::message::AudioMessage` * `wa::message::DocumentMessage` * `wa::message::StickerMessage` * `wa::ExternalBlobReference` (app state) * `wa::message::HistorySyncNotification` *** ## MediaType Media type enum for encryption/decryption. ```rust theme={null} pub enum MediaType { Image, Video, Audio, Document, History, AppState, Sticker, StickerPack, StickerPackThumbnail, LinkThumbnail, ProductCatalogImage, } ``` Each media type has specific HKDF info strings used for key derivation: * `Image` / `Sticker` → `"WhatsApp Image Keys"` * `Video` → `"WhatsApp Video Keys"` * `Audio` → `"WhatsApp Audio Keys"` * `Document` → `"WhatsApp Document Keys"` * `History` → `"WhatsApp History Keys"` * `AppState` → `"WhatsApp App State Keys"` * `StickerPack` → `"WhatsApp Sticker Pack Keys"` * `StickerPackThumbnail` → `"WhatsApp Sticker Pack Thumbnail Keys"` * `LinkThumbnail` → `"WhatsApp Link Thumbnail Keys"` ### MediaType methods | Method | Return type | Description | | ---------------- | -------------- | ------------------------------------------- | | `app_info()` | `&'static str` | HKDF info string for key derivation | | `mms_type()` | `&'static str` | Media type string for MMS path construction | | `upload_path()` | `&'static str` | URL path prefix for upload/download | | `is_encrypted()` | `bool` | Whether this media type uses E2E encryption | ### Upload paths | Media type | Upload path | | ---------------------- | ----------------------------- | | `Image` / `Sticker` | `/mms/image` | | `Video` | `/mms/video` | | `Audio` | `/mms/audio` | | `Document` | `/mms/document` | | `History` | `/mms/md-msg-hist` | | `AppState` | `/mms/md-app-state` | | `StickerPack` | `/mms/sticker-pack` | | `StickerPackThumbnail` | `/mms/thumbnail-sticker-pack` | | `LinkThumbnail` | `/mms/thumbnail-link` | | `ProductCatalogImage` | `/product/image` | `ProductCatalogImage` is **unencrypted** — `is_encrypted()` returns `false`. This matches WhatsApp Web's behavior where `CreateMediaKeys.js` skips encryption for product catalog images. Its upload path is `/product/image` (not under the `/mms/` prefix like other media types). *** ## Media Decryption WhatsApp uses different handling for encrypted (E2EE) and plaintext media: ### Encrypted Media (E2EE) 1. **Download** encrypted bytes from CDN 2. **Verify** HMAC-SHA256 (last 10 bytes) 3. **Decrypt** using AES-256-CBC with keys derived from `media_key` via HKDF 4. **Return** decrypted plaintext The `media_key` is expanded using HKDF-SHA256 to derive: * 16-byte IV * 32-byte cipher key * 32-byte MAC key ### Plaintext media (newsletter/channel) 1. **Download** plaintext bytes from CDN (often via `static_url`) 2. **Verify** SHA-256 hash matches `file_sha256` 3. **Return** plaintext (no decryption needed) Newsletter and channel media is **not encrypted**. The library automatically detects this when `media_key` is absent and switches to plaintext validation. ### Example: detect media type ```rust theme={null} if downloadable.is_encrypted() { println!("E2EE media - will decrypt"); } else { println!("Plaintext media - no decryption needed"); } ``` *** ## DownloadUtils Low-level static methods for media decryption and validation. These are re-exported from `wacore::download` and useful when you need fine-grained control over the download pipeline. ```rust theme={null} use whatsapp_rust::download::DownloadUtils; ``` ### Key methods | Method | Description | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `verify_and_decrypt(encrypted_payload, media_key, media_type)` | Verifies HMAC and decrypts AES-256-CBC in one call | | `verify_and_decrypt_in_place(encrypted_payload, media_key, media_type)` | In-place counterpart to `verify_and_decrypt` — authenticates and decrypts into the same buffer, truncating the trailing MAC/padding, instead of allocating a separate output. Used internally by non-streaming downloads to avoid keeping two file-sized buffers alive at once | | `decrypt_stream(reader, media_key, media_type)` | Streaming decryption from a reader (convenience wrapper around `decrypt_stream_to_writer`) | | `decrypt_stream_to_writer(reader, media_key, media_type, writer)` | Streaming decryption directly into a writer with constant memory usage | | `validate_plaintext_sha256(data, expected_sha256)` | Validates SHA-256 hash of plaintext media (in-memory) | | `copy_and_validate_plaintext_to_writer(reader, expected_sha256, writer)` | Streams plaintext media to a writer while validating SHA-256 hash | | `prepare_download_requests(downloadable, route)` | Builds CDN request URLs with host failover. Takes a [`MediaRoute`](#mediaroute-and-mediahost) (previously `MediaConnection`) — hosts plus an optional auth token | | `get_media_keys(media_key, app_info)` | Derives IV, cipher key, and MAC key via HKDF | # Error Types Source: https://whatsapp-rust.jlucaso.com/api/errors Typed error reference for all whatsapp-rust public APIs As of PR #893, all feature-domain APIs return a typed, domain-specific error instead of `anyhow::Error`. Use `?` to propagate errors into any `anyhow` context — all types implement `std::error::Error` and are `#[non_exhaustive]`, so your existing error-handling code compiles unchanged. Lower-level APIs such as media upload/download are not covered by this page and may still surface `anyhow::Error` directly. As of PR #1090, the connect/lifecycle surface (`Client::connect`, `wait_for_socket`, `wait_for_connected`), the background Signal maintenance surface (`Client::rotate_signed_pre_key`, `Client::flush_pending_signal_state`), and the message-edit target-key resolvers (`EncryptedEdit::original_sender_jid`, `SecretEncrypted::original_sender_jid`/`original_sender_for_dispatch`) also return typed errors — see [`ConnectError`](#connecterror), [`SignalMaintenanceError`](#signalmaintenanceerror), and [`MessageEditError`](#messageediterror) below. `Client::logout()` is now infallible (`()`, not `Result<(), _>`): the deregistration IQ it sends is best-effort, so there was nothing for a caller to branch on. As of PR #1100, every wrapping variant that used to be `#[error(transparent)]` is `#[error("{0}")]` instead — same `Display` output, but the wrapped error is now reachable via `std::error::Error::source()` instead of being erased. [`ErrorChainExt`](#error-chain-recovery) is a new extension trait, implemented for every `std::error::Error`, that walks that chain for you: `server_rejection()`, `is_timeout()`, `is_transport_unavailable()`, and `store_failure()` answer type-agnostically across every error on this page, without downcasting or string-matching. See [Error chain recovery](#error-chain-recovery) below. As of PR #1195, `ErrorChainExt` also answers `http_status()` — the HTTP status code behind a refused download, upload, sticker-pack fetch, or app-version fetch, recovered from a [`HttpStatusError`](#httpstatuserror) node the same way `server_rejection()` recovers an IQ rejection. This is the one place the "lower-level APIs … may still surface `anyhow::Error` directly" caveat above gets a typed escape hatch: `download`/`upload` still return `anyhow::Error`, but the status inside that error is now recoverable by type instead of by parsing `Display` text. See [Error chain recovery](#error-chain-recovery) below. As of PR #1257, `IqError::ServerError` also carries `response: RejectionStanza` — the `type="error"` stanza itself, handed over whole the same way a `type="result"` response already was, alongside the four fields this crate parses off it (`code`, `text`, `error_type`, `backoff`). See [`RejectionStanza`](#rejectionstanza) below. As of PR #1261, `wacore::bot_message::decrypt_bot_message` (and its private decryption helper) return `Result` instead of `anyhow::Result`. Unlike the entries above, this is **a breaking change to `wacore`'s public API**, not an additive one — a caller matching on the previous `anyhow::Error` needs to switch to the typed enum. See [`BotMessageError`](#botmessageerror) below. As of PR #1299, a DM send that reaches no device of its recipient now fails instead of silently succeeding. A DM's recipient devices and the sender's own companion devices used to share one participant list, and the old guard only checked whether that list was empty. That missed one shape: every recipient device fails to encrypt (no session, a refused pre-key bundle), but a sender's own companion still succeeds. The stanza then went out carrying only the sender's own devices. The server acked it, and `send_message` returned `Ok` even though the recipient received nothing. Two conditions now surface as `SendError::NoRecipientDevice(wacore::send::NoRecipientDeviceError)` instead of `Ok` or the `Internal` catch-all: every resolved recipient device failing to encrypt, and the fan-out resolving no recipient device at all for a destination that isn't one of the sender's own identities. See [`NoRecipientDeviceError`](#norecipientdeviceerror) below. As of PR #1360, `ConnectError::Version` no longer follows from every version-source failure on the `wasm32` target. That target fetches the version from the Facebook JS SDK bundle at `connect.facebook.net`, which sits on common tracker blocklists. A client blocked from reaching it now connects anyway, on the version the device already holds, and reports the fallback on [`Event::Connected`](/concepts/events#connected) via `app_version_fallback` instead of failing `connect()`. The native target's source (`sw.js`) is unchanged — a client that can't reach it still fails with `ConnectError::Version`, since that host serves WhatsApp Web itself and an unreachable `sw.js` is a real break. See [Connected](/concepts/events#connected) for the fallback payload. This is also **a breaking change** for a direct caller of `whatsapp_rust::version::resolve_and_update_version` (most callers only go through `Client::connect`, which absorbs it): its return type changed from `Result<()>` to `Result>`. A caller discarding the result (`resolve_and_update_version(...).await?;`) keeps compiling unchanged; one that named the `Ok` type as `()` needs to switch to `Option`. ## Error hierarchy ``` ClientError (transport/connection base — embedded by select domain errors) ├── NotConnected ├── Socket(SocketError) ├── EncryptSend(EncryptSendError) ├── NotLoggedIn ├── Iq(IqError) └── Internal(anyhow::Error) ConnectError (Client::connect, wait_for_socket, wait_for_connected) ├── AlreadyConnected ├── NotActivated ├── Shutdown ├── Paused ├── Timeout { stage: ConnectStage, timeout: Duration } ├── Version(anyhow::Error) ├── Transport(anyhow::Error) └── Handshake(HandshakeError) SignalMaintenanceError (Client::rotate_signed_pre_key, Client::flush_pending_signal_state) ├── CorruptKey(String) ├── Storage(anyhow::Error) ├── Iq(IqError) ├── Signal(SignalProtocolError) ├── DrainCommitFailed └── DrainShuttingDown MessageEditError (EncryptedEdit / SecretEncrypted target-key resolvers) ├── InvalidTargetJid { field, source: JidError } └── MissingTargetSender IqError (IQ request failures — embedded by most domain errors) ├── Timeout ├── NotConnected ├── Socket(SocketError) ├── EncryptSend(EncryptSendError) ├── ClientState(Box) ├── Disconnected(Box) ├── ServerError ├── UnexpectedResponseType ├── InternalChannelClosed ├── DuplicateRequestId(String) ├── EncodeError └── ParseError BotMessageError (wacore::bot_message::decrypt_bot_message — wacore crate, not whatsapp_rust) ├── InvalidSecretLength ├── InvalidIvLength ├── PayloadTooShort ├── KeyDerivation └── AuthenticationFailed ``` Domain errors that embed `IqError` or `ClientError` via `#[from]` propagate those failures automatically via `?`. Some errors (e.g. `AppStateError`, `SignalError`) use internal `anyhow::Error` wrapping instead and do not have `Iq` or `Client` variants. `ConnectError` and `SignalMaintenanceError` are **not** variants of `ClientError` — they are separate top-level error types returned directly by their respective methods (previously those methods returned bare `anyhow::Error`). `ClientError::AlreadyConnected` was removed in PR #1090; the equivalent case now lives on `ConnectError::AlreadyConnected`. ## Error chain recovery Added in PR #1100. Before this, `#[error(transparent)]` on a wrapping variant made `Display` forward to the inner error but also made `source()` forward to *that error's own* source — so the wrapped error itself was never reachable, and a consumer walking `source()` to find (say) a `403` from the server lost the typed node and was left parsing `Display` text. Every `transparent` in the crate (46 occurrences) is now `#[error("{0}")]`: byte-identical `Display` output, but `source()` now returns the wrapped error itself, so it can be downcast. `ErrorChainExt` is a blanket-implemented trait (`impl ErrorChainExt for E`) that turns that walk into a few type-agnostic questions, so a domain error added later answers them without implementing anything: ```rust theme={null} pub trait ErrorChainExt { fn sources(&self) -> Sources<'_>; fn server_rejection(&self) -> Option>; fn http_status(&self) -> Option; fn is_timeout(&self) -> bool; fn is_transport_unavailable(&self) -> bool; fn store_failure(&self) -> Option<&StoreError>; } ``` * `sources()` — an iterator over the error and everything reachable from it via `source()`, nearest first. Use this to recover a domain type the other methods don't model. * `server_rejection()` — the [`ServerRejection`](#serverrejection) behind this error, if any of the three types that can carry one (`wacore::request::IqError`, `crate::request::IqError`, or the crate-boundary `ServerErrorCode`) appear anywhere in the chain. Reports IQ-level rejections only — `MexError::ExtensionError`'s `code` is a GraphQL extension code, a different space from the IQ `code` attribute, so it is deliberately not reported here. * `http_status()` — added in PR #1195. Recovers the HTTP status code behind this error, if any, from a [`HttpStatusError`](#httpstatuserror) node anywhere in the chain. Populated by `Client::download`/`download_to_writer`/`download_from_params*`, `Client::upload`/`upload_stream`, `Client::fetch_sticker_pack`, and the internal app-version fetch behind `Client::connect` (`sw.js`, or the Facebook JS SDK bundle on `wasm32`; see [HTTP Client](/api/http-client#the-version-fetch-does-not-pool-a-connection)). Each of those attaches the status only when the client refused a *completed* HTTP exchange. `None` does not mean no HTTP exchange happened — a body that downloaded fine and then failed decryption or hash validation also reports `None`, since no refused status was ever attached. Treat `None` as "no typed refusal is on the chain," not as "nothing came back from the CDN," and inspect the underlying cause instead of assuming it is always the caller's own bug. Kept separate from `server_rejection()` — a CDN refusing a byte range and the chat server refusing a stanza are different layers with different remedies, so one accessor answering for both would report a number while hiding which thing to retry. * `is_timeout()` — whether the operation ran out of time: a request that got no answer, or a connect/handshake step that never completed. * `is_transport_unavailable()` — whether the failure was the transport being gone (disconnected, socket/channel closed) rather than the operation being refused. Mirrors the judgement the send and receive paths already make internally when deciding whether a failure is worth retrying. * `store_failure()` — the `StoreError` behind this error, if a persistence backend failed anywhere in the chain. ```rust theme={null} use whatsapp_rust::ErrorChainExt; match client.groups().set_description(&jid, Some(desc), PreviousDescription::Resolve).await { Ok(_) => {} Err(e) if e.is_transport_unavailable() => { /* retry once reconnected */ } Err(e) => { if let Some(rejection) = e.server_rejection() { eprintln!("server said {}: {}", rejection.code, rejection.text); } else { eprintln!("group update failed: {e}"); } } } ``` From a caller holding `anyhow::Error` rather than a typed error, annotate the cast — `anyhow::Error` has two `AsRef` impls, and both are covered: ```rust theme={null} let cause: &(dyn std::error::Error + 'static) = err.as_ref(); if cause.is_timeout() { /* ... */ } ``` `http_status()` follows the same pattern — useful for `download`/`upload`, which still return bare `anyhow::Error`: ```rust theme={null} use whatsapp_rust::ErrorChainExt; match client.download(downloadable).await { Ok(bytes) => { /* ... */ } Err(e) => { let cause: &(dyn std::error::Error + 'static) = e.as_ref(); match cause.http_status() { // 500/502/503/504 are the CDN statuses normally worth a retry; // 501/505 mean the request itself is unsupported and won't // succeed on a resend. Apply your own policy per status. Some(429) | Some(500) | Some(502) | Some(503) | Some(504) => { /* back off and retry later */ } Some(status) => eprintln!("CDN refused with {status}: {e}"), None => eprintln!("no HTTP refusal status recoverable, inspect the cause: {e}"), } } } ``` ### ServerRejection ```rust theme={null} #[non_exhaustive] pub struct ServerRejection<'a> { pub code: u16, pub text: &'a str, pub error_type: Option<&'a str>, pub backoff: Option, } ``` Borrowed from whichever error in the chain carried it, so recovering one costs no allocation. ### RejectionStanza Added in PR #1257. `IqError::ServerError`'s `response` field: the `` stanza the receive path decoded, kept as-is rather than reduced to the four fields [`ServerRejection`](#serverrejection) exposes above. It wraps the same `Arc` the success path already hands back, so attaching it to the error costs one refcount bump, not a copy. ```rust theme={null} pub struct RejectionStanza(Arc); impl RejectionStanza { /// The preserved node behind its refcount, for a caller that wants to keep or share it. pub fn as_arc(&self) -> &Arc; /// Takes the preserved node out, consuming the wrapper. pub fn into_arc(self) -> Arc; } impl From> for RejectionStanza { /* ... */ } impl Deref for RejectionStanza { type Target = OwnedNodeRef; } ``` `ServerRejection`'s four fields cover what WA Web's own `parseIqResponse` reads off an error; `RejectionStanza` is the escape hatch for everything that parser (and this crate's) leaves unread — further ``/`` attributes, `` children such as XMPP application-condition elements, and the raw bytes, which are the only faithful material for logging or replaying a rejection. `Deref` (see [`OwnedNodeRef`](/advanced/binary-protocol#ownednoderef-yoke-zero-copy)) keeps every node accessor reachable directly on the wrapper — `response.tag()`, `response.attrs()`, `response.get_optional_child(...)`, or `response.get()` for the underlying `NodeRef`. `Debug` is overridden to print only the tag (``), not the stanza's contents. This matters because background IQ failures on the connect path (the post-connect active IQ, props, blocklist, privacy settings) are logged with `{e:?}` at warn level, and an error stanza's attributes or children can carry a JID — a straight derive would have written that into production logs where before only the four summarized fields went. Read the node explicitly (`response.get()`, `response.attrs()`, …) when you need its contents. Re-exported from the crate root as `whatsapp_rust::RejectionStanza`, and from `whatsapp_rust::prelude`. ### HttpStatusError Added in PR #1195, in `whatsapp_rust::http`. Carries the status of an HTTP exchange the client refused — the source node `http_status()` looks for. ```rust theme={null} #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] #[error("HTTP status {status}")] pub struct HttpStatusError { pub status: u16, } ``` The download and upload paths attach this as the `source` of the `anyhow::Error` they return — `anyhow::Error::new(HttpStatusError { status }).context("Download failed with status: 403")` — so the message a caller logs is unchanged while the status becomes reachable by type via `http_status()` instead of only by parsing that message. Re-exported as `whatsapp_rust::http::HttpStatusError`. Rendering changed alongside the `source()` fix: a wrapping variant's `Display` still prints exactly what it wraps, so code that concatenates every node in a chain (a logging layer, a `display_chain` helper) now sees the same sentence repeated once per wrapping variant — e.g. `CommunityError::Group(GroupError::Iq(..))` is three nodes rendering one sentence three times. That repetition is the cost of keeping the wrapped error downcastable. Print the innermost cause, or collapse equal neighbours, rather than joining every node in the chain. `ErrorChainExt`, `ServerRejection`, and `Sources` are re-exported from the crate root (`whatsapp_rust::ErrorChainExt`, `whatsapp_rust::ServerRejection`, `whatsapp_rust::Sources`). `HttpStatusError` lives at `whatsapp_rust::http::HttpStatusError` — it is not re-exported from the crate root, since most callers only need `http_status()` and never need to name the type itself. ## Domain error types | Error type | Returned by | Module | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | --------------- | | `SendError` | `send_message`, `revoke_message`, `edit_message`, `pin_message`, `send_reaction`, and all other send-path methods | `whatsapp_rust` | | `GroupError` | All `Groups::*` methods | `whatsapp_rust` | | `BlockingError` | All `Blocking::*` methods | `whatsapp_rust` | | `BusinessError` | `Business::{get_catalog, get_collections, get_order, update_profile, set_cover_photo, remove_cover_photo}` | `whatsapp_rust` | | `AppStateError` | All `ChatActions::*` and `Labels::*` methods | `whatsapp_rust` | | `ChatStateError` | All `Chatstate::send*` methods | `whatsapp_rust` | | `CommunityError` | All `Community::*` methods | `whatsapp_rust` | | `ContactError` | All `Contacts::*` methods | `whatsapp_rust` | | `NewsletterError` | All `Newsletter::*` methods | `whatsapp_rust` | | `PollError` | `Polls::{create, create_quiz, vote, decrypt_vote, aggregate_votes}` | `whatsapp_rust` | | `ProfileError` | All `Profile::*` methods | `whatsapp_rust` | | `SignalError` | All `Signal::*` methods | `whatsapp_rust` | | `TcTokenError` | All `TcToken::*` methods | `whatsapp_rust` | | `MediaReuploadError` | `MediaReupload::request` | `whatsapp_rust` | | `PresenceError` | All `Presence::*` methods | `whatsapp_rust` | | `ConnectError` | `Client::connect`, `Client::wait_for_socket`, `Client::wait_for_connected` | `whatsapp_rust` | | `SignalMaintenanceError` | `Client::rotate_signed_pre_key`, `Client::flush_pending_signal_state` | `whatsapp_rust` | | `MessageEditError` | `EncryptedEdit::original_sender_jid`, `SecretEncrypted::original_sender_jid`, `SecretEncrypted::original_sender_for_dispatch` | `whatsapp_rust` | | `BotMessageError` | `wacore::bot_message::decrypt_bot_message` | `wacore` | ## Type definitions ### SendError ```rust theme={null} #[non_exhaustive] pub enum SendError { #[error("{0}")] Client(ClientError), #[error("client is not logged in")] NotLoggedIn, #[error("IQ request failed: {0}")] Iq(#[from] IqError), #[error("invalid send request: {0}")] InvalidRequest(String), #[error("{0}")] NoRecipientDevice(#[source] wacore::send::NoRecipientDeviceError), #[error("{0}")] PrimaryDeviceRejected(#[source] wacore::send::PrimaryDeviceRejected), #[error("{0}")] Internal(#[from] anyhow::Error), } ``` `NoRecipientDevice` — added in PR #1299. A DM had no recipient device available: every resolved recipient device failed encryption, or no recipient device resolved for a non-self destination, so nothing was sent. Distinct from a transport failure: the connection is fine and the message id was never on the wire. The useful retry is `SendOptions::default().with_device_freshness(Freshness::Refresh)` passed to [`send_message_with_options`](/api/send#send_message_with_options), which forces the recipient's device list to re-resolve instead of reading the cached one. An immediate resend with the same (cached) options would hit the same empty result. Wraps [`NoRecipientDeviceError`](#norecipientdeviceerror), the typed cause from `wacore`, reachable via `source()`. `PrimaryDeviceRejected` — added in [PR #1362](https://github.com/oxidezap/whatsapp-rust/pull/1362). The pre-key fetch that establishes sessions ahead of a DM send got back a `406` naming a primary device (device 0), either the recipient's or the sender's own, so nothing was built or sent. This mirrors WA Web's `ensureE2ESessions`, which throws on a named rejection unless every rejected device is a companion (`device != null && device !== DEFAULT_DEVICE_ID`). A companion's `406`, and any non-406 rejection code even on a primary, behave as before: refreshed and skipped, not fatal. The device lists the fetch named are already refreshed by the time this returns, so retry immediately — the retry re-resolves them instead of repeating the same question. This is distinct from `NoRecipientDevice`: that variant means encryption was attempted and failed, or had nothing to attempt, while `PrimaryDeviceRejected` means the fetch that would have supplied key material was refused outright, before encryption started. #### NoRecipientDeviceError Added in PR #1299, in `wacore::send`. The typed cause carried by `SendError::NoRecipientDevice`. ```rust theme={null} #[non_exhaustive] pub enum NoRecipientDeviceError { #[error("encryption failed for all {attempted} recipient device(s)")] EncryptionFailed { attempted: usize, #[source] source: anyhow::Error, }, #[error("no device resolved for the recipient")] Unresolved, } ``` **Variants:** * `EncryptionFailed` — every device resolved for the recipient was attempted and none produced an `` node. `attempted` is the device count; `source` is the first per-device failure (a missing session, a refused pre-key bundle), reachable via `source()`. * `Unresolved` — the fan-out held no device for the recipient to begin with, so nothing was attempted and there is no per-device cause. Only returned when the destination is not one of the sender's own identities — an empty recipient half for a self chat (note to self) is the normal shape, since every resolved device is the sender's own, and that case still returns `Ok`. Not returned when every one of the sender's *own* devices fails to encrypt in a self chat — that stays on the pre-existing `Internal` catch-all, since it isn't about a recipient at all. ### GroupError ```rust theme={null} #[non_exhaustive] pub enum GroupError { #[error("{0}")] Iq(#[from] IqError), #[error("{0}")] Mex(#[from] MexError), #[error("invalid group request: {0}")] InvalidRequest(String), #[error("the group description changed since it was read")] DescriptionConflict, #[error("{0}")] Internal(#[from] anyhow::Error), } ``` `DescriptionConflict` (added in PR #1097) is returned by [`Groups::set_description`](/api/groups#set_description) when the `prev` token no longer matches the group's current description — see [`GroupError` in the groups reference](/api/groups#grouperror). ### BlockingError ```rust theme={null} #[non_exhaustive] pub enum BlockingError { #[error("{0}")] Iq(#[from] IqError), #[error("invalid blocklist target: {0}")] InvalidJid(String), #[error("{0}")] Internal(#[from] anyhow::Error), } ``` ### BusinessError ```rust theme={null} #[non_exhaustive] pub enum BusinessError { #[error("MEX request failed")] Mex(#[from] MexError), #[error("IQ request failed")] Request(#[from] IqError), #[error("invalid business profile update")] InvalidUpdate(#[from] BusinessProfileUpdateError), #[error("malformed {operation} response: {detail}")] MalformedResponse { operation: &'static str, detail: String, }, } ``` `get_catalog`, `get_collections`, and `get_order` go over MEX; `update_profile`, `set_cover_photo`, and `remove_cover_photo` go over IQ. `InvalidUpdate` is client-side validation on `update_profile` — see [`BusinessProfileUpdateError` in the business reference](/api/business#validation) for every rejection reason. ### AppStateError Shared by `ChatActions` and `Labels`. ```rust theme={null} #[non_exhaustive] pub enum AppStateError { #[error("invalid app-state request: {0}")] InvalidRequest(String), #[error("{0}")] Internal(#[from] anyhow::Error), } ``` ### ChatStateError ```rust theme={null} #[non_exhaustive] pub enum ChatStateError { #[error("{0}")] Client(#[from] ClientError), } ``` ### CommunityError ```rust theme={null} #[non_exhaustive] pub enum CommunityError { #[error("{0}")] Iq(#[from] IqError), #[error("{0}")] Mex(#[from] MexError), #[error("{0}")] Group(#[from] GroupError), #[error("invalid community request: {0}")] InvalidRequest(String), } ``` ### ContactError ```rust theme={null} #[non_exhaustive] pub enum ContactError { #[error("{0}")] Iq(#[from] IqError), #[error("unsupported contact JID: {0}")] InvalidJid(String), #[error("{0}")] Username(#[from] UsernameLookupError), } ``` `Username` is returned by [`Contacts::find_by_username`](/api/contacts#find_by_username) when the given handle can't be turned into a valid username lookup (wrong length, or a username key usync's own validation rejects) — see [`UsernameLookupError` in the contacts reference](/api/contacts#error-handling). ### NewsletterError ```rust theme={null} #[non_exhaustive] pub enum NewsletterError { #[error("{0}")] Mex(#[from] MexError), #[error("{0}")] Iq(#[from] IqError), #[error("{0}")] Client(#[from] ClientError), #[error("invalid newsletter request: {0}")] InvalidRequest(String), #[error("{0}")] Internal(#[from] anyhow::Error), } ``` ### PollError ```rust theme={null} #[non_exhaustive] pub enum PollError { #[error("{0}")] Send(#[from] SendError), #[error("invalid poll: {0}")] InvalidPoll(String), #[error("client is not logged in")] NotLoggedIn, #[error("poll vote crypto failed: {0}")] Crypto(#[source] anyhow::Error), } ``` ### ProfileError ```rust theme={null} #[non_exhaustive] pub enum ProfileError { #[error("{0}")] Iq(#[from] IqError), #[error("{0}")] Client(#[from] ClientError), #[error("invalid argument: {0}")] InvalidArgument(String), #[error("{0}")] Internal(#[from] anyhow::Error), } ``` ### SignalError ```rust theme={null} #[non_exhaustive] pub enum SignalError { #[error("{0}")] Protocol(#[from] SignalProtocolError), #[error("unsupported signal operation: {0}")] Unsupported(String), #[error("{0}")] Internal(#[from] anyhow::Error), } ``` ### TcTokenError ```rust theme={null} #[non_exhaustive] pub enum TcTokenError { #[error("{0}")] Iq(#[from] IqError), #[error("{0}")] Store(#[from] StoreError), } ``` ### MediaReuploadError ```rust theme={null} #[non_exhaustive] pub enum MediaReuploadError { #[error("{0}")] Client(#[from] ClientError), #[error("client is not logged in")] NotLoggedIn, #[error("invalid media reupload request: {0}")] InvalidRequest(String), #[error("media retry notification timed out")] Timeout, #[error("{0}")] Internal(#[from] anyhow::Error), } ``` ### PresenceError ```rust theme={null} #[non_exhaustive] pub enum PresenceError { #[error("cannot send presence without a push name set")] PushNameEmpty, #[error("{0}")] Client(#[from] ClientError), #[error("{0}")] Other(#[from] anyhow::Error), } ``` ### ConnectError Returned by [`Client::connect`](/api/client#connect), [`Client::wait_for_socket`](/api/client#wait_for_socket), and [`Client::wait_for_connected`](/api/client#wait_for_connected). Added in PR #1090, replacing bare `anyhow::Error` on all three methods. As of PR #1258, `connect()`'s success case changed too — it resolves to `Result, ConnectError>` rather than `Result<(), ConnectError>`; see [`Connection`](/api/client#connection). ```rust theme={null} #[non_exhaustive] pub enum ConnectError { #[error("client is already connected")] AlreadyConnected, #[error("client construction did not activate")] NotActivated, #[error("client has been shut down")] Shutdown, #[error("client is paused")] Paused, #[error("{stage} timed out after {timeout:?}")] Timeout { stage: ConnectStage, timeout: std::time::Duration, }, #[error("failed to resolve app version")] Version(#[source] anyhow::Error), #[error("failed to open transport")] Transport(#[source] anyhow::Error), #[error("{0}")] Handshake(#[from] crate::handshake::HandshakeError), } ``` **Variants:** * `AlreadyConnected` — a connection is already up, or another `connect()` attempt is already in flight. This is the old `ClientError::AlreadyConnected` case, moved here. * `NotActivated` — construction never completed (only reachable with the `client-lifecycle` feature), so the attempt was rejected before any I/O. * `Shutdown` — added in PR #1258. The client was already shut down (`disconnect()`, `logout()`, or `signal_shutdown_sync()`) before or during this `connect()` attempt. Shutdown is final and non-reversible, so this is refused rather than reviving a client the application was already told is gone — build a new client instead of reconnecting this one. * `Paused` — added in PR #1265. [`Client::pause`](/api/client#pause) is in effect. Unlike `Shutdown` this is not final: [`Client::resume`](/api/client#resume) lifts it and `connect()` works again. Re-checked at every step of the connect graph (version fetch, transport open, handshake, publish), so an attempt already in flight when `pause()` lands is retracted rather than published, not just refused for attempts that start after. * `Timeout` — a step of the connect flow ran out of time. `stage` says which one (see [`ConnectStage`](#connectstage) below); `wait_for_socket`/`wait_for_connected` always report `Socket`/`Ready` respectively. * `Version` / `Transport` — the app-version resolution or transport factory failed outright (not a timeout). As of PR #1195, if `Version` was caused by a non-2xx response fetching the app version (`sw.js`, or the Facebook JS SDK bundle on `wasm32`), the status is recoverable via `err.http_status()` ([`ErrorChainExt`](#error-chain-recovery)) instead of only appearing in the message. As of PR #1360, `Version` is no longer necessarily the outcome of a failed fetch on the `wasm32` target: that target's source is survivable, so a client blocked from reaching it connects on a fallback version instead — see [Connected](/concepts/events#connected). The native target's source stays fatal, so `Version` there is unchanged. * `Handshake` — the Noise handshake failed after the transport was up. Wraps `HandshakeError` (see [WebSocket & Noise Protocol](/advanced/websocket-handling#handshake-errors)) via `#[from]`, so `?` still works and `matches!(err, ConnectError::Handshake(e) if e.is_transient())` replaces the old `err.downcast_ref::()` pattern for deciding whether a failed reconnect attempt is worth retrying. Added in PR #1100: `ConnectError::is_timeout()` is an exhaustively-matched method that reports `true` for `Timeout` and for a `Handshake(e)` where `e.is_timeout()` (`HandshakeError` gained the same method). Prefer [`ErrorChainExt::is_timeout()`](#error-chain-recovery) unless you specifically hold a `ConnectError` and want to skip the chain walk. #### ConnectStage The step of the connect flow a `ConnectError::Timeout` refers to: ```rust theme={null} #[non_exhaustive] pub enum ConnectStage { /// Resolving the app version advertised to the server. VersionFetch, /// Opening the underlying transport. Transport, /// Waiting for the noise socket, which is ready before login. Socket, /// Waiting for login plus the critical app state sync to finish. Ready, } ``` Both `ConnectError` and `ConnectStage` are re-exported from the crate root (`whatsapp_rust::ConnectError`, `whatsapp_rust::ConnectStage`) and from `whatsapp_rust::prelude`. ### SignalMaintenanceError Returned by [`Client::rotate_signed_pre_key`](/api/client#signed-pre-key-rotation) and [`Client::flush_pending_signal_state`](/api/client#flush_pending_signal_state). Added in PR #1090, replacing bare `anyhow::Error` on both methods. The split that matters to a caller is corruption versus everything else: `CorruptKey` will keep failing until the stored material is replaced, while `Storage`, `Iq`, `Signal`, and `DrainCommitFailed` are worth retrying on the same client — none of them mean the local key material itself is bad. `DrainShuttingDown` is different again — it fires because the client itself is being torn down, so retrying the same call on that instance fails the same way; the only recovery is a fresh client. ```rust theme={null} #[non_exhaustive] pub enum SignalMaintenanceError { #[error("corrupt signed pre-key material: {0}")] CorruptKey(String), #[error("signal storage failure: {0}")] Storage(#[source] anyhow::Error), #[error("IQ request failed: {0}")] Iq(#[from] IqError), #[error("{0}")] Signal(#[from] SignalProtocolError), #[error( "inbound drain batch commit failed; Signal cache left unflushed so the server redelivers" )] DrainCommitFailed, #[error("client dropping while inbound drain is active; skipping Signal flush")] DrainShuttingDown, } ``` **Variants:** * `CorruptKey` — key material is unusable: bad encoding, or a missing/wrong-sized field on a staged signed pre-key. Almost always means a retry would read back the same bytes, so this is not worth retrying without intervention. * `Storage` — the storage backend failed a read, write, or flush. The typed backend error stays reachable via `std::error::Error::source()`. * `Iq` — the rotation IQ was rejected by the server or never reached it (embeds [`IqError`](#iqerror-base-type) via `#[from]`). * `Signal` — a Signal primitive failed (e.g. signing the new signed pre-key). * `DrainCommitFailed` — the inbound drain batch could not be committed, so the Signal cache was deliberately left unflushed and the server will redeliver. * `DrainShuttingDown` — the client is going away while an inbound drain is active; flushing would persist ratchet advances whose messages have no durable row. Not worth retrying on this client — it will report the same error until the client is dropped and replaced. `SignalMaintenanceError` is re-exported from the crate root as `whatsapp_rust::SignalMaintenanceError`. `Signal::*` methods on the [`Signal`](/api/signal) struct are unaffected — `SignalError` already had `Internal`/`Protocol` variants and now converts `SignalMaintenanceError` via `From` (mapping `Signal(e)` to `SignalError::Protocol(e)` and everything else to `SignalError::Internal`). ### MessageEditError Returned by `EncryptedEdit::original_sender_jid`, `SecretEncrypted::original_sender_jid`, and `SecretEncrypted::original_sender_for_dispatch` — the target-key sender resolvers used when decrypting `secret_encrypted_message` envelopes (message edits, poll edits/add-option, event edits). Added in PR #1090, replacing bare `anyhow::Error`. See [Decrypting secret-encrypted envelopes](/api/polls#decrypting-secret-encrypted-envelopes) for how these methods are used. ```rust theme={null} #[non_exhaustive] pub enum MessageEditError { #[error("invalid {field} in target message key")] InvalidTargetJid { field: &'static str, #[source] source: JidError, }, #[error("target message key missing participant and remote_jid")] MissingTargetSender, } ``` **Variants:** * `InvalidTargetJid` — a JID carried by the target message key did not parse. `field` names the offending wire field (`"participant"` or `"remoteJid"`); the underlying `JidError` is available via `source()`. * `MissingTargetSender` — the target key carried neither `participant` nor `remote_jid`, and `from_me` was not `Some(true)`, so no author can be derived from it. Both variants mean the peer sent a target message key that cannot be attributed — retrying the same envelope yields the same result. `MessageEditError` is re-exported from the crate root as `whatsapp_rust::MessageEditError`. ### BotMessageError Returned by `wacore::bot_message::decrypt_bot_message` and its private decryption helper. Added in PR #1261, replacing bare `anyhow::Result`. Unlike every other error type on this page, `BotMessageError` lives in the `wacore` crate, not `whatsapp_rust` — and its introduction is **a breaking change to `wacore`'s public API**: `decrypt_bot_message`'s return type changed from `anyhow::Result` to `Result`, so a caller matching on the previous `anyhow::Error` needs to switch to this typed enum. ```rust theme={null} #[non_exhaustive] pub enum BotMessageError { InvalidSecretLength, InvalidIvLength, PayloadTooShort, KeyDerivation, AuthenticationFailed, } ``` **Variants:** * `InvalidSecretLength` — the bot message secret is not the expected size. * `InvalidIvLength` — the IV carried by the payload is not the expected size. * `PayloadTooShort` — the payload is too short to contain what it claims to. * `KeyDerivation` — deriving the decryption key from the secret failed. * `AuthenticationFailed` — the ciphertext did not verify (AES-GCM tag mismatch). `BotMessageError::stage(&self) -> BotMessageFailure` classifies which stage of decryption a failure belongs to, without matching every variant by name: ```rust theme={null} pub enum BotMessageFailure { Envelope, Secret, Authentication, } ``` Use `stage()` when you only care whether the envelope, the secret, or the authentication step failed — for metrics or a coarse retry policy — rather than the specific `BotMessageError` variant. ### ClientError (base type) ```rust theme={null} #[non_exhaustive] pub enum ClientError { #[error("client is not connected")] NotConnected, #[error("client is not logged in")] NotLoggedIn, #[error("socket error: {0}")] Socket(SocketError), #[error("encrypt/send error: {0}")] EncryptSend(EncryptSendError), #[error("IQ request failed: {0}")] Iq(#[from] IqError), #[error("{0}")] Internal(#[from] anyhow::Error), } ``` `AlreadyConnected` was removed from `ClientError` in PR #1090 — it had no remaining constructor once `Client::connect()` moved to [`ConnectError`](#connecterror). If you matched on `ClientError::AlreadyConnected`, switch to `ConnectError::AlreadyConnected`. ### IqError (base type) ```rust theme={null} #[non_exhaustive] pub enum IqError { #[error("IQ request timed out")] Timeout, #[error("client is not connected")] NotConnected, #[error("socket error")] Socket(#[from] SocketError), #[error("encrypted send pipeline failed")] EncryptSend(#[from] EncryptSendError), #[error("client state prevented send")] ClientState(#[source] Box), // boxed to break the ClientError<->IqError cycle #[error("received disconnect node during IQ wait: {0:?}")] Disconnected(Box), #[error("received a server error response: code={code}, text='{text}'")] ServerError { code: u16, text: String, error_type: Option, backoff: Option, response: RejectionStanza, // added in PR #1257 }, #[error("received unexpected IQ response type: {got:?}")] UnexpectedResponseType { got: Option }, #[error("internal channel closed unexpectedly")] InternalChannelClosed, #[error("IQ request ID is already in flight: {0}")] DuplicateRequestId(String), #[error("failed to encode IQ request")] EncodeError(#[source] anyhow::Error), #[error("failed to parse IQ response")] ParseError(#[from] anyhow::Error), } ``` `IqError::ClientState` holds a `Box` (not `ClientError` directly) to break the mutual-size cycle between `ClientError` and `IqError`. Pattern matching needs dereferencing: `IqError::ClientState(e) => { /* *e is a ClientError */ }`. Added in PR #1100: `wacore::request::IqError` gained public `is_timeout()` (`true` only for `Timeout`) and `is_transport_unavailable()` (`true` for `NotConnected`, `Disconnected`, and `InternalChannelClosed`) methods, each an exhaustive match so a future variant has to be classified rather than silently defaulting to `false`. `whatsapp_rust::request::IqError` (the crate-level type shown above, with the extra `Socket`/`EncryptSend`/`ClientState`/`EncodeError`/`ParseError` variants) makes the same judgement internally but does not expose it publicly — go through [`ErrorChainExt`](#error-chain-recovery) instead, which handles both types. Added in PR #1257: `ServerError` carries `response: RejectionStanza` — see [`RejectionStanza`](#rejectionstanza) above and the [migration note](#from-pr-1257-servererror-carries-the-rejection-stanza) below. Matching with `..` is unaffected by this field. A match that already names all four former fields without `..` needs `..` added (or `response` bound too) — Rust rejects a struct pattern missing a field with `E0027`. Constructing the variant by hand (mainly test fixtures) needs updating the same way. ## Migration guide ### From `anyhow::Error` If your handler used `?` into `anyhow`: ```rust theme={null} // Before — still compiles unchanged async fn my_fn() -> anyhow::Result<()> { client.send_message(jid, msg).await?; // SendError: Into Ok(()) } ``` If you were matching on `anyhow::Error` downcasts, switch to typed matching: ```rust theme={null} // Before match client.send_message(jid, msg).await { Ok(r) => { /* ... */ } Err(e) => eprintln!("send failed: {e}"), } // After — match specific variants use whatsapp_rust::SendError; match client.send_message(jid, msg).await { Ok(r) => { /* ... */ } Err(SendError::NotLoggedIn) => eprintln!("not authenticated"), Err(SendError::Iq(e)) => eprintln!("IQ failed: {e}"), Err(SendError::InvalidRequest(msg)) => eprintln!("bad request: {msg}"), Err(e) => eprintln!("send failed: {e}"), } ``` ### IqError::ClientState boxing ```rust theme={null} // Before Err(IqError::ClientState(e)) => { /* e: ClientError */ } // After — dereference the box Err(IqError::ClientState(e)) => { /* *e: ClientError */ } ``` ### From PR #1090: `connect()`/`logout()`/Signal maintenance ```rust theme={null} // Before if let Err(e) = client.connect().await { if e.downcast_ref::().is_some_and(|e| matches!(e, ClientError::AlreadyConnected)) { // ... } } client.logout().await?; // After use whatsapp_rust::ConnectError; if let Err(e) = client.connect().await { if matches!(e, ConnectError::AlreadyConnected) { // ... } } client.logout().await; // infallible — no `?` needed anymore ``` As of PR #1258, `connect()`'s `Ok` case changed too — this snippet only shows the error-matching migration, so it still discards a successful connection. See the [`connect()` migration](/api/client#connect) for the full picture: ```rust theme={null} use whatsapp_rust::ConnectError; match client.connect().await { Ok(connection) => { connection.read_until_disconnected().await; } Err(ConnectError::AlreadyConnected) => { // ... } Err(e) => { /* ... */ } } client.logout().await; ``` ### From PR #1100: error chain recovery If you were walking `source()` by hand to recover a server rejection or classify a failure, switch to [`ErrorChainExt`](#error-chain-recovery). This is the shape the crate's own internal helper had before PR #1100 (`ClientError::is_transport_unavailable` is public; the `IqError` half required a call this crate could make on its own type but a downstream consumer could not, since it wasn't exposed): ```rust theme={null} // Before (internal to this crate — the IqError check isn't public API) fn is_transport_unavailable(err: &anyhow::Error) -> bool { err.chain().any(|cause| { cause .downcast_ref::() .is_some_and(ClientError::is_transport_unavailable) || cause .downcast_ref::() .is_some_and(|iq| matches!(iq, IqError::NotConnected | IqError::Disconnected(_) | IqError::InternalChannelClosed)) }) } // After — works the same from inside or outside the crate use whatsapp_rust::ErrorChainExt; fn is_transport_unavailable(err: &anyhow::Error) -> bool { let cause: &(dyn std::error::Error + 'static) = err.as_ref(); cause.is_transport_unavailable() } ``` If you concatenated an error's full chain (e.g. a logging layer joining every `source()` node into one string), be aware the text changed: a wrapping variant still renders exactly what it wraps, so `#[error(transparent)]` becoming `#[error("{0}")]` means a chain like `CommunityError::Group(GroupError::Iq(..))` now repeats the same sentence once per wrapping node instead of once. Each error's own `Display` output — what you get from `{e}` on a single error value — is unchanged. Print the innermost cause, or collapse equal neighbours, rather than joining every node. `#[non_exhaustive]` was added to four error enums that were missing it: `wacore::pair_code::PairCodeError`, `wacore::shortcake::ShortcakeError`, `wacore::iq::chatstate::ChatstateParseError`, and `wacore::iq::dirty::DirtyBitParseError`. An exhaustive `match` on any of these from outside their defining crate now fails with `E0004`; add a `_ => {}` arm. ### From PR #1195: recovering an HTTP status by type If you were matching on the text of a download/upload error to classify it (e.g. `e.to_string().contains("403")`), switch to [`ErrorChainExt::http_status()`](#error-chain-recovery): ```rust theme={null} // Before — fragile string matching match client.download(downloadable).await { Err(e) if e.to_string().contains("401") || e.to_string().contains("403") => { // ... } Err(e) => eprintln!("download failed: {e}"), Ok(bytes) => { /* ... */ } } // After use whatsapp_rust::ErrorChainExt; match client.download(downloadable).await { Err(e) => { let cause: &(dyn std::error::Error + 'static) = e.as_ref(); match cause.http_status() { Some(401) | Some(403) => { /* ... */ } Some(status) => eprintln!("download refused with {status}: {e}"), None => eprintln!("download failed: {e}"), } } Ok(bytes) => { /* ... */ } } ``` Messages are unchanged either way — `http_status()` is purely additive, recovering a fact that was already in the text but previously reachable only by parsing it. ### From PR #1257: `ServerError` carries the rejection stanza `IqError::ServerError` gained a `response: RejectionStanza` field carrying the `type="error"` stanza verbatim, alongside the four fields it already parsed off it. Matching with `..` is unaffected — this is the common case, and nearly every match in the codebase already used it: ```rust theme={null} // Still works unchanged match err { IqError::ServerError { code, text, .. } => { /* ... */ } // ... } ``` If you matched all four former fields by name without `..`, the pattern now fails to compile with `E0027` ("pattern does not mention field `response`") — add `..`, or bind `response` too: ```rust theme={null} // Before — breaks with E0027 once `response` is added IqError::ServerError { code, text, error_type, backoff } => { /* ... */ } // After IqError::ServerError { code, text, error_type, backoff, .. } => { /* ... */ } ``` Constructing the variant by hand — mainly test fixtures — now needs the stanza it was rejected with: ```rust theme={null} // Before IqError::ServerError { code, text, error_type, backoff, } // After — pass the response the rejection came with IqError::ServerError { code, text, error_type, backoff, response: response.into(), // response: Arc, RejectionStanza converts via `.into()` } ``` A fixture with no real wire response can build one directly, but `OwnedNodeRef::new` expects node bytes with the format byte already stripped, not the raw output of `marshal` — the same [`unpack`](/advanced/binary-protocol#the-format-byte) step the receive path runs ahead of every `OwnedNodeRef::new` call: ```rust theme={null} let node = wacore_binary::builder::NodeBuilder::new("iq") .attr("type", "error") .children([/* ... */]) .build(); let packed = wacore_binary::marshal::marshal(&node).expect("marshals"); let node_bytes = wacore_binary::util::unpack(&packed).expect("unpacks").into_owned(); let response: Arc = Arc::new(OwnedNodeRef::new(node_bytes).expect("decodes")); ``` `From for whatsapp_rust::request::IqError` is also removed — a bare `From` conversion has no response available to attach, which is exactly the material this change stops discarding. Replace it with `IqError::from_response`: ```rust theme={null} // Before let err: IqError = wacore_err.into(); // After — pass the response the classification failure was read from let err = IqError::from_response(wacore_err, &response); ``` ### From PR #1261: `decrypt_bot_message` returns a typed error If you were matching on `anyhow::Error` from `wacore::bot_message::decrypt_bot_message`, switch to [`BotMessageError`](#botmessageerror): ```rust theme={null} // Before match decrypt_bot_message(message_secret, enc_iv, enc_payload, &ctx) { Ok(plaintext) => { /* ... */ } Err(e) => eprintln!("bot message decrypt failed: {e}"), } // After — match specific variants, or classify by stage use wacore::bot_message::{BotMessageError, BotMessageFailure}; match decrypt_bot_message(message_secret, enc_iv, enc_payload, &ctx) { Ok(plaintext) => { /* ... */ } Err(e @ BotMessageError::AuthenticationFailed) => eprintln!("tampered or wrong secret: {e}"), Err(e) => match e.stage() { BotMessageFailure::Envelope => eprintln!("malformed envelope: {e}"), BotMessageFailure::Secret => eprintln!("bad secret: {e}"), BotMessageFailure::Authentication => eprintln!("authentication failed: {e}"), }, } ``` ### From PR #1299: a DM with no recipient device now returns a typed error Previously, a DM where every recipient device failed to encrypt could still return `Ok` if one of the sender's own companion devices succeeded — the stanza went out carrying only the sender's own devices, the server acked it, and the caller had no way to tell the message never reached its recipient. That case, and the case where the fan-out resolved no recipient device at all (for a non-self destination), now return `Err(SendError::NoRecipientDevice(..))` instead: ```rust theme={null} // Before — `Ok` did not guarantee the recipient could read this message match client.send_message(to.clone(), message).await { Ok(result) => { /* looked like success even with no recipient device reached */ } Err(e) => eprintln!("send failed: {e}"), } // After — match the new variant to detect the specific failure use whatsapp_rust::{Freshness, SendError, send::SendOptions}; match client.send_message(to.clone(), message.clone()).await { Ok(result) => { /* ... */ } Err(SendError::NoRecipientDevice(e)) => { eprintln!("nothing reached the recipient: {e}"); // Force a device-list refresh before retrying — an immediate resend // with the same (cached) options would hit the same empty result. let options = SendOptions::default().with_device_freshness(Freshness::Refresh); client.send_message_with_options(to.clone(), message, options).await?; } Err(e) => eprintln!("send failed: {e}"), } ``` `SendError` is `#[non_exhaustive]`, so this new variant does not break a `match` that already ends in a catch-all arm. See [`NoRecipientDeviceError`](#norecipientdeviceerror) for the two variants it can carry. ### From PR #1362: a DM's partial fan-out is now visible, and a primary's 406 now fails the send Three related changes, all in [#1362](https://github.com/oxidezap/whatsapp-rust/pull/1362): `SendResult` gains `recipient_fanout: Option` (see [`RecipientFanout`](/api/send#recipientfanout)), populated for a DM. A caller that treated `Ok` as full delivery can now check it instead of assuming: ```rust theme={null} // Before — Ok told you the stanza was sent, nothing about who got it // (send_message does not wait for the server's ack before returning) let result = client.send_message(to.clone(), message).await?; // After — inspect the DM fan-out when the caller's policy cares let result = client.send_message(to.clone(), message).await?; if result.recipient_fanout.as_ref().is_some_and(|f| f.is_partial()) { eprintln!("message {} did not reach every device", result.message_id); } ``` `SendError` gains `PrimaryDeviceRejected` (see above). A pre-key fetch that gets a `406` naming a primary device now fails the send instead of silently continuing with zero established sessions for it: ```rust theme={null} match client.send_message(to.clone(), message).await { Ok(result) => { /* ... */ } Err(SendError::PrimaryDeviceRejected(e)) => { // The named device lists are already refreshed — retry immediately. eprintln!("a primary device was rejected fetching pre-keys: {e}"); } Err(e) => eprintln!("send failed: {e}"), } ``` Separately, a DM phash mismatch (see [Phash validation](/advanced/signal-protocol#phash-validation-for-stale-device-list-detection)) now re-resolves the peer's device list and resends the message, under the original message id, to any device that list holds and the original stanza did not cover. Previously a mismatch only invalidated caches. This has no `SendError`/`SendResult` shape to match on — it runs after the original `send_message` call has already returned. Each newly discovered device receives the message for the first time; a device the original stanza already covered is left untouched, so this never delivers a duplicate to the same device. Both new variants are additive at the type level: `SendError` and `SendResult` are `#[non_exhaustive]`, so a `match` or destructuring pattern written against the old shape still compiles. `recipient_fanout` is purely additive at runtime too — ignoring it changes nothing. `PrimaryDeviceRejected` is not: a caller whose `match` ends in a catch-all `Err` arm still compiles and still runs, but a named-primary `406` now takes that arm instead of the `Ok` path it took before, so this is a real behavior change for that one case, just not a breaking one. ### From PR #1406: edit, revoke, and pin now return the `SendResult` they built [#1406](https://github.com/oxidezap/whatsapp-rust/pull/1406) is a real breaking change, not an additive one — it changes the `Ok` type of six methods so a caller that shares one `Client` between several consumers can see what a self-built send (an edit, a revoke, a pin, a poll) actually put in the chat. WhatsApp echoes a send to every device on the account except the one that sent it, so without this a consumer other than the sender had nothing to show for those sends but an id. `SendResult` gains `message: Arc` (see [`SendResult`](/api/send#sendresult)) — the message exactly as the send pipeline encoded it. `edit_message`, `edit_message_with_options`, and `edit_message_encrypted` now return `SendResult` instead of `String`; `revoke_message`, `pin_message`, and `unpin_message` now return `SendResult` instead of `()`. `MessageContext::edit_message` and `MessageContext::revoke_message` follow the same change. ```rust theme={null} // Before let edit_id: String = client.edit_message(&chat_jid, &original_id, new_content).await?; client.revoke_message(&chat_jid, &message_id, RevokeType::Sender).await?; client.pin_message(chat_jid.clone(), key.clone(), PinDuration::Days7).await?; // After — the id moves onto `message_id`, and `message` is what this crate built let edit = client.edit_message(&chat_jid, &original_id, new_content).await?; let edit_id: &str = &edit.message_id; let revoke = client.revoke_message(&chat_jid, &message_id, RevokeType::Sender).await?; let pin = client.pin_message(chat_jid.clone(), key.clone(), PinDuration::Days7).await?; ``` A call site that used `?` without binding the `Ok` value (`client.revoke_message(...).await?;`) compiles unchanged — only code that named the previous `String` or `()` result needs to change. `SendResult` losing its `Eq` derive (`wa::Message` carries floats; `PartialEq` and `Clone` are unaffected) only matters to code that put a `SendResult` in a `HashSet`/`BTreeSet` key position or otherwise required `Eq`. Separately, `wacore::proto_helpers::MessageExt::prepare_for_forward` now returns `wa::Message` by value instead of `Box` — see the [forwarding-preparation note](/guides/sending-messages#preparing-messages-for-forwarding). `prepare_for_quote` is unchanged. Newsletter `edit_message`/`revoke_message` (`client.newsletter().edit_message(...)`) are untouched and still return `()`: the plaintext channel path sends a node under the target's own id with the caller's body, so there is neither a fresh id nor a built message to report. # Events Source: https://whatsapp-rust.jlucaso.com/api/events Create WhatsApp event messages and collect encrypted RSVPs The `Events` feature creates WhatsApp **event** messages — the scheduled-event bubble with a date, location, and join link. It also sends RSVPs. Like polls, an event carries a per-message `message_secret`. Responders derive their RSVP encryption key from that secret. Keep the secret so you can send and decrypt responses later. ## Access Access event operations through the client: ```rust theme={null} let events = client.events(); ``` ## Methods ### create Create and send an event message. ```rust theme={null} pub async fn create( &self, to: &Jid, params: EventCreationParams, ) -> Result<(SendResult, Vec)> ``` Recipient JID — a direct message or group chat. Event details. Only `name` is required; everything else is optional. See [EventCreationParams](#eventcreationparams). A [`SendResult`](/api/send#sendresult) and the event's 32-byte `message_secret`. **Store the secret** — it is required to decrypt the RSVPs that come back. A fresh secret is generated per event and attached as `messageContextInfo.messageSecret`; WhatsApp Web rejects an event without one. **Example:** ```rust theme={null} use whatsapp_rust::features::EventCreationParams; let chat: Jid = "120363012345678@g.us".parse()?; let (sent, secret) = client.events().create(&chat, EventCreationParams { name: "Team offsite".to_string(), description: Some("Bring a laptop".to_string()), start_time: Some(1_760_000_000), // unix seconds end_time: Some(1_760_010_000), location: None, extra_guests_allowed: Some(true), ..Default::default() }).await?; // Persist `secret` keyed by `sent.message_id` so you can decrypt RSVPs later. println!("Event sent: {}", sent.message_id); ``` ### respond RSVP to an event. The RSVP is end-to-end encrypted against the event's `message_secret`. ```rust theme={null} pub async fn respond( &self, chat_jid: &Jid, event_msg_id: &str, event_creator_jid: &Jid, message_secret: &[u8], response: EventResponseType, extra_guest_count: Option, ) -> Result ``` Chat JID where the event was sent. Message ID of the event creation message. JID of the user who created the event. It keys the RSVP's encryption derivation and AAD, so it must match the creator exactly. The 32-byte secret from the event creation message. `Going`, `NotGoing`, or `Maybe`. See [EventResponseType](#eventresponsetype). Number of additional guests you're bringing, when the event allows extra guests. Result of the RSVP send. See [SendResult](/api/send#sendresult). **Example:** ```rust theme={null} use whatsapp_rust::features::EventResponseType; let creator: Jid = "15551234567@s.whatsapp.net".parse()?; client.events() .respond( &chat, &event_msg_id, &creator, &message_secret, EventResponseType::Going, Some(2), // bringing two guests ) .await?; ``` The responder (self) JID also keys the derivation, so it must use the creator's namespace: your own LID for a LID-addressed event, your PN otherwise (falling back to PN when your LID isn't known). The library resolves this for you, mirroring the poll-vote path. ## Decrypting inbound RSVPs When you receive an `EncEventResponseMessage`, decrypt it with the event's secret using the helpers in `wacore::event`: ```rust theme={null} use wacore::event::decrypt_event_response_with_secret; let response = decrypt_event_response_with_secret( &enc_payload, &iv, &message_secret, // the secret you stored at create() time &event_msg_id, &event_creator_jid, // creator (non-AD form) &responder_jid, // who sent the RSVP (non-AD form) )?; println!("RSVP: {:?}", response.response); // Going / NotGoing / Maybe println!("Extra guests: {:?}", response.extra_guest_count); ``` | Function | Description | | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `encrypt_event_response_with_secret(response, secret, stanza_id, creator, responder) -> (Vec, [u8; 12])` | Encrypt an `EventResponseMessage` against the event secret. Returns `(payload_with_tag, iv)`. | | `decrypt_event_response_with_secret(payload, iv, secret, stanza_id, creator, responder) -> EventResponseMessage` | Decrypt an inbound RSVP. The creator + responder JIDs must match what the responder used, or decryption fails (rather than silently mis-decrypting). | Both are thin wrappers over the shared `secret_enc_addon` machinery specialized for the `"Event Response"` use-case, matching WhatsApp Web's `WAWebAddonEncryption`. ## Types ### EventCreationParams ```rust theme={null} #[derive(Debug, Clone, Default)] pub struct EventCreationParams { pub name: String, pub description: Option, pub start_time: Option, pub end_time: Option, pub join_link: Option, pub location: Option, pub is_scheduled_call: Option, pub extra_guests_allowed: Option, } ``` | Field | Type | Description | | ---------------------- | ------------------------- | ------------------------------------------ | | `name` | `String` | Event title (required; must not be empty). | | `description` | `Option` | Free-text description. | | `start_time` | `Option` | Start time in unix seconds. | | `end_time` | `Option` | End time in unix seconds. | | `join_link` | `Option` | Call/join link. | | `location` | `Option` | Event location. | | `is_scheduled_call` | `Option` | Mark the event as a scheduled call. | | `extra_guests_allowed` | `Option` | Allow responders to bring extra guests. | ### EventResponseType `whatsapp_rust` re-exports this enum from the generated proto. ```rust theme={null} pub enum EventResponseType { Unknown, // 0 Going, // 1 NotGoing, // 2 Maybe, // 3 } ``` ## Error handling All methods return `Result`. Common errors: * **Empty event name** when calling `create`. * **Not logged in** — the responder's own JID can't be determined. * **`message_secret` not 32 bytes** — encryption/decryption rejects the wrong size. * **GCM tag verification failed** — wrong secret, wrong creator/responder JID, or a tampered payload. ## See also * [Polls](/api/polls) — the closest analog; also uses a per-message secret * [Send API](/api/send) — low-level send operations * [Events](/concepts/events) — event types emitted on receive # Groups Source: https://whatsapp-rust.jlucaso.com/api/groups Group management operations - create, modify, and manage WhatsApp groups The `Groups` struct provides methods for managing WhatsApp groups, including creating groups, managing participants, and modifying group settings. ## Access Access group operations through the client: ```rust theme={null} let groups = client.groups(); ``` ## Methods ### query\_info Query group information with caching support. `query_info` and [`get_metadata`](#get_metadata) return different, purpose-built views of a group — pick based on what you're doing with the result: | | `query_info` | `get_metadata` | | ------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Returns | `Arc` (slim) | `GroupMetadata` (full) | | Fields | Participant JIDs, addressing mode, LID/PN mapping, CAG flag | Subject, description, admin roles, ephemeral/membership settings, and everything else | | Cache | Cached; a hit is free, a miss sends the persisted phash so an unchanged group costs a `not-modified` reply | Hits the network; no phash sent, no population of the group cache. Concurrent calls for the same group share one round trip — see [`get_metadata`](#get_metadata) | | Use for | Routing and encrypting a message (the send path's own choice) | Displaying or auditing a group | Use [`query_info_with_freshness`](#query_info_with_freshness) when you need explicit control over `query_info`'s staleness instead of the default cache-preferred behavior. ```rust theme={null} pub async fn query_info(&self, jid: &Jid) -> Result, GroupError> ``` **Parameters:** * `jid` - Group JID (must end with `@g.us`) **Returns:** * `Arc` - Shared, reference-counted snapshot containing the participants list and addressing mode. Repeated calls for the same group return the same `Arc` from the in-memory cache, so warm sends avoid deep-cloning group metadata. **Example:** ```rust theme={null} use std::sync::Arc; use wacore::client::context::GroupInfo; let group_jid: Jid = "123456789@g.us".parse()?; let info: Arc = client.groups().query_info(&group_jid).await?; println!("Participants: {}", info.participants.len()); println!("Addressing mode: {:?}", info.addressing_mode); // Cheaply share the snapshot across tasks without copying participants. let info_for_task = info.clone(); tokio::spawn(async move { println!("Participants in task: {}", info_for_task.participants.len()); }); ``` ### `query_info_with_freshness` Query group information with an explicit cache [`Freshness`](#freshness) policy, instead of the always-cache-preferred behavior of `query_info`. ```rust theme={null} pub async fn query_info_with_freshness( &self, jid: &Jid, freshness: Freshness, ) -> Result, GroupError> ``` **Parameters:** * `jid` - Group JID (must end with `@g.us`) * `freshness` - `Freshness::CachePreferred` (same as `query_info`) or `Freshness::Refresh` to force a network round-trip **Returns:** * `Arc` - Same shared snapshot type as `query_info` A `Refresh` query leaves the currently cached snapshot readable to concurrent callers while the network request is in flight, then atomically replaces it once the response arrives — there is no window where the cache is empty. **Example:** ```rust theme={null} use whatsapp_rust::Freshness; // Force a refresh after a notification implies the cached snapshot is stale. let info = client .groups() .query_info_with_freshness(&group_jid, Freshness::Refresh) .await?; ``` ### get\_participating Get all groups the client is participating in. ```rust theme={null} pub async fn get_participating(&self) -> Result, GroupError> ``` **Returns:** * `HashMap` - Map of group `Jid` to metadata This map is keyed by `Jid`. Call `.to_string()` on the key if you need the string form. `GroupMetadata` implements `PartialEq` and `Eq`, allowing direct comparison of group metadata instances. **GroupMetadata fields:** * `id: Jid` - Group JID * `subject: String` - Group name * `notify: Option` - Display notification string reported by the server (from the `notify` attribute) * `participants: Vec` - List of participants * `addressing_mode: AddressingMode` - Phone number or LID mode * `creator: Option` - Group creator JID * `creator_pn: Option` - Creator's phone-number JID, when `creator` is a LID * `creator_username: Option` - Creator's Meta username, when present * `creator_country_code: Option` - Creator's ISO country code, when present * `creation_time: Option` - Group creation timestamp (Unix seconds) * `participant_version_id: Option` - Participant-list version identifier (from `p_v_id`) * `admin_version_id: Option` - Admin-list version identifier (from `a_v_id`) * `open_thread_id: Option` - Open thread identifier associated with the group * `has_missing_participant_identification: bool` - Whether participant identity information was incomplete in this response * `subject_time: Option` - Subject modification timestamp (Unix seconds) * `subject_owner: Option` - Subject owner JID * `subject_owner_pn: Option` - Subject owner's phone-number JID (from `s_o_pn`) * `subject_owner_username: Option` - Subject owner's Meta username (from `s_o_username`) * `description: Option` - Group description body text * `description_id: Option` - Description ID (for conflict detection) * `description_owner: Option` - JID of the participant who set the description * `description_owner_pn: Option` - Description owner's phone-number JID * `description_owner_username: Option` - Description owner's Meta username * `description_time: Option` - Timestamp when the description was set (Unix seconds) * `is_locked: bool` - Whether only admins can edit group info * `is_announcement: bool` - Whether only admins can send messages * `ephemeral: Option` - Disappearing-message settings. `None` when the server response has no `` node at all; `Some(GroupEphemeralSettings { expiration, trigger })` when the node is present — `expiration` is `None` if the node omitted the attribute, which is distinct from `Some(0)` (timer explicitly disabled) * `membership_approval: bool` - Whether admin approval is required to join * `member_add_mode: Option` - Who can add members * `member_link_mode: Option` - Who can use invite links * `size: Option` - Total participant count * `is_parent_group: bool` - Whether this group is a community parent group * `parent_membership_approval_required: bool` - Whether joins to this parent group require approval by default * `parent_group_jid: Option` - JID of the parent community (for subgroups) * `is_default_sub_group: bool` - Whether this is the default announcement subgroup of a community * `is_general_chat: bool` - Whether this is the general chat subgroup of a community * `allow_non_admin_sub_group_creation: bool` - Whether non-admin community members can create subgroups * `no_frequently_forwarded: bool` - Whether frequently-forwarded messages are restricted * `member_share_history_mode: Option` - Who can share message history with new members * `growth_locked: Option` - Growth lock status (invite links temporarily disabled by the system) * `is_suspended: bool` - Whether the group is suspended * `suspension_can_auto_file: bool` - Whether a suspension appeal may be filed automatically * `appeal_status: Option` - Current suspension-appeal state * `appeal_update_time: Option` - Last suspension-appeal update timestamp (Unix seconds) * `is_support_group: bool` - Whether the group is marked as a support group * `allow_admin_reports: bool` - Whether admin reports are allowed * `is_hidden_group: bool` - Whether the group is hidden * `is_incognito: bool` - Whether incognito mode is enabled * `has_group_history: bool` - Whether group history is enabled * `is_auto_add_disabled: bool` - Whether automatic participant addition is disabled * `has_capi: bool` - Whether the group carries the CAPI capability marker * `evolution_version: Option` - Group schema evolution version * `has_group_safety_check: bool` - Whether the group safety-check feature is enabled * `participant_label_enabled: bool` - Whether participant labels are enabled * `is_limit_sharing_enabled: bool` - Whether limit sharing is enabled * `limit_sharing_trigger: Option` - Source trigger for limit-sharing enablement `ephemeral_expiration: u32` and `ephemeral_trigger: Option` were replaced by the single `ephemeral: Option` field. Migrate reads like `metadata.ephemeral_expiration` to `metadata.ephemeral.as_ref().and_then(|e| e.expiration).unwrap_or(0)`. See [Community API](/api/community) for community-specific operations. `GroupParticipant` implements `PartialEq` and `Eq`. **GroupParticipant fields:** * `jid: Jid` - Participant JID * `phone_number: Option` - Phone number JID (for LID groups) * `lid: Option` - Participant's LID JID, when the server includes one * `username: Option` - Participant's Meta username, when present * `participant_type: ParticipantType` - Participant role (member, admin, or super admin) * `details: Option>` - Less-common participant metadata (label, join time, display name, etc.); boxed and only populated when at least one field is present **Example:** ```rust theme={null} let groups = client.groups().get_participating().await?; for (jid, metadata) in groups { println!("Group: {} ({})", metadata.subject, jid); println!(" Participants: {}", metadata.participants.len()); for participant in &metadata.participants { println!(" {} ({:?})", participant.jid, participant.participant_type); } } ``` ### get\_metadata Get metadata for a specific group. ```rust theme={null} pub async fn get_metadata(&self, jid: &Jid) -> Result ``` **Parameters:** * `jid` - Group JID **Returns:** * `GroupMetadata` - Owned, full user-facing metadata: subject, description, creator, per-participant admin roles, and ephemeral/membership settings (see `get_participating` for the full field list). In a LID-addressed group, participant phone numbers the server left out are backfilled from known LID/PN mappings on a best-effort basis; a participant with no known mapping keeps `phone_number: None`. The query hits the network (no phash is sent, so the server never answers `not-modified`) and the result does not populate the group cache. Use this for displaying or auditing a group; when you only need the participant list to send a message, prefer the cached [`query_info`](#query_info). **Concurrent calls for one group share a round trip.** A call that arrives while another is already in flight for the same group is answered by that in-flight query instead of sending its own — so the metadata it returns can describe an instant slightly before the call was made, bounded by how long the query in flight has been running. Sequential calls are unaffected: each one sends its own query. If an earlier `get_metadata` call is already in flight, wait for it to finish before calling `get_metadata` again. Then issue a new sequential call when you need a result ordered after that earlier call. **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; let metadata = client.groups().get_metadata(&group_jid).await?; println!("Subject: {}", metadata.subject); println!("Mode: {:?}", metadata.addressing_mode); ``` ### create\_group Create a new group. ```rust theme={null} pub async fn create_group( &self, options: GroupCreateOptions, ) -> Result ``` **Parameters:** * `options: GroupCreateOptions` - Group creation options * `subject: String` - Group name (max 100 characters) * `participants: Vec` - Initial participants * `member_link_mode: Option` - Who can use invite links (default: `AdminLink`) * `member_add_mode: Option` - Who can add members (default: `AllMemberAdd`) * `membership_approval_mode: Option` - Require admin approval (default: `Off`) * `ephemeral_expiration: Option` - Disappearing messages timer in seconds (default: `0`) * `is_parent: bool` - Create as a community parent group (default: `false`) * `closed: bool` - Whether the community requires approval to join. Only used when `is_parent` is `true` (default: `false`) * `allow_non_admin_sub_group_creation: bool` - Allow non-admin members to create subgroups. Only used when `is_parent` is `true` (default: `false`) * `create_general_chat: bool` - Create a general chat subgroup alongside the community. Only used when `is_parent` is `true` (default: `false`) * `linked_parent: Option` - Atomically link this group as a subgroup of an existing community on create. Mutually exclusive with `is_parent` — when set, the new group is always classified as a subgroup even if `is_parent: true` is also passed. (added in v0.6) * `description: Option` - Inline group description, emitted as a `` child of the `` stanza so it lands in one round-trip. Matches the existing community-create behavior. (added in v0.6) **Returns:** * `CreateGroupResult` with a `metadata: GroupMetadata` field carrying the full server response (JID, subject, addressing mode, participants with display names, parent/community linkage, etc.). When the `PRIVACY_TOKEN_ON_GROUP_CREATE` AB prop is enabled, the library automatically resolves and attaches privacy tokens (`tc_token`) to each participant during group creation. This is handled internally — you don't need to manage tokens yourself. **Example:** ```rust theme={null} use whatsapp_rust::features::groups::{GroupCreateOptions, GroupParticipantOptions}; let participant1: Jid = "15551234567@s.whatsapp.net".parse()?; let participant2: Jid = "15559876543@s.whatsapp.net".parse()?; let options = GroupCreateOptions::builder() .subject("My New Group") .participants(vec![ GroupParticipantOptions::new(participant1), GroupParticipantOptions::new(participant2), ]) .build(); let result = client.groups().create_group(options).await?; println!("Created group: {} ({})", result.metadata.subject, result.metadata.id); for participant in &result.metadata.participants { println!("- {} ({:?})", participant.jid, participant.participant_type); } ``` Since v0.6, `create_group` returns the full `GroupMetadata` (matching `get_metadata`) instead of just the JID. Inspect `result.metadata` for participants, addressing mode, ephemeral timer, and parent linkage in a single round-trip. `GroupMetadata.participants` is a `Vec` (the IQ-response shape), not the `GroupParticipantInfo` used by group notification events — but masked-number `display_name` labels are available here too, via `participant.details.as_ref().and_then(|d| d.display_name.as_deref())`, in addition to the event-side `GroupParticipantInfo.display_name` for live group-update events. ### set\_subject Change the group name. ```rust theme={null} pub async fn set_subject(&self, jid: &Jid, subject: GroupSubject) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID * `subject` - New group name (max 100 characters) **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; let new_subject = GroupSubject::new("Updated Group Name")?; client.groups().set_subject(&group_jid, new_subject).await?; ``` ### set\_description Set or delete the group description. ```rust theme={null} pub async fn set_description( &self, jid: impl Into, description: Option, prev: PreviousDescription<'_>, ) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID * `description` - New description (max 2048 characters) or `None` to delete * `prev` - The description this update replaces, as a [`PreviousDescription`](#previousdescription). The server accepts the update only when this token matches the group's current description id — a group that already has a description cannot be updated without it. [`PreviousDescription::Resolve`](#previousdescription) (the default) reads the current id from the server first, which always works but costs one extra query. If you already hold fresh [`GroupMetadata::description_id`](#groupmetadata), pass it directly — `metadata.description_id.as_deref().into()` — to skip that query. Use `PreviousDescription::Absent` for a group you know has no description yet, such as one you just created. Returns [`GroupError::DescriptionConflict`](#grouperror) if the group's description changed on the server between the read and this update (for example, another device changed it first). **Example:** ```rust theme={null} use whatsapp_rust::features::groups::PreviousDescription; let group_jid: Jid = "123456789@g.us".parse()?; let desc = GroupDescription::new("This is our group chat")?; // Resolves the current description id from the server automatically client.groups().set_description(&group_jid, Some(desc), PreviousDescription::Resolve).await?; // Skip the extra round trip with a token already on hand let updated = GroupDescription::new("Updated topic")?; let metadata = client.groups().get_metadata(&group_jid).await?; client.groups() .set_description(&group_jid, Some(updated), metadata.description_id.as_deref().into()) .await?; // A freshly created group has no description yet client.groups().set_description(&group_jid, Some(desc), PreviousDescription::Absent).await?; // Delete description client.groups().set_description(&group_jid, None, PreviousDescription::Resolve).await?; ``` ### leave Leave a group. ```rust theme={null} pub async fn leave(&self, jid: &Jid) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID to leave **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; client.groups().leave(&group_jid).await?; ``` ### add\_participants Add participants to a group. ```rust theme={null} pub async fn add_participants( &self, jid: &Jid, participants: &[Jid], ) -> Result, GroupError> ``` **Parameters:** * `jid` - Group JID * `participants` - Array of participant JIDs to add **Returns:** * `Vec` - Result for each participant When the `PRIVACY_TOKEN_ON_GROUP_PARTICIPANT_ADD` AB prop is enabled, the library automatically resolves and attaches privacy tokens (`tc_token`) to each participant. This is handled internally — you don't need to manage tokens yourself. **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; let new_members = vec![ "15551234567@s.whatsapp.net".parse()?, "15559876543@s.whatsapp.net".parse()?, ]; let results = client.groups().add_participants(&group_jid, &new_members).await?; for result in results { println!("Added: {:?}", result); } ``` ### remove\_participants Remove participants from a group. ```rust theme={null} pub async fn remove_participants( &self, jid: &Jid, participants: &[Jid], ) -> Result, GroupError> ``` **Parameters:** * `jid` - Group JID * `participants` - Array of participant JIDs to remove **Returns:** * `Vec` - Result for each participant **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; let to_remove = vec!["15551234567@s.whatsapp.net".parse()?]; client.groups().remove_participants(&group_jid, &to_remove).await?; ``` ### remove\_participants\_including\_linked\_groups Remove participants from a group and cascade the removal to its linked/child groups. Used for community-linked groups, where removing someone from the community should also remove them from subgroups. ```rust theme={null} pub async fn remove_participants_including_linked_groups( &self, jid: &Jid, participants: &[Jid], ) -> Result, GroupError> ``` **Parameters:** * `jid` - Group JID (typically the community parent group) * `participants` - Array of participant JIDs to remove **Returns:** * `Vec` - Result for each participant **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; let to_remove = vec!["15551234567@s.whatsapp.net".parse()?]; let results = client.groups().remove_participants_including_linked_groups(&group_jid, &to_remove).await?; ``` ### promote\_participants Promote participants to admin. ```rust theme={null} pub async fn promote_participants( &self, jid: &Jid, participants: &[Jid], ) -> Result, GroupError> ``` **Parameters:** * `jid` - Group JID * `participants` - Array of participant JIDs to promote **Returns:** * `Vec` - Result for each participant **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; let to_promote = vec!["15551234567@s.whatsapp.net".parse()?]; let results = client.groups().promote_participants(&group_jid, &to_promote).await?; for result in results { println!("Promoted {}: status {:?}", result.jid, result.status); } ``` `promote_participants` returns `Vec` (one entry per participant) instead of `()`. ### demote\_participants Demote admin participants to regular members. ```rust theme={null} pub async fn demote_participants( &self, jid: &Jid, participants: &[Jid], ) -> Result, GroupError> ``` **Parameters:** * `jid` - Group JID * `participants` - Array of admin JIDs to demote **Returns:** * `Vec` - Result for each participant **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; let to_demote = vec!["15551234567@s.whatsapp.net".parse()?]; let results = client.groups().demote_participants(&group_jid, &to_demote).await?; for result in results { println!("Demoted {}: status {:?}", result.jid, result.status); } ``` `demote_participants` returns `Vec` (one entry per participant) instead of `()`. ### get\_invite\_link Get or reset the group invite link. ```rust theme={null} pub async fn get_invite_link(&self, jid: &Jid, reset: bool) -> Result ``` **Parameters:** * `jid` - Group JID * `reset` - Whether to reset and generate a new invite link **Returns:** * `String` - Invite link code **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; // Get current invite link let link = client.groups().get_invite_link(&group_jid, false).await?; println!("Invite link: https://chat.whatsapp.com/{}", link); // Reset and get new link let new_link = client.groups().get_invite_link(&group_jid, true).await?; println!("New invite link: https://chat.whatsapp.com/{}", new_link); ``` ### join\_with\_invite\_code Join a group using an invite code or full invite URL. ```rust theme={null} pub async fn join_with_invite_code(&self, code: &str) -> Result ``` **Parameters:** * `code` - Invite code or full URL (e.g., `"AbCdEfGh"` or `"https://chat.whatsapp.com/AbCdEfGh"`) **Returns:** * `JoinGroupResult` - Either `Joined(Jid)` if immediately joined, or `PendingApproval(Jid)` if the group requires admin approval **Example:** ```rust theme={null} use whatsapp_rust::features::groups::JoinGroupResult; // Join using a full URL or just the code let result = client.groups().join_with_invite_code("https://chat.whatsapp.com/AbCdEfGh").await?; match &result { JoinGroupResult::Joined(jid) => println!("Joined group: {}", jid), JoinGroupResult::PendingApproval(jid) => println!("Pending approval for group: {}", jid), } // Access the group JID regardless of result println!("Group JID: {}", result.group_jid()); ``` ### join\_with\_invite\_v4 Accept a V4 group invite received as a `GroupInviteMessage` (not a link). V4 invites are sent directly by a group admin as a message, rather than shared as a URL. ```rust theme={null} pub async fn join_with_invite_v4( &self, group_jid: &Jid, code: &str, expiration: i64, admin_jid: &Jid, ) -> Result ``` **Parameters:** * `group_jid` - The target group JID * `code` - Invite code from the `GroupInviteMessage` * `expiration` - Invite expiration timestamp (Unix seconds). The method returns an error if the invite has expired. * `admin_jid` - JID of the admin who sent the invite **Returns:** * `JoinGroupResult` - Either `Joined(Jid)` if immediately joined, or `PendingApproval(Jid)` if the group requires admin approval **Example:** ```rust theme={null} use whatsapp_rust::features::groups::JoinGroupResult; let group_jid: Jid = "123456789@g.us".parse()?; let admin_jid: Jid = "15551234567@s.whatsapp.net".parse()?; let result = client.groups().join_with_invite_v4( &group_jid, "AbCdEfGh", 1735689600, // expiration timestamp &admin_jid, ).await?; match &result { JoinGroupResult::Joined(jid) => println!("Joined group: {}", jid), JoinGroupResult::PendingApproval(jid) => println!("Pending approval for group: {}", jid), } ``` V4 invites expire. The method automatically checks the expiration timestamp and returns an error if the invite has already expired. You can pass `0` as the expiration to skip the expiration check. ### get\_invite\_info Get group metadata from an invite code without joining the group. ```rust theme={null} pub async fn get_invite_info(&self, code: &str) -> Result ``` **Parameters:** * `code` - Invite code or full URL **Returns:** * `GroupMetadata` - Group metadata (see `get_participating` for fields) **Example:** ```rust theme={null} // Preview a group before joining let metadata = client.groups().get_invite_info("AbCdEfGh").await?; println!("Group: {}", metadata.subject); println!("Participants: {}", metadata.participants.len()); println!("Approval required: {}", metadata.membership_approval); ``` ### set\_locked Lock or unlock the group so only admins can change group info. ```rust theme={null} pub async fn set_locked(&self, jid: &Jid, locked: bool) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID * `locked` - `true` to lock (only admins edit info), `false` to unlock **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; // Lock group info client.groups().set_locked(&group_jid, true).await?; // Unlock group info client.groups().set_locked(&group_jid, false).await?; ``` ### set\_announce Set announcement mode. When enabled, only admins can send messages. ```rust theme={null} pub async fn set_announce(&self, jid: &Jid, announce: bool) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID * `announce` - `true` to enable (only admins send), `false` to disable **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; // Enable announcement mode client.groups().set_announce(&group_jid, true).await?; // Disable announcement mode client.groups().set_announce(&group_jid, false).await?; ``` ### set\_ephemeral Set the disappearing messages timer on the group. ```rust theme={null} pub async fn set_ephemeral(&self, jid: &Jid, expiration: u32) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID * `expiration` - Timer duration in seconds. Common values: `86400` (24 hours), `604800` (7 days), `7776000` (90 days). Pass `0` to disable. **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; // Enable 7-day disappearing messages client.groups().set_ephemeral(&group_jid, 604800).await?; // Disable disappearing messages client.groups().set_ephemeral(&group_jid, 0).await?; ``` ### set\_membership\_approval Set membership approval mode. When enabled, new members must be approved by an admin. ```rust theme={null} pub async fn set_membership_approval( &self, jid: &Jid, mode: MembershipApprovalMode, ) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID * `mode` - `MembershipApprovalMode::On` to require approval, `MembershipApprovalMode::Off` to disable **Example:** ```rust theme={null} use whatsapp_rust::features::groups::MembershipApprovalMode; let group_jid: Jid = "123456789@g.us".parse()?; // Require admin approval client.groups().set_membership_approval(&group_jid, MembershipApprovalMode::On).await?; // Remove approval requirement client.groups().set_membership_approval(&group_jid, MembershipApprovalMode::Off).await?; ``` ### get\_membership\_requests Get pending membership approval requests for a group. ```rust theme={null} pub async fn get_membership_requests( &self, jid: &Jid, ) -> Result, GroupError> ``` **Parameters:** * `jid` - Group JID **Returns:** * `Vec` - List of pending requests **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; let requests = client.groups().get_membership_requests(&group_jid).await?; for request in &requests { println!("Pending request from: {}", request.jid); if let Some(time) = request.request_time { println!(" Requested at: {}", time); } } ``` ### approve\_membership\_requests Approve pending membership requests for a group. ```rust theme={null} pub async fn approve_membership_requests( &self, jid: &Jid, participants: &[Jid], ) -> Result, GroupError> ``` **Parameters:** * `jid` - Group JID * `participants` - Array of JIDs to approve **Returns:** * `Vec` - Result for each participant **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; // Get pending requests let requests = client.groups().get_membership_requests(&group_jid).await?; let jids: Vec = requests.iter().map(|r| r.jid.clone()).collect(); // Approve all pending requests let results = client.groups().approve_membership_requests(&group_jid, &jids).await?; for result in results { println!("Approved {}: status {:?}", result.jid, result.status); } ``` ### reject\_membership\_requests Reject pending membership requests for a group. ```rust theme={null} pub async fn reject_membership_requests( &self, jid: &Jid, participants: &[Jid], ) -> Result, GroupError> ``` **Parameters:** * `jid` - Group JID * `participants` - Array of JIDs to reject **Returns:** * `Vec` - Result for each participant **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; let to_reject = vec!["15551234567@s.whatsapp.net".parse()?]; let results = client.groups().reject_membership_requests(&group_jid, &to_reject).await?; ``` ### set\_member\_add\_mode Set who can add members to the group. ```rust theme={null} pub async fn set_member_add_mode( &self, jid: &Jid, mode: MemberAddMode, ) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID * `mode` - `MemberAddMode::AdminAdd` to restrict to admins, `MemberAddMode::AllMemberAdd` to allow all members **Example:** ```rust theme={null} use whatsapp_rust::features::groups::MemberAddMode; let group_jid: Jid = "123456789@g.us".parse()?; // Only admins can add members client.groups().set_member_add_mode(&group_jid, MemberAddMode::AdminAdd).await?; // All members can add others client.groups().set_member_add_mode(&group_jid, MemberAddMode::AllMemberAdd).await?; ``` ### set\_no\_frequently\_forwarded Restrict or allow frequently-forwarded messages in the group. ```rust theme={null} pub async fn set_no_frequently_forwarded( &self, jid: &Jid, restrict: bool, ) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID * `restrict` - `true` to restrict frequently-forwarded messages, `false` to allow them **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; // Restrict frequently-forwarded messages client.groups().set_no_frequently_forwarded(&group_jid, true).await?; // Allow frequently-forwarded messages client.groups().set_no_frequently_forwarded(&group_jid, false).await?; ``` ### set\_allow\_admin\_reports Enable or disable admin reports in the group. ```rust theme={null} pub async fn set_allow_admin_reports( &self, jid: &Jid, allow: bool, ) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID * `allow` - `true` to enable admin reports, `false` to disable **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; // Enable admin reports client.groups().set_allow_admin_reports(&group_jid, true).await?; // Disable admin reports client.groups().set_allow_admin_reports(&group_jid, false).await?; ``` ### set\_group\_history Enable or disable group history sharing. ```rust theme={null} pub async fn set_group_history(&self, jid: &Jid, enabled: bool) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID * `enabled` - `true` to enable group history, `false` to disable **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; // Enable group history client.groups().set_group_history(&group_jid, true).await?; // Disable group history client.groups().set_group_history(&group_jid, false).await?; ``` ### set\_member\_link\_mode Set who can share invite links. This uses the MEX (Mutation Exchange) protocol. ```rust theme={null} pub async fn set_member_link_mode( &self, jid: &Jid, mode: MemberLinkMode, ) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID * `mode` - `MemberLinkMode::AdminLink` to restrict to admins, `MemberLinkMode::AllMemberLink` to allow all members **Example:** ```rust theme={null} use whatsapp_rust::features::groups::MemberLinkMode; let group_jid: Jid = "123456789@g.us".parse()?; // Only admins can share invite links client.groups().set_member_link_mode(&group_jid, MemberLinkMode::AdminLink).await?; // All members can share invite links client.groups().set_member_link_mode(&group_jid, MemberLinkMode::AllMemberLink).await?; ``` ### set\_member\_share\_history\_mode Set who can share message history with new members. This uses the MEX protocol. ```rust theme={null} pub async fn set_member_share_history_mode( &self, jid: &Jid, mode: MemberShareHistoryMode, ) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID * `mode` - `MemberShareHistoryMode::AdminShare` to restrict to admins, `MemberShareHistoryMode::AllMemberShare` to allow all members **Example:** ```rust theme={null} use whatsapp_rust::features::groups::MemberShareHistoryMode; let group_jid: Jid = "123456789@g.us".parse()?; // Only admins can share history with new members client.groups().set_member_share_history_mode( &group_jid, MemberShareHistoryMode::AdminShare, ).await?; // All members can share history client.groups().set_member_share_history_mode( &group_jid, MemberShareHistoryMode::AllMemberShare, ).await?; ``` ### set\_limit\_sharing Enable or disable limit sharing in the group. This uses the MEX protocol. ```rust theme={null} pub async fn set_limit_sharing(&self, jid: &Jid, enabled: bool) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID * `enabled` - `true` to enable limit sharing, `false` to disable **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; // Enable limit sharing client.groups().set_limit_sharing(&group_jid, true).await?; // Disable limit sharing client.groups().set_limit_sharing(&group_jid, false).await?; ``` ### cancel\_membership\_requests Cancel pending membership requests from the requesting user's side. ```rust theme={null} pub async fn cancel_membership_requests( &self, jid: &Jid, participants: &[Jid], ) -> Result, GroupError> ``` **Parameters:** * `jid` - Group JID * `participants` - Array of JIDs whose pending requests to cancel **Returns:** * `Vec` - Result for each participant **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; let to_cancel = vec!["15551234567@s.whatsapp.net".parse()?]; let results = client.groups().cancel_membership_requests(&group_jid, &to_cancel).await?; ``` ### revoke\_request\_code Revoke invitation codes from specific participants. This is an admin operation. ```rust theme={null} pub async fn revoke_request_code( &self, jid: &Jid, participants: &[Jid], ) -> Result, GroupError> ``` **Parameters:** * `jid` - Group JID * `participants` - Array of participant JIDs whose invitation codes to revoke **Returns:** * `Vec` - Result for each participant **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; let to_revoke = vec!["15551234567@s.whatsapp.net".parse()?]; let results = client.groups().revoke_request_code(&group_jid, &to_revoke).await?; ``` ### acknowledge Acknowledge a group notification. ```rust theme={null} pub async fn acknowledge(&self, jid: &Jid) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; client.groups().acknowledge(&group_jid).await?; ``` ### update\_member\_label\_with\_id Set or clear the bot's per-group member label, sent as a `ProtocolMessage` over the normal message path (not an IQ). Returns the sent stanza's message ID. ```rust theme={null} pub async fn update_member_label_with_id( &self, group_jid: &Jid, label: impl Into, ) -> Result ``` **Parameters:** * `group_jid` - Group JID * `label` - New label text, or an empty string to clear the label **Returns:** * `String` - Message ID of the sent stanza **Example:** ```rust theme={null} let group_jid: Jid = "123456789@g.us".parse()?; let message_id = client.groups().update_member_label_with_id(&group_jid, "VIP").await?; println!("Label update sent as message {}", message_id); ``` `update_member_label` is a thin wrapper around `update_member_label_with_id` that discards the message ID and returns `Result<(), GroupError>`, for callers that don't need it. ### batch\_get\_info Batch query group info for multiple groups at once. ```rust theme={null} pub async fn batch_get_info( &self, jids: Vec, ) -> Result, GroupError> ``` **Parameters:** * `jids` - List of group JIDs to query (max 10,000) **Returns:** * `Vec` - Result for each group **Example:** ```rust theme={null} use whatsapp_rust::features::groups::BatchGroupResult; let group_jids: Vec = vec![ "120363012345678@g.us".parse()?, "120363087654321@g.us".parse()?, ]; let results = client.groups().batch_get_info(group_jids).await?; for result in results { match result { BatchGroupResult::Full(metadata) => { println!("Group: {} - {}", metadata.id, metadata.subject); } BatchGroupResult::Truncated { id, size } => { println!("Truncated: {} (size: {:?})", id, size); } BatchGroupResult::Forbidden(id) => { println!("Forbidden: {}", id); } BatchGroupResult::NotFound(id) => { println!("Not found: {}", id); } } } ``` ### set\_profile\_picture Set a group's profile picture. Admin operation. ```rust theme={null} pub async fn set_profile_picture( &self, group_jid: &Jid, image_data: Vec, ) -> Result ``` **Parameters:** * `group_jid` - Group JID (must end with `@g.us`) * `image_data` - JPEG image bytes. The caller is responsible for sizing/cropping the image (WhatsApp uses 640x640). **Returns:** * `SetProfilePictureResponse` - Contains the new picture ID Passing empty `image_data` routes to removal, mirroring the own-picture API. Prefer [`remove_profile_picture`](#remove_profile_picture) when removal is the intent. **Example:** ```rust theme={null} use std::fs; let group_jid: Jid = "120363012345678@g.us".parse()?; let image_bytes = fs::read("group_avatar.jpg")?; let response = client.groups().set_profile_picture(&group_jid, image_bytes).await?; if let Some(id) = response.id { println!("Group picture updated: {}", id); } ``` The image must be a valid JPEG. Other formats are not supported. The caller must be a group admin. ### remove\_profile\_picture Remove a group's profile picture. Admin operation. ```rust theme={null} pub async fn remove_profile_picture( &self, group_jid: &Jid, ) -> Result ``` **Parameters:** * `group_jid` - Group JID **Returns:** * `SetProfilePictureResponse` - Confirmation of removal **Example:** ```rust theme={null} let group_jid: Jid = "120363012345678@g.us".parse()?; client.groups().remove_profile_picture(&group_jid).await?; ``` See [`SetProfilePictureResponse`](/api/profile#setprofilepictureresponse) for the response shape. ### get\_profile\_pictures Batch fetch group profile pictures. ```rust theme={null} pub async fn get_profile_pictures( &self, group_jids: Vec, picture_type: PictureType, ) -> Result, GroupError> ``` **Parameters:** * `group_jids` - List of group JIDs (max 1,000) * `picture_type` - `PictureType::Preview` for thumbnails or `PictureType::Image` for full-size **Returns:** * `Vec` - Profile picture data for each group **Example:** ```rust theme={null} use whatsapp_rust::features::groups::{PictureType, GroupProfilePicture}; let group_jids: Vec = vec![ "120363012345678@g.us".parse()?, "120363087654321@g.us".parse()?, ]; let pictures = client.groups().get_profile_pictures(group_jids, PictureType::Preview).await?; for pic in pictures { if let Some(url) = &pic.url { println!("Group {}: {}", pic.group_jid, url); } } ``` ### report\_messages\_to\_admins Report one or more messages to the group's own admins. This is distinct from [reporting to WhatsApp](/api/spam-report) — it stays inside the group and is gated by the group's own `allow_admin_reports` setting (see [`set_allow_admin_reports`](#set_allow_admin_reports)). ```rust theme={null} pub async fn report_messages_to_admins( &self, jid: impl Into, message_ids: &[String], ) -> Result<(), GroupError> ``` **Parameters:** * `jid` - Group JID * `message_ids` - Stanza IDs of the messages being reported **Returns:** * `()` - The server answers an empty result on success; nothing here reports which of the listed ids were accepted. A refusal arrives as a `GroupError::Iq` wrapping the server's error code: `400` (`bad-request`, malformed list), `403` (`forbidden`, the group does not allow admin reports or this sender may not report in it), `404` (`item-not-found`, unknown group or message), `423` (`locked`, a locked group), or `429` (`rate-overlimit`, too many reports in sequence). **Example:** ```rust theme={null} let group_jid: Jid = "120363012345678@g.us".parse()?; let message_ids = vec!["MSG-AAA".to_string(), "MSG-BBB".to_string()]; client.groups().report_messages_to_admins(group_jid, &message_ids).await?; ``` ### get\_reported\_messages Fetch the messages already reported to a group's admins. ```rust theme={null} pub async fn get_reported_messages( &self, jid: impl Into, ) -> Result ``` **Parameters:** * `jid` - Group JID **Returns:** * [`ReportedGroupMessages`](#reportedgroupmessages) - The addressing mode the server used for the response JIDs (when declared) plus every reported message and who reported it. A refusal arrives as a `GroupError::Iq`: `400` (`bad-request`), `401` (`not-authorized`), `404` (`item-not-found`), `423` (`locked`), or `429` (`rate-overlimit`). This mirrors the `set` error list above except `401` replaces `403` for the permission case. **Example:** ```rust theme={null} use whatsapp_rust::features::groups::ReportedGroupMessages; let group_jid: Jid = "120363012345678@g.us".parse()?; let ReportedGroupMessages { addressing_mode, reports } = client.groups().get_reported_messages(group_jid).await?; println!("Addressing mode: {:?}", addressing_mode); for report in reports { println!("Message {} reported by:", report.message_id); for reporter in &report.reporters { println!( " {} at {} (phone: {:?}, username: {:?})", reporter.jid, reporter.timestamp, reporter.phone_number, reporter.username, ); } } ``` ## Types ### `Freshness` Selects whether an operation may return an existing cached snapshot or must consult its source before returning. Shared across the SDK wherever a cache-backed lookup accepts an explicit staleness policy — `Groups::query_info_with_freshness` here, and `StatusSendOptions::device_freshness` (see [Status](/api/status#statussendoptions)). ```rust theme={null} #[non_exhaustive] pub enum Freshness { /// Return a cached snapshot when available and consult the source on a miss (default). CachePreferred, /// Consult the source and publish the resulting snapshot without clearing the /// previous one first. Refresh, } ``` `Freshness` is re-exported from the crate root, so you can import it directly: `use whatsapp_rust::Freshness;`. When you pass `Refresh`, you never see a caller-visible gap — the previous snapshot stays servable until the new one is published. ### GroupInfo Cached group information returned by `query_info`. Contains participant list, addressing mode, and LID-to-phone mappings for privacy-addressed groups. `query_info` returns `Arc` so that repeated lookups and warm sends share the same snapshot without deep-cloning the participants list. ```rust theme={null} pub struct GroupInfo { pub participants: Vec, pub addressing_mode: AddressingMode, } ``` **Methods:** | Method | Description | | --------------------------------------------------------------------- | ---------------------------------------------- | | `phone_jid_for_lid_user(lid_user: &str) -> Option<&Jid>` | Look up the phone JID for a LID user | | `lid_user_for_phone_user(phone_user: &str) -> Option<&CompactString>` | Look up the LID user string for a phone number | | `phone_device_jid_to_lid(phone_device_jid: &Jid) -> Jid` | Convert a phone-based device JID to LID format | **Example:** ```rust theme={null} let info = client.groups().query_info(&group_jid).await?; // Arc // Access participants and addressing mode for participant in &info.participants { println!("Participant: {}", participant); } // For LID-addressed groups, resolve phone numbers if info.addressing_mode == AddressingMode::Lid { if let Some(phone_jid) = info.phone_jid_for_lid_user("lid_user_id") { println!("Phone: {}", phone_jid); } } ``` ### GroupSubject Validated group name with 100 character limit. ```rust theme={null} impl GroupSubject { pub fn new(subject: impl Into) -> Result pub fn into_string(self) -> String } ``` ### GroupDescription Validated group description with 2048 character limit. ```rust theme={null} impl GroupDescription { pub fn new(description: impl Into) -> Result pub fn into_string(self) -> String } ``` ### PreviousDescription The description a [`set_description`](#set_description) call expects to replace — the server's optimistic-concurrency token for the group's description. ```rust theme={null} pub enum PreviousDescription<'a> { Resolve, // default Absent, Id(&'a str), } impl<'a> From> for PreviousDescription<'a> ``` * `Resolve` (default) — read the group's current description id from the server before sending. The only variant that's correct without knowing anything about the group, and the only one that costs a round trip. * `Absent` — the group carries no description yet (e.g. one you just created), so no token is sent. * `Id(&str)` — a description id you already hold, typically [`GroupMetadata::description_id`](#groupmetadata) from a recent [`get_metadata`](#get_metadata) call. `From>` lets you turn a held `GroupMetadata::description_id` into a token directly: `None` maps to `Absent` (that metadata says the group has no description), `Some(id)` maps to `Id(id)`. Use `Resolve` instead when the metadata's age is unknown, since stale metadata that missed a description added since would otherwise surface as `GroupError::DescriptionConflict`. ### GroupEphemeralSettings Disappearing-message settings carried by a group's `` node. ```rust theme={null} pub struct GroupEphemeralSettings { pub expiration: Option, pub trigger: Option, } ``` ### MemberAddMode ```rust theme={null} pub enum MemberAddMode { AdminAdd, // Only admins can add members AllMemberAdd, // All members can add } ``` ### ParticipantType Participant role within a group. ```rust theme={null} pub enum ParticipantType { Member, // Regular member Admin, // Group admin SuperAdmin, // Group creator / super admin } ``` **Methods:** * `is_admin(&self) -> bool` - Returns `true` if admin or super admin ### GroupParticipantDetails Less-common participant metadata. Boxed on `GroupParticipant`/`GroupParticipantResponse` and only populated when at least one field is present. ```rust theme={null} #[non_exhaustive] pub struct GroupParticipantDetails { pub participant_label: Option, pub participant_label_mtime: Option, pub join_time: Option, pub group_history_sent: Option, pub display_name: Option, pub is_addressable: bool, } ``` `GroupParticipantDetails` is `#[non_exhaustive]`. Field reads are unaffected; only exhaustive struct destructuring from outside the crate requires adding `..`. ### MemberLinkMode Controls who can use invite links to join the group. ```rust theme={null} pub enum MemberLinkMode { AdminLink, // Only admins can share invite links AllMemberLink, // All members can share invite links } ``` ### MemberShareHistoryMode Controls who can share message history with new members. ```rust theme={null} pub enum MemberShareHistoryMode { AdminShare, // Only admins can share history AllMemberShare, // All members can share history } ``` ### MembershipApprovalMode ```rust theme={null} pub enum MembershipApprovalMode { Off, // No approval required On, // Admin approval required } ``` ### GroupAppealStatus Review state for an appeal on a suspended group. ```rust theme={null} pub enum GroupAppealStatus { Approved, InReview, NoAppeal, Rejected, } ``` ### GroupParticipantOptions Options for specifying a participant when creating or modifying a group. ```rust theme={null} pub struct GroupParticipantOptions { pub jid: Jid, pub phone_number: Option, pub privacy: Option>, } ``` **Constructors:** * `GroupParticipantOptions::new(jid)` - Create from a JID * `GroupParticipantOptions::from_phone(phone_number)` - Create from a phone number JID * `GroupParticipantOptions::from_lid_and_phone(lid, phone_number)` - Create from a LID and phone number **Builder methods:** * `.with_phone_number(jid)` - Attach a phone number JID (for LID participants) * `.with_privacy(token)` - Attach a privacy token (`tc_token` bytes) You typically don't need to set the `privacy` field manually. When AB props are enabled, the library automatically resolves and attaches privacy tokens during `create_group` and `add_participants` operations. **Example:** ```rust theme={null} use whatsapp_rust::features::groups::GroupParticipantOptions; let participant = GroupParticipantOptions::new("15551234567@s.whatsapp.net".parse()?); ``` ### JoinGroupResult Result of joining a group via invite code. ```rust theme={null} pub enum JoinGroupResult { Joined(Jid), // Successfully joined PendingApproval(Jid), // Membership approval required } ``` **Methods:** * `group_jid(&self) -> &Jid` - Returns the group JID regardless of result variant ### MembershipRequest A pending membership approval request. ```rust theme={null} pub struct MembershipRequest { pub jid: Jid, pub request_time: Option, } ``` ### ParticipantChangeResponse Result of a participant change operation (add, remove, approve, reject). ```rust theme={null} #[non_exhaustive] pub struct ParticipantChangeResponse { pub jid: Jid, pub status: Option, pub error: Option, pub add_request: Option, } ``` `ParticipantChangeResponse` is `#[non_exhaustive]`. Field reads are unaffected; only exhaustive struct destructuring from outside the crate requires adding `..`. `add_request` is populated when the server responds with HTTP 403 carrying an `` child — that happens when a participant has privacy blocked direct adds and the inviter must send them a v4 invite link out-of-band. Since v0.6 the value is preserved instead of being dropped, so consumers can drive the v4 invite flow without parsing the raw IQ. ```rust theme={null} pub struct AddRequestInfo { pub code: String, // v4 invite token pub expiration: i64, // unix seconds; 0 means the server omitted it } ``` ### AddressingMode ```rust theme={null} pub enum AddressingMode { Pn, // Phone number addressing Lid, // LID (privacy) addressing } ``` ### BatchGroupResult Result for a single group in a batch query. ```rust theme={null} pub enum BatchGroupResult { Full(Box), // Full group metadata Truncated { id: Jid, size: Option }, // Only ID and size returned Forbidden(Jid), // Access denied NotFound(Jid), // Group does not exist } ``` ### GrowthLockInfo Growth lock information (system-managed, read-only). When present, invite links are temporarily disabled by the system. ```rust theme={null} pub struct GrowthLockInfo { pub lock_type: String, pub expiration: u64, } ``` ### PictureType Profile picture query type for batch fetching. ```rust theme={null} pub enum PictureType { Preview, // Thumbnail / preview size Image, // Full-size image } ``` ### GroupProfilePicture A single group profile picture result from a batch query. ```rust theme={null} pub struct GroupProfilePicture { pub group_jid: Jid, pub url: Option, pub direct_path: Option, pub photo_id: Option, } ``` ### CreateGroupResult Result of creating a group. ```rust theme={null} #[non_exhaustive] pub struct CreateGroupResult { pub metadata: GroupMetadata, } ``` `CreateGroupResult` is `#[non_exhaustive]`. Field reads are unaffected; only exhaustive struct destructuring from outside the crate requires adding `..`. The `metadata` field carries the full group state returned by the server (same shape as [`get_metadata`](#get_metadata)). `metadata.participants` is `Vec` — masked-number `display_name` labels are reachable via `participant.details.as_ref().and_then(|d| d.display_name.as_deref())`, or from the event-side `GroupParticipantInfo` (which carries `` children of *notification* events) when handling live group-update events. Prior to v0.6 this struct only exposed a `gid: Jid`. Replace `result.gid` with `result.metadata.id` when upgrading (`GroupMetadata.id`, not `jid`). The same migration applies to `CreateCommunityResult`. ### ReportedGroupMessages Result of [`get_reported_messages`](#get_reported_messages) — the outstanding reports an admin can see for a group. ```rust theme={null} pub struct ReportedGroupMessages { pub addressing_mode: Option, pub reports: Vec, } ``` `addressing_mode` reflects the addressing the server used for JIDs in this response, when it declared one. ### ReportedGroupMessage One reported message and everyone who reported it. ```rust theme={null} pub struct ReportedGroupMessage { pub message_id: String, pub reporters: Vec, } ``` Nothing guarantees this device still holds the message `message_id` names. ### GroupMessageReporter One account that reported a message to the group's admins. ```rust theme={null} pub struct GroupMessageReporter { pub jid: Jid, pub timestamp: u64, pub phone_number: Option, pub username: Option, } ``` * `jid` - The reporting account, in whatever addressing the response declares. * `timestamp` - When the report was filed, in seconds since the Unix epoch. * `phone_number` - The reporter's phone-number JID, when the server's identity mixin carries one (e.g. a LID-addressed reporter with a known PN mapping). * `username` - The reporter's Meta username, when the identity mixin carries one. `phone_number` and `username` are populated on the same node the `jid` came from — the server may attach a phone number, a username, both, or neither to a `` entry, and neither is verified beyond parsing. ### GroupJoinError Error codes returned when joining a group via invite fails. ```rust theme={null} pub enum GroupJoinError { AlreadyMember, // 304 - Already a member of the group BadRequest, // 400 - Invalid request Forbidden, // 403 - Not allowed to join NotFound, // 404 - Group not found NotAllowed, // 405 - Join method not allowed Conflict, // 409 - Conflicting state Gone, // 410 - Group no longer exists CommunityFull, // 412 - Community has reached capacity GroupFull, // 419 - Group has reached capacity Locked, // 423 - Group is locked Unknown(u16), // Other error code } ``` **Methods:** * `from_code(code: u16) -> Self` - Create from a numeric status code * `code(&self) -> u16` - Get the numeric status code ### InviteInfoError Error codes returned when fetching group info from an invite code. ```rust theme={null} pub enum InviteInfoError { BadRequest, // 400 - Invalid request NotAuthorized, // 401 - Not authorized NotFound, // 404 - Invite code not found NotAcceptable, // 406 - Not acceptable Gone, // 410 - Invite code expired ParentGroupSuspended, // 416 - Parent community is suspended Locked, // 423 - Group is locked GrowthLocked, // 436 - Group growth is temporarily locked Unknown(u16), // Other error code } ``` **Methods:** * `from_code(code: u16) -> Self` - Create from a numeric status code * `code(&self) -> u16` - Get the numeric status code ## Error types ### `GroupError` All group methods return `Result`: ```rust theme={null} #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum GroupError { #[error("{0}")] Iq(#[from] IqError), #[error("{0}")] Mex(#[from] MexError), #[error("invalid group request: {0}")] InvalidRequest(String), #[error("the group description changed since it was read")] DescriptionConflict, #[error("{0}")] Internal(#[from] anyhow::Error), } ``` **Variants:** * `Iq` — IQ request failed (timeout, server rejection, etc.) * `Mex` — MEX protocol error (for methods using the MEX transport) * `InvalidRequest` — malformed request (expired invite, missing fields, etc.) * `DescriptionConflict` — [`set_description`](#set_description)'s `prev` token no longer matches the group's current description (the server answered `409 conflict`); another device changed it first. Kept distinct from a permission refusal so a caller can re-read the description and retry instead of giving up. * `Internal` — catch-all for other errors ```rust theme={null} use whatsapp_rust::GroupError; match client.groups().leave(&group_jid).await { Ok(_) => println!("Left group"), Err(GroupError::Iq(e)) => eprintln!("Server rejected: {}", e), Err(GroupError::InvalidRequest(msg)) => eprintln!("Bad request: {}", msg), Err(e) => eprintln!("Error: {}", e), } ``` # HTTP Client Trait Source: https://whatsapp-rust.jlucaso.com/api/http-client HTTP client abstraction and ureq implementation for media operations ## Overview The HTTP client abstraction provides a runtime-agnostic interface for making HTTP requests. It's primarily used for: * Media uploads to WhatsApp servers * Media downloads (with streaming support) * Fetching metadata and authentication tokens The client supports both buffered and streaming responses for efficient handling of large files. ## HttpClient Trait ```rust theme={null} use async_trait::async_trait; use wacore::sync_marker::MaybeSendSync; #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait HttpClient: MaybeSendSync { /// Executes a given HTTP request and returns the response async fn execute(&self, request: HttpRequest) -> Result; /// Whether this client supports synchronous streaming downloads. /// Defaults to `false`. Override to return `true` if you implement /// `execute_streaming`. fn supports_streaming(&self) -> bool { false } /// Synchronous streaming variant. Returns a reader over the response body /// instead of buffering it all in memory. /// /// Must be called from a blocking context (e.g. inside `spawn_blocking`). /// Returns an error by default if not implemented. fn execute_streaming(&self, request: HttpRequest) -> Result; /// Whether this client can stream a request body from a reader (upload). /// Defaults to `false`. Override alongside `execute_upload` to enable /// constant-memory uploads via `Client::upload_stream`. fn supports_upload_streaming(&self) -> bool { false } /// Synchronous streaming upload: send `body` (exactly `content_length` /// bytes) as the request body. Implementations MUST set an explicit /// `Content-Length` header rather than chunked transfer-encoding (the /// WhatsApp CDN rejects chunked uploads). Any body set on `request` is /// ignored. Must be called from a blocking context. fn execute_upload( &self, request: HttpRequest, body: UploadBody, // = Box content_length: u64, ) -> Result; } ``` `MaybeSendSync` is `Send + Sync` on native targets and carries no bounds on `wasm32`. Custom `HttpClient` implementations backed by `!Send` browser `fetch` handles now compile on the wasm port. On native, `Arc` remains `Send + Sync` as before. `supports_upload_streaming` / `execute_upload` were added in v0.6 to back [`Client::upload_stream`](/api/upload#upload-stream-constant-memory). Both have safe defaults (returning `false` / an error), so existing custom `HttpClient` implementations keep compiling — implement them only if you want constant-memory uploads through your client. `UreqHttpClient` implements both. `UploadBody` is `Box`. **Return `Ok` for every completed exchange, even a non-2xx status.** Reserve `Err` for a request that never completed — DNS failure, connection refused, a TLS error, or a timeout. `execute` and `execute_upload` have one more `Err` case. If you read a **2xx** body past your declared cap (`max_body_bytes` on `UreqHttpClient`), fail that read — a truncated payload must never look like a complete one. `execute_streaming` doesn't raise this error: its reader just reaches EOF at the cap, and a downstream integrity check (MAC or SHA) catches the truncation instead. See [Internal Implementation](#internal-implementation) for how `UreqHttpClient` implements both halves of this. Read `status_code` off the `Ok` response to decide what to do next. Download and upload both use it to refresh a stale media-auth token (401/403) and to move to the next CDN host on other errors like 5xx — see [download's retry and URL re-derivation](/api/download#automatic-retry-and-url-re-derivation) and [upload's retry on auth errors](/api/upload#automatic-retry-on-auth-errors). Download has one case upload doesn't: it treats an expired URL (404/410) the same as an auth error and re-derives it. If you map 4xx/5xx to `Err` instead, you hide the status from all of this, and every retry repeats the same stale auth token. Some HTTP crates default the wrong way. `ureq`'s `http_status_as_error` turns non-2xx into an `Err` unless you disable it, which is why `UreqHttpClient` disables it on every request. If you build a custom client on `ureq` or a similar library, disable that default yourself. `MockHttpClient` below shows the shape a compliant implementation should have: `Err` only when there's no response to give at all. ### Methods #### supports\_streaming Returns whether this HTTP client supports synchronous streaming downloads via `execute_streaming`. The default implementation returns `false`. When this returns `false`, `download_to_writer` automatically falls back to a buffered download using `execute` instead. ```rust theme={null} fn supports_streaming(&self) -> bool { false } ``` **Returns:** * `true` if `execute_streaming` is implemented (e.g., `UreqHttpClient`) * `false` (default) if streaming is not supported #### execute Executes an HTTP request and buffers the entire response body. ```rust theme={null} async fn execute(&self, request: HttpRequest) -> Result; ``` **Parameters:** * `request: HttpRequest` - The request to execute **Returns:** * `HttpResponse` for any completed exchange — a non-2xx `status_code` is still `Ok`; read it to classify the response * `anyhow::Error` when the request never completed (a transport-level failure), or when a 2xx body exceeds your configured cap **Example:** ```rust theme={null} let request = HttpRequest::get("https://api.example.com/data") .with_header("Authorization", "Bearer token"); let response = client.execute(request).await?; println!("Status: {}", response.status_code); println!("Body: {:?}", response.body); ``` #### execute\_streaming Executes an HTTP request and returns a streaming reader over the response body. **Important:** This is a synchronous method that must be called from within `tokio::task::spawn_blocking`. ```rust theme={null} fn execute_streaming(&self, request: HttpRequest) -> Result; ``` **Parameters:** * `request: HttpRequest` - The request to execute **Returns:** * `StreamingHttpResponse` for any completed exchange — a non-2xx `status_code` is still `Ok` * `anyhow::Error` on a transport-level failure, or if streaming is not supported. A body past your cap is not one of these cases — the reader reaches EOF early instead; see the [contract note](#httpclient-trait) above **Example:** ```rust theme={null} let http_client = Arc::new(UreqHttpClient::new()); let request = HttpRequest::get("https://mmg.whatsapp.net/large-file.enc"); // Must be called inside spawn_blocking let response = tokio::task::spawn_blocking(move || { http_client.execute_streaming(request) }).await??; // Read in chunks let mut buffer = vec![0u8; 4096]; while let Ok(n) = response.body.read(&mut buffer) { if n == 0 { break; } // Process chunk } ``` #### resource\_report Best-effort per-session footprint of this client: idle connection-pool buffers plus any in-flight download/media buffering the implementation can see. Defaulted to `None` ("not reported") — a client that can introspect its pool overrides it. ```rust theme={null} fn resource_report(&self) -> Option { None } ``` **Returns:** * `Some(HttpResourceReport)` with any subset of `pool_connections`, `pool_buffer_bytes`, `inflight_bytes` filled in (each `Option`) * `None` (default) if the client doesn't report Feeds into [`Client::resource_report()`](/api/client#resource_report). `UreqHttpClient` overrides this (below) with an estimate for its default agent's idle pool; media downloads are a real transient-RAM source, so even a coarse estimate is worth reporting. ## Data Structures ### HttpRequest Represents an HTTP request with headers and optional body. ```rust theme={null} pub struct HttpRequest { pub url: String, pub method: String, // "GET" or "POST" pub headers: HashMap, pub body: Option>, } ``` #### Constructors ```rust theme={null} // GET request let request = HttpRequest::get("https://example.com/data"); // POST request let request = HttpRequest::post("https://example.com/upload"); ``` #### Builder Methods ```rust theme={null} // Add header let request = HttpRequest::get("https://example.com/data") .with_header("Authorization", "Bearer token") .with_header("Content-Type", "application/json"); // Add body (for POST) let body = b"{\"key\": \"value\"}"; let request = HttpRequest::post("https://example.com/upload") .with_body(body.to_vec()); ``` ### HttpResponse Represents an HTTP response with buffered body. ```rust theme={null} pub struct HttpResponse { pub status_code: u16, pub body: Vec, } ``` #### Methods ```rust theme={null} // Convert body to string let text = response.body_string()?; println!("Response text: {}", text); ``` ### StreamingHttpResponse Represents an HTTP response with streaming body reader. ```rust theme={null} pub struct StreamingHttpResponse { pub status_code: u16, pub body: Box, } ``` **Usage:** ```rust theme={null} use std::io::Read; let mut buffer = vec![0u8; 8192]; loop { match response.body.read(&mut buffer) { Ok(0) => break, // EOF Ok(n) => { // Process n bytes from buffer process_chunk(&buffer[..n]); } Err(e) => return Err(e.into()), } } ``` ## UreqHttpClient The default HTTP client implementation using the `ureq` crate (v3.4) for synchronous HTTP requests. ### Features * **Blocking I/O** - Uses synchronous ureq, wrapped in `tokio::task::spawn_blocking` * **Connection pooling** - Shares a `ureq::Agent` across requests for connection reuse * **Streaming support** - Implements efficient streaming downloads * **Simple API** - Minimal configuration required * **Thread-safe** - Implements `Clone` for easy sharing (cloning the `Agent` is cheap) * **TLS via rustls** - Uses rustls for TLS, with optional `danger-skip-tls-verify` for testing ### Creating a client ```rust theme={null} use whatsapp_rust_ureq_http_client::UreqHttpClient; // Basic usage — creates a shared ureq::Agent internally let client = UreqHttpClient::new(); // Or use default let client = UreqHttpClient::default(); // With a pre-configured agent (proxy, custom TLS, timeouts, etc.) let agent = ureq::Agent::new_with_config( ureq::config::Config::builder() // your custom settings here .build() ); let client = UreqHttpClient::with_agent(agent); ``` #### with\_agent ```rust theme={null} pub fn with_agent(agent: ureq::Agent) -> Self ``` Creates a client with a pre-configured `ureq::Agent`. This lets you configure proxy support, custom TLS, timeouts, or any other agent-level settings externally. This is the primary extension point for customizing HTTP behavior — for example, routing media uploads and downloads through a proxy, or using custom CA certificates. **Parameters:** * `agent` - A pre-configured `ureq::Agent` **Example — proxy support:** ```rust theme={null} use ureq::config::Config; let agent: ureq::Agent = Config::builder() .proxy(ureq::Proxy::new("socks5://127.0.0.1:1080")?) .build() .into(); let client = UreqHttpClient::with_agent(agent); ``` See [custom backends — proxy and custom TLS](/guides/custom-backends#proxy-and-custom-tls) for a complete guide. ### The version fetch does not pool a connection `Client::connect()` fetches a version update unless you've set `with_version` or the cached version is under 24h old. The source depends on the target: everywhere except `wasm32`, it's `https://web.whatsapp.com/sw.js`; that request sends `Connection: close`, so ureq drops the connection at cleanup instead of returning it to the pool. Previously this fetch left one idle TLS connection resident for the rest of the session, even for a session that never touched media, measured at roughly 88 KiB of `RssAnon` per session. This change eliminates that idle-connection cost. It doesn't guarantee zero residual state, though — a shared agent can still retain a small TLS session-resumption ticket after the handshake (see [below](#sharing-one-client-across-many-sessions)). On `wasm32`, `sw.js` can't be reached from a page. It only answers 200 to a `Sec-Fetch-Site: none` request. That's a forbidden header name a script can't set, so a browser drops it and gets a 400 instead. No response from `web.whatsapp.com` carries `Access-Control-Allow-Origin` either, so a request that got past the header would still fail cross-origin. So the wasm build instead reads the same build revision from `https://connect.facebook.net/en_US/sdk.js` — the Facebook JS SDK bundle. Meta serves it with `Access-Control-Allow-Origin: *` for cross-origin loading, with no fetch-metadata gate. The number is Meta's shared `www` build revision. It's identical across `web.whatsapp.com`, `facebook.com`, `instagram.com`, and `messenger.com`, so it's the same revision `sw.js` would have reported. The request adds no headers of its own. There's no fallback between the two sources — whichever one applies to the target is the only one tried, never the other. What happens after a failure there differs by target, though: on native, it surfaces as `ConnectError::Version`. On `wasm32`, as of PR #1360, it usually doesn't — the connection survives on the version the device already holds, and [`Event::Connected`](/concepts/events#connected) reports the fallback via `app_version_fallback` instead. See [Connected](/concepts/events#connected) for when that fallback fires and when a `wasm32` failure still reaches `ConnectError::Version`. `resource_report()` reflects the same change for the default client: a request carrying `Connection: close` is treated as non-pooling, so a session using `UreqHttpClient::new()` whose only HTTP traffic is the version fetch reports an empty pool (`pool_connections: Some(0)`, `pool_buffer_bytes: Some(0)`) instead of latching onto the 96 KiB cap described [below](#internal-implementation) after its first request. A client created with `UreqHttpClient::with_agent(...)` continues to report `None` because its pool configuration is opaque. Media requests are untouched by this — the pool still exists there to make the next range request against the same CDN host cheap. ### Sharing one client across many sessions If your process runs many WhatsApp sessions, build **one** `UreqHttpClient`. Wrap it in an `Arc` and pass it to every builder with [`BotBuilder::with_http_client_arc`](/api/bot#with_http_client_arc) instead of constructing one per session. `UreqHttpClient` is also `Clone`, and cloning it shares the underlying `ureq::Agent` and therefore its connection pool — so `with_http_client(shared_http_client.clone())` shares just as well. Reach for `with_http_client_arc` instead once the client is already type-erased to `Arc`, since nothing lets that reach the by-value setter at all. This is worth doing for a process with pooled HTTP traffic — media, in practice. An idle session retains no idle connection-pool buffers (on the non-`wasm32` targets `UreqHttpClient` runs on, the version fetch sends `Connection: close`, so it pools no connection either way — though a shared agent can still hold onto a small TLS session-resumption ticket, see [above](#the-version-fetch-does-not-pool-a-connection)), but a session that has transferred media retains its own connection-pool buffers — on the order of tens of KiB of live heap over plain HTTP, more over TLS — for as long as its `UreqHttpClient` lives. Building one per bot pays that cost once per session; sharing collapses the whole fleet onto one pool. **Does sharing serialize requests? No.** `ureq::Agent` holds its pool lock only across checkout, never across the request itself, so concurrent requests through one shared client run concurrently — each still occupies its own `spawn_blocking` thread, exactly as it would with a client per bot. **What sharing does change: the idle pool is capped per agent, not per bot.** `UreqHttpClient::new()`'s default agent retains 3 idle connections and 2 per host — sized for one bot's traffic, not a fleet's. Share it across several concurrent workers and they now contend over that one small pool; measured at 8 concurrent workers, a shared default agent reused \~80% of connections versus \~95% with one client each. If you're sharing across meaningful concurrency, size a `ureq::Agent` for the fleet with [`with_agent`](#with_agent) before sharing it, raising **both** `max_idle_connections` and `max_idle_connections_per_host`. Raising only the global cap can still leave you bottlenecked: media traffic concentrates on a small, fixed set of CDN hosts — WhatsApp's [default media route](/api/download#mediaroute-and-mediahost) is two, a primary and a fallback — so whichever of those hosts a request lands on, the per-host cap of 2 binds well before the global one does. Sized for the fleet, reuse comes back in line with a client per bot while retention stays collapsed into one pool. You can share the connection with the same per-request auth guarantees as an unshared one. Media requests carry their own auth per request, and the client sends no cookies. A shared connection therefore doesn't leak anything between sessions beyond what the shared source IP already reveals — with two exceptions. A live pooled connection reused across sessions lets the CDN see both sessions' requests on the same TCP/TLS stream, a stronger correlation signal than a shared IP alone. TLS session resumption is the other: it lets a server correlate two sessions even across a source-IP change. Both are why sharing stays opt-in rather than the default. ```rust theme={null} use std::sync::Arc; use whatsapp_rust::http::{HttpClient, UreqHttpClient}; let shared_http_client: Arc = Arc::new(UreqHttpClient::new()); let mut handles = Vec::new(); for backend in session_backends { let bot = Bot::builder() .with_backend(backend) .with_http_client_arc(shared_http_client.clone()) // ... .build() .await?; // spawn() runs the bot in the background and returns a BotHandle; // keep it so the handle (and the task it tracks) isn't dropped. handles.push(bot.spawn()); } ``` ### Usage Examples #### Basic GET Request ```rust theme={null} use whatsapp_rust_ureq_http_client::UreqHttpClient; use wacore::net::HttpRequest; #[tokio::main] async fn main() -> Result<(), Box> { let client = UreqHttpClient::new(); let request = HttpRequest::get("https://api.example.com/data") .with_header("User-Agent", "whatsapp-rust"); let response = client.execute(request).await?; if response.status_code == 200 { println!("Success! Body length: {}", response.body.len()); } Ok(()) } ``` #### POST request with body ```rust theme={null} let client = UreqHttpClient::new(); let body = serde_json::to_vec(&json!({ "key": "value", "number": 42 }))?; let request = HttpRequest::post("https://api.example.com/upload") .with_header("Content-Type", "application/json") .with_body(body); let response = client.execute(request).await?; ``` #### Streaming Download ```rust theme={null} use std::fs::File; use std::io::Write; let client = Arc::new(UreqHttpClient::new()); let url = "https://mmg.whatsapp.net/file.enc"; // Must use spawn_blocking for streaming let file_data = tokio::task::spawn_blocking(move || -> Result> { let request = HttpRequest::get(url); let response = client.execute_streaming(request)?; if response.status_code != 200 { return Err(anyhow::anyhow!("HTTP {}", response.status_code)); } let mut buffer = vec![0u8; 65536]; let mut output = Vec::new(); let mut reader = response.body; loop { match reader.read(&mut buffer) { Ok(0) => break, Ok(n) => output.extend_from_slice(&buffer[..n]), Err(e) => return Err(e.into()), } } Ok(output) }).await??; println!("Downloaded {} bytes", file_data.len()); ``` ### Internal Implementation The `UreqHttpClient` wraps a shared `ureq::Agent` for connection pooling. All requests go through the agent rather than standalone `ureq::get()`/`ureq::post()` functions: ```rust theme={null} #[derive(Debug, Clone)] pub struct UreqHttpClient { agent: ureq::Agent, /// Cap for `execute` and the reader `execute_streaming` returns. /// Defaults to WhatsApp's 2 GiB max file size; override with `with_max_body_bytes`. max_body_bytes: u64, } impl UreqHttpClient { pub fn new() -> Self { Self { agent: ureq::Agent::new_with_defaults(), max_body_bytes: DEFAULT_MAX_BODY_BYTES, // 2 GiB } } } ``` When the `danger-skip-tls-verify` feature is enabled, the agent is built with TLS verification disabled: ```rust theme={null} use ureq::config::Config; use ureq::tls::TlsConfig; let agent: ureq::Agent = Config::builder() .tls_config(TlsConfig::builder().disable_verification(true).build()) .build() .into(); ``` `UreqHttpClient::new()` (the default agent) tracks whether it has ever dispatched a request it could actually send. Before that, `resource_report()` reports `pool_connections: Some(0)`, `pool_buffer_bytes: Some(0)`, `inflight_bytes: None` — ureq allocates its buffers per connection, not per agent, so a client that has never connected really does hold an empty pool. Once a request has gone out, it reports the cap instead: up to 3 idle connections, each with a 16 KiB input and 16 KiB output buffer, so `pool_connections: Some(3)`, `pool_buffer_bytes: Some(96 * 1024)`. That cap is an upper bound estimate, not a live measurement, and it stays set even if every subsequent request fails — a failed or redirected request still opens (or reuses) a connection, so the pool is no longer provably empty. "Dispatched" is decided *before* the request goes on the wire — a request ureq itself refuses to build (an unsupported method, a malformed URI, or a header it rejects) never flips the flag, and neither does a request that reaches the wire but carries `Connection: close` (see [above](#the-version-fetch-does-not-pool-a-connection)): ureq closes that connection at cleanup instead of pooling it, so nothing is retained for the flag to describe. `UreqHttpClient::with_agent(...)` (a caller-supplied agent) reports `None` throughout — its buffer and pool configuration is opaque, and since every clone of that agent shares one pool it may already have connected before reaching this client, so an empty pool isn't a safe guess either. Cloning a client shares the underlying `ureq::Agent` and therefore its pool: a request made through one clone is reflected in every other clone's `resource_report()`, including clones held by other sessions when you've [shared one client across many sessions](#sharing-one-client-across-many-sessions). `pool_connections` and `pool_buffer_bytes` describe that one shared pool, not a per-session allocation. If you sum `Client::resource_report().await.http` across sessions that share a client, sum it once for the shared agent instead of once per session — otherwise you count the same pool N times over. ureq 3.4 treats any 4xx/5xx as `ureq::Error::StatusCode` by default — the opposite of the `HttpClient` contract above. `UreqHttpClient` sends every request through `status_as_response`, which disables `http_status_as_error` **per request** instead of once on the shared agent. That placement matters: a caller-supplied agent from [`with_agent`](#with_agent) carries ureq's own defaults, not `UreqHttpClient`'s, so only a per-request override reaches it too: ```rust theme={null} fn status_as_response(req: ureq::RequestBuilder) -> ureq::RequestBuilder { req.config().http_status_as_error(false).build() } ``` The `execute` method clones the agent and wraps the call in `spawn_blocking`: ```rust theme={null} #[async_trait] impl HttpClient for UreqHttpClient { async fn execute(&self, request: HttpRequest) -> Result { let agent = self.agent.clone(); let max_body_bytes = self.max_body_bytes; // ureq is blocking, so wrap in spawn_blocking tokio::task::spawn_blocking(move || { let response = match request.method.as_str() { "GET" => { let mut req = status_as_response(agent.get(&request.url)); for (key, value) in &request.headers { req = req.header(key, value); } req.call()? } "POST" => { let mut req = status_as_response(agent.post(&request.url)); for (key, value) in &request.headers { req = req.header(key, value); } if let Some(body) = request.body { req.send(&body[..])? } else { req.send(&[])? } } method => { return Err(anyhow::anyhow!("Unsupported HTTP method: {}", method)); } }; let status_code = response.status().as_u16(); let body = read_body(response, max_body_bytes)?; Ok(HttpResponse { status_code, body }) }) .await? } } ``` `read_body` is where the non-2xx contract meets your `max_body_bytes` cap, and it treats the two cases differently. A 2xx body *is* the payload, so an over-cap read stays an `Err` — you must never mistake a truncated media file for a complete one. A non-2xx body is diagnostic text (an error page), so `read_body` truncates it instead of erroring. That truncation cap is 64 KiB on top of `max_body_bytes`, not instead of it: losing the tail of an error page costs nothing, but losing the status code costs the media-conn refresh: ```rust theme={null} const ERROR_BODY_CAP: u64 = 64 * 1024; fn read_body(response: ureq::http::Response, max_body_bytes: u64) -> Result> { if response.status().is_success() { return Ok(response .into_body() .into_with_config() .limit(max_body_bytes) .read_to_vec()?); } let mut body = Vec::new(); let mut reader = std::io::Read::take( response.into_body().into_reader(), max_body_bytes.min(ERROR_BODY_CAP), ); // A read that fails partway still leaves the status worth returning. let _ = std::io::Read::read_to_end(&mut reader, &mut body); Ok(body) } ``` The `execute_streaming` method is synchronous (no `spawn_blocking`) because it's called from within a blocking context. It also uses the shared agent and `status_as_response`: ```rust theme={null} fn execute_streaming(&self, request: HttpRequest) -> Result { let response = match request.method.as_str() { "GET" => { let mut req = status_as_response(self.agent.get(&request.url)); for (key, value) in &request.headers { req = req.header(key, value); } req.call()? } method => { return Err(anyhow::anyhow!( "Streaming only supports GET, got: {}", method )); } }; let status_code = response.status().as_u16(); let reader = std::io::Read::take(response.into_body().into_reader(), self.max_body_bytes); Ok(StreamingHttpResponse { status_code, body: Box::new(reader), }) } ``` Notice `execute_streaming` doesn't error when a body exceeds `max_body_bytes`, unlike `read_body` above. `std::io::Read::take` reaches EOF at the cap instead of raising an error, so `execute_streaming` itself always returns `Ok` once the response headers arrive. That silent truncation is safe inside `Client::download` and `download_to_writer`: they decrypt the result and verify a MAC or SHA over it, so a body truncated at the cap fails that check instead of passing as a complete file. It is **not** safe if you call `execute_streaming` directly, the way the [Streaming Download](#streaming-download) example above does — that example has no integrity check of its own, so a truncated body would come back as an ordinary success. If you call `execute_streaming` directly, check the response against an expected length (a `Content-Length` header, for example) or your own integrity check before you trust it. `execute_upload` follows the buffered shape, not the streaming one: it applies `status_as_response` to the request builder, then reads the response through `read_body`. This is what makes [upload's auth-error retry](/api/upload#automatic-retry-on-auth-errors) reachable at all. Before this fix, a rejected upload's status was swallowed into an opaque transport error, so the media-conn refresh it should have triggered never ran. ## Implementing custom HTTP clients You can implement custom HTTP clients for different runtimes or requirements. ### Example: Reqwest client (async) ```rust theme={null} use async_trait::async_trait; use wacore::net::{HttpClient, HttpRequest, HttpResponse}; #[derive(Clone)] pub struct ReqwestHttpClient { client: reqwest::Client, } impl ReqwestHttpClient { pub fn new() -> Self { Self { client: reqwest::Client::new(), } } } #[async_trait] impl HttpClient for ReqwestHttpClient { async fn execute(&self, request: HttpRequest) -> Result { let mut req = match request.method.as_str() { "GET" => self.client.get(&request.url), "POST" => self.client.post(&request.url), method => return Err(anyhow::anyhow!("Unsupported method: {}", method)), }; // Add headers for (key, value) in request.headers { req = req.header(key, value); } // Add body for POST if let Some(body) = request.body { req = req.body(body); } let response = req.send().await?; let status_code = response.status().as_u16(); let body = response.bytes().await?.to_vec(); Ok(HttpResponse { status_code, body }) } // supports_streaming() defaults to false, so download_to_writer // will use a buffered fallback automatically — no need to implement // execute_streaming. } ``` Notice what this example doesn't do: it never calls `.error_for_status()`. `reqwest::RequestBuilder::send()` (`req.send()` above) already returns `Ok` for a non-2xx status by default, so this example matches the `HttpClient` contract for free. Don't add `.error_for_status()` here — it turns every 4xx/5xx into an `Err` and breaks the contract. ### Example: mock client for testing ```rust theme={null} use async_trait::async_trait; use std::collections::HashMap; pub struct MockHttpClient { responses: HashMap, } impl MockHttpClient { pub fn new() -> Self { Self { responses: HashMap::new(), } } pub fn with_response(mut self, url: &str, response: HttpResponse) -> Self { self.responses.insert(url.to_string(), response); self } } #[async_trait] impl HttpClient for MockHttpClient { async fn execute(&self, request: HttpRequest) -> Result { self.responses .get(&request.url) .cloned() .ok_or_else(|| anyhow::anyhow!("No mock response for {}", request.url)) } } // Usage in tests: let client = MockHttpClient::new() .with_response( "https://api.example.com/test", HttpResponse { status_code: 200, body: b"test data".to_vec(), }, ); ``` ## Usage with Bot builder ```rust theme={null} use whatsapp_rust::Bot; use whatsapp_rust_ureq_http_client::UreqHttpClient; use std::sync::Arc; #[tokio::main] async fn main() -> Result<(), Box> { let http_client = UreqHttpClient::new(); let mut bot = Bot::builder() .with_backend(backend) .with_transport_factory(transport_factory) .with_http_client(http_client) .on_event(|event, client| async move { /* handle events */ }) .build() .await?; Ok(()) } ``` ## Best Practices 1. **Blocking operations** - Always wrap blocking HTTP libraries in `tokio::task::spawn_blocking` 2. **Streaming for large files** - Use `execute_streaming` for media downloads to avoid buffering 3. **Error handling** - Return descriptive errors with context 4. **Timeouts** - ureq 3.4 applies per-IP connection timeouts automatically; implement request-level timeouts for reliability 5. **Retries** - Consider retry logic for transient failures 6. **Connection pooling** - Use a shared `ureq::Agent` (as `UreqHttpClient` does) for connection reuse within a session, and [share one `UreqHttpClient` across sessions](#sharing-one-client-across-many-sessions) when running many of them in one process ## Media Operations The HTTP client is primarily used for media operations in whatsapp-rust: ### Media upload flow The client manages media connections internally. Use the high-level `upload` method instead of building requests manually: ```rust theme={null} use whatsapp_rust::download::MediaType; // Upload handles encryption, auth, and CDN host selection automatically let upload_response = client.upload(media_bytes, MediaType::Image, Default::default()).await?; ``` Under the hood, the client uses the HTTP client to: 1. Fetch media connection credentials from WhatsApp servers 2. Encrypt the media with AES-256-CBC 3. Upload to the CDN with proper auth headers 4. Parse the response for `direct_path` and file hashes ### Media download flow ```rust theme={null} // 1. Extract media info from message let media_info = extract_media_info(&message)?; // 2. Build download URL let url = format!("https://mmg.whatsapp.net{}", media_info.direct_path); // 3. Download with streaming (in spawn_blocking) let file_data = tokio::task::spawn_blocking(move || { let request = HttpRequest::get(&url); let response = http_client.execute_streaming(request)?; // Read and decrypt chunks decrypt_media_stream(response.body, &media_key, &expected_sha256) }).await??; ``` ## Testing ### Unit test example ```rust theme={null} #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_http_get() { let client = UreqHttpClient::new(); let request = HttpRequest::get("https://httpbin.org/get") .with_header("User-Agent", "test"); let response = client.execute(request).await.unwrap(); assert_eq!(response.status_code, 200); } #[tokio::test] async fn test_http_post() { let client = UreqHttpClient::new(); let body = b"test data"; let request = HttpRequest::post("https://httpbin.org/post") .with_body(body.to_vec()); let response = client.execute(request).await.unwrap(); assert_eq!(response.status_code, 200); } } ``` ## See Also * [Storage Traits](/api/store) - Storage backend abstraction * [Transport Trait](/api/transport) - Network transport abstraction * [Client API](/api/client) - Main client interface * [Media Handling](/guides/media-handling) - Guide to sending and receiving media # Labels Source: https://whatsapp-rust.jlucaso.com/api/labels Create, delete, and associate WhatsApp chat labels, and react to label changes from linked devices The `Labels` feature manages WhatsApp chat labels (etiquetas) — the colored tags WhatsApp Business uses to organize chats. Outbound calls create, rename, recolor, or delete labels and add or remove a label on a chat. Inbound label changes made on a linked device (such as WhatsApp Web or the phone) are delivered as events. Labels sync across all linked devices via WhatsApp's app state sync mechanism (the `regular` collection, action version 3). ## Access Access label operations through the client: ```rust theme={null} let labels = client.labels(); ``` ## Create or update a label ### create\_label Create a new label or update an existing one. Because app state is an upsert keyed by `label_id`, calling this with an existing `label_id` renames or recolors that label. ```rust theme={null} pub async fn create_label( &self, label_id: &str, name: &str, color: i32, ) -> Result<(), AppStateError> ``` **Parameters:** * `label_id` — Stable identifier for the label. Must be non-empty. Reuse the same `label_id` to update the label later. * `name` — Display name shown in the WhatsApp UI. Must be non-empty. * `color` — WhatsApp color index for the label swatch. **Example:** ```rust theme={null} // Create a new "Work" label client.labels().create_label("5", "Work", 2).await?; // Rename and recolor the same label later client.labels().create_label("5", "Clients", 4).await?; ``` ## Delete a label ### delete\_label Delete a label. Existing chat associations are kept by the server; WhatsApp Web prunes them from its local database on receipt of the delete. ```rust theme={null} pub async fn delete_label(&self, label_id: &str) -> Result<(), AppStateError> ``` **Parameters:** * `label_id` — The label to delete. Must be non-empty. **Example:** ```rust theme={null} client.labels().delete_label("5").await?; ``` ## Associate a label with a chat ### add\_chat\_label Tag a chat with a label. ```rust theme={null} pub async fn add_chat_label( &self, label_id: &str, chat_jid: &Jid, ) -> Result<(), AppStateError> ``` **Parameters:** * `label_id` — The label to apply. Must be non-empty. * `chat_jid` — The chat to label. **Example:** ```rust theme={null} use wacore_binary::jid::Jid; let chat: Jid = "15551234567@s.whatsapp.net".parse()?; client.labels().add_chat_label("5", &chat).await?; ``` ### remove\_chat\_label Remove a label from a chat. ```rust theme={null} pub async fn remove_chat_label( &self, label_id: &str, chat_jid: &Jid, ) -> Result<(), AppStateError> ``` **Parameters:** * `label_id` — The label to remove. Must be non-empty. * `chat_jid` — The chat to untag. **Example:** ```rust theme={null} client.labels().remove_chat_label("5", &chat).await?; ``` ## Associate a label with a message ### add\_message\_label Use this to associate a label with a single message. Unlike [`add_chat_label`](#add_chat_label) above, this association is keyed by the message as well as the chat, under the `label_message` action. ```rust theme={null} pub async fn add_message_label( &self, label_id: &str, chat_jid: &Jid, message_id: &str, ) -> Result<(), AppStateError> ``` **Parameters:** * `label_id` — The label to apply. Must be non-empty. * `chat_jid` — The chat containing the message. * `message_id` — The message to label. Must be non-empty. **Example:** ```rust theme={null} client.labels().add_message_label("5", &chat, "MESSAGE_ID").await?; ``` ### remove\_message\_label Remove a label association from a single message. ```rust theme={null} pub async fn remove_message_label( &self, label_id: &str, chat_jid: &Jid, message_id: &str, ) -> Result<(), AppStateError> ``` **Parameters:** * `label_id` — The label to remove. Must be non-empty. * `chat_jid` — The chat containing the message. * `message_id` — The message to unlabel. Must be non-empty. **Example:** ```rust theme={null} client.labels().remove_message_label("5", &chat, "MESSAGE_ID").await?; ``` Both calls send one mutation per message. There is no batch form on the wire, matching WhatsApp Web's own builder. `label_message` is not in the generated app-state schema registry, because WhatsApp Web's current live action table no longer builds this mutation (only `label_edit` and `label_jid` remain there). It is still a first-class protocol action: the protobuf action registry declares it, and both whatsmeow and Baileys build the identical index. For that reason the schema is hand-maintained separately from the generated set. The wire index is `["label_message", labelId, chatJid, messageId, "0", "0"]` on the `regular` collection at action version 3. The trailing `"0", "0"` pair is the message-key `fromMe`/`participant` tail every message-scoped action carries, and no observed source shows it holding anything but its defaults. ## Inbound label events Label changes made on a linked device are delivered through the event bus. All three events carry the underlying app state action so you can read the new name, color, deleted flag, or labeled state directly. ```rust theme={null} use wacore::types::events::Event; .on_event(|event, _client| async move { match &*event { Event::LabelEditUpdate(update) => { if update.action.deleted == Some(true) { println!("Label {} deleted", update.label_id); } else { println!( "Label {} = {:?} (color {:?})", update.label_id, update.action.name, update.action.color, ); } } Event::LabelAssociationUpdate(update) => { let attached = update.action.labeled == Some(true); println!( "Label {} {} chat {}", update.label_id, if attached { "added to" } else { "removed from" }, update.chat_jid, ); } Event::MessageLabelAssociationUpdate(update) => { let attached = update.action.labeled == Some(true); println!( "Label {} {} message {} in chat {}", update.label_id, if attached { "added to" } else { "removed from" }, update.message_id, update.chat_jid, ); } _ => {} } }) ``` `from_full_sync` is `true` when the event came from an initial app state full sync, so you can suppress UI notifications during bootstrap. ## App state sync | Action | Collection | Action version | | -------------------------------------- | ---------- | -------------- | | Create / update / delete label | `regular` | 3 | | Add / remove chat label association | `regular` | 3 | | Add / remove message label association | `regular` | 3 | App state sync requires the relevant encryption keys, which arrive during initial sync after pairing. Outbound label calls may fail if invoked immediately after pairing before sync completes. `create_label`, `delete_label`, `add_chat_label`, and `remove_chat_label` are thin wrappers over the same app-state send path as [chat actions](/api/chat-actions#app-state-sync). They share its conflict-retry behavior, added in PR [#1158](https://github.com/oxidezap/whatsapp-rust/pull/1158). A call that loses a version race with another linked device is no longer silently dropped. The client applies the winning patches, rebuilds the mutation, and resends. It retries up to 5 times before returning `Err`. See [Chat actions — App state sync](/api/chat-actions#app-state-sync) for the full explanation. ## Error handling All methods return `Result<(), AppStateError>`. If you pass an empty `label_id` (or an empty `name` to `create_label`), the call fails immediately with `AppStateError::InvalidRequest` before any network work is done. A call can also return `AppStateError::Internal` if no app state sync key is available yet, a network error occurred, or an app-state version conflict with another device could not be resolved after 5 rebuild-and-resend attempts (see [App state sync](#app-state-sync) above). ```rust theme={null} use whatsapp_rust::AppStateError; if let Err(AppStateError::InvalidRequest(msg)) = client.labels().create_label("", "Work", 0).await { eprintln!("Validation failed: {msg}"); // "label_id cannot be empty" } ``` ## Complete example ```rust theme={null} use whatsapp_rust::Client; use wacore_binary::jid::Jid; use std::sync::Arc; async fn tag_vip_chats(client: &Arc) -> anyhow::Result<()> { let vip: Jid = "15551234567@s.whatsapp.net".parse()?; let lead: Jid = "15559876543@s.whatsapp.net".parse()?; // Create a "VIP" label (color index 3) client.labels().create_label("vip", "VIP", 3).await?; // Tag two chats client.labels().add_chat_label("vip", &vip).await?; client.labels().add_chat_label("vip", &lead).await?; // Later: drop one of the chats from the label client.labels().remove_chat_label("vip", &lead).await?; Ok(()) } ``` ## See also * [Events](/concepts/events) — Handle `LabelEditUpdate`, `LabelAssociationUpdate`, and `MessageLabelAssociationUpdate` * [Chat actions](/api/chat-actions) — Archive, pin, mute, star, and other app-state-synced chat operations * [State management](/advanced/state-management) — How app state sync works under the hood # Media reupload Source: https://whatsapp-rust.jlucaso.com/api/media-reupload Request re-upload of media with expired CDN URLs The `MediaReupload` struct provides a method to request the server to re-upload media when the original CDN URL has expired. This is essential for long-running bots that need to download media from older messages. ## Access Access media reupload operations through the client: ```rust theme={null} let media_reupload = client.media_reupload(); ``` ## Methods ### request Request the server to re-upload media for a message with an expired URL. ```rust theme={null} pub async fn request( &self, req: &MediaReuploadRequest<'_>, ) -> Result ``` Parameters identifying the media message to re-upload. The result of the reupload request. On success, contains a new `direct_path` for downloading. **Example:** ```rust theme={null} use whatsapp_rust::features::media_reupload::{MediaReuploadRequest, MediaRetryResult}; let result = client.media_reupload().request(&MediaReuploadRequest { msg_id: "3EB0ABC123", chat_jid: &chat_jid, media_key: &media_key_bytes, is_from_me: false, participant: Some(&sender_jid), // Required for group messages }).await?; match result { MediaRetryResult::Success { direct_path } => { println!("New download path: {}", direct_path); // Use direct_path to download the media } MediaRetryResult::NotFound => { eprintln!("Media no longer available on server"); } MediaRetryResult::GeneralError => { eprintln!("Server returned an error"); } MediaRetryResult::DecryptionError => { eprintln!("Failed to decrypt server response"); } } ``` ### request\_many Request re-upload for several messages at once, concurrently. Use this for bulk recovery — e.g. resuming a client after a long offline period leaves many expired media URLs — since it completes in roughly one [request](#request) timeout instead of the serial sum of per-item waits. ```rust theme={null} pub async fn request_many( &self, reqs: &[MediaReuploadRequest<'_>], ) -> Vec> ``` The batch of reupload requests. One result per input request, in the same order as `reqs`. One item failing does not abort the others. **Example:** ```rust theme={null} use whatsapp_rust::features::media_reupload::{MediaReuploadRequest, MediaRetryResult}; let reqs = vec![ MediaReuploadRequest { msg_id: "3EB0ABC123", chat_jid: &chat_jid, media_key: &media_key_bytes, is_from_me: false, participant: Some(&sender_jid), }, MediaReuploadRequest { msg_id: "3EB0DEF456", chat_jid: &other_chat_jid, media_key: &other_media_key_bytes, is_from_me: false, participant: None, }, ]; for result in client.media_reupload().request_many(&reqs).await { match result { Ok(MediaRetryResult::Success { direct_path }) => { println!("New download path: {}", direct_path); } Ok(other) => eprintln!("Reupload failed: {:?}", other), Err(e) => eprintln!("Request error: {}", e), } } ``` Duplicate `msg_id`s within one batch are rejected (past the first occurrence) with `MediaReuploadError::InvalidRequest` — the underlying `mediaretry` waiter is keyed on message id alone, so two in-flight waiters for the same id could otherwise resolve each other with the wrong payload. A message id is unique per message, so a duplicate in a batch is a caller mistake. ## Protocol flow 1. The client encrypts a `ServerErrorReceipt` protobuf using an HKDF-derived key from the media key 2. A `` stanza is sent with the encrypted payload and `` metadata 3. The client waits up to 30 seconds for a `` response 4. The response is decrypted and the new `directPath` is extracted ## Types ### MediaReuploadRequest ```rust theme={null} pub struct MediaReuploadRequest<'a> { pub msg_id: &'a str, pub chat_jid: &'a Jid, pub media_key: &'a [u8], pub is_from_me: bool, pub participant: Option<&'a Jid>, } ``` | Field | Type | Description | | ------------- | -------------- | ---------------------------------------------------------------------- | | `msg_id` | `&str` | The message ID containing the media | | `chat_jid` | `&Jid` | The chat JID where the message was received | | `media_key` | `&[u8]` | Raw media key bytes (32 bytes, from the message's `mediaKey` field) | | `is_from_me` | `bool` | Whether the message was sent by you | | `participant` | `Option<&Jid>` | For group/broadcast messages, the participant JID who sent the message | ### MediaRetryResult ```rust theme={null} pub enum MediaRetryResult { Success { direct_path: String }, GeneralError, NotFound, DecryptionError, } ``` | Variant | Description | | ----------------- | ----------------------------------------------------------------------------- | | `Success` | Server re-uploaded the media. Contains the new `direct_path` for downloading. | | `GeneralError` | Server returned a general error. | | `NotFound` | Media is no longer available on the server. | | `DecryptionError` | Failed to decrypt the server response. | ## Error handling The method returns `Result`. The `MediaRetryResult` enum itself distinguishes between server-side success and failure; `MediaReuploadError` covers transport and validation failures: ```rust theme={null} #[non_exhaustive] pub enum MediaReuploadError { #[error("{0}")] Client(#[from] ClientError), #[error("client is not logged in")] NotLoggedIn, #[error("invalid media reupload request: {0}")] InvalidRequest(String), #[error("media retry notification timed out")] Timeout, #[error("{0}")] Internal(#[from] anyhow::Error), } ``` * `Client` — wraps `ClientError` (transport/client-layer failures) * `NotLoggedIn` — Cannot determine own JID * `InvalidRequest` — Newsletter messages are not supported for media reupload; or, when using `request_many`, a `msg_id` appears more than once in the batch * `Timeout` — The server did not respond within 30 seconds * `Internal` — Encryption failure or other internal error ```rust theme={null} use whatsapp_rust::MediaReuploadError; match client.media_reupload().request(&req).await { Ok(MediaRetryResult::Success { direct_path }) => { // Re-download using new path } Ok(other) => { eprintln!("Reupload failed: {:?}", other); } Err(MediaReuploadError::Timeout) => { eprintln!("Request timed out"); } Err(MediaReuploadError::NotLoggedIn) => { eprintln!("Not authenticated"); } Err(e) => { eprintln!("Request error: {}", e); } } ``` Media reupload requests have a 30-second timeout. If the server does not respond in time, the request fails with a timeout error. Media reupload is not supported for newsletter messages. Newsletter messages do not have media keys, so the encrypted retry protocol cannot be used. ## See also * [Media handling guide](/guides/media-handling) - Upload and download media * [Upload API](/api/upload) - Upload media files * [Download API](/api/download) - Download media files # MEX (GraphQL) Source: https://whatsapp-rust.jlucaso.com/api/mex Execute typed GraphQL queries and mutations via Meta Exchange persisted operations The `Mex` feature wraps WhatsApp's Meta Exchange (MEX) GraphQL API. Operations are persisted: you reference each query or mutation by a `(name, id)` pair pulled from a generated operation module, and pass its typed `Variables`. WhatsApp Web bundle updates rotate the numeric `id`, so the human-readable `name` keeps diagnostics stable across releases. ## Access ```rust theme={null} let mex = client.mex(); ``` ## Building a request Each persisted operation lives in its own module under [`wacore::iq::mex_operations`](/api/wacore). Every module exposes: * `NAME` — the operation name (e.g. `"WAWebJoinNewsletterMutation"`). * `DOC_ID` — the current persisted document ID. * `OPERATION_KIND` — `"query"` or `"mutation"`. * `VARIABLE_KEYS` — every variable name the persisted document declares, e.g. `&["fetch_viewer_metadata", "fetch_full_image", ...]`. * `Variables` — a typed struct matching the operation's input shape. * `Response` — a typed struct matching the operation's output shape. Construct a [`MexRequest`](#mexrequest) with [`MexRequest::new`](#new) using those constants and a `Variables` value: ```rust theme={null} use wacore::iq::mex_operations::fetch_newsletter; use whatsapp_rust::features::mex::MexRequest; let request = MexRequest::new( fetch_newsletter::NAME, fetch_newsletter::DOC_ID, fetch_newsletter::VARIABLE_KEYS, fetch_newsletter::Variables { input: Some(fetch_newsletter::Input { key: Some(jid.to_string()), r#type: Some("JID".into()), view_role: Some("GUEST".into()), }), fetch_viewer_metadata: Some(true), fetch_full_image: Some(true), fetch_creation_time: Some(true), fetch_pinned_messages: Some(false), fetch_status_metadata: Some(false), fetch_wamo_sub: Some(false), }, ); let response = client.mex().query(request).await?; ``` A generated `Variables` does not implement `Default`. The server binds a persisted query's variables by name and answers a bare `400 Bad Request` when it can't bind one of them, so every variable the operation declares has to be named at the call site — `..Default::default()` does not compile, and neither does `Variables::default()`. Passing `None` for an `Option` field is still how you send a variable WhatsApp Web itself omits; nested input objects (for example `Updates` on `update_newsletter`) are unaffected and keep `Default`. For operations whose `Variables` schema is too permissive to model exactly, pass any `serde::Serialize` value (for example a `serde_json::json!({...})`) as the variables — `query` and `mutate` are generic over `V: Serialize`. Use [`MexRequest::missing_variables`](#missing_variables) to check such a payload against `VARIABLE_KEYS`, since the compiler can't. ## Methods ### query Execute a GraphQL query. ```rust theme={null} pub async fn query( &self, request: MexRequest, ) -> Result ``` ### mutate Execute a GraphQL mutation. ```rust theme={null} pub async fn mutate( &self, request: MexRequest, ) -> Result ``` **Example — mutation with typed variables:** ```rust theme={null} use wacore::iq::mex_operations::join_newsletter; use whatsapp_rust::features::mex::MexRequest; let request = MexRequest::new( join_newsletter::NAME, join_newsletter::DOC_ID, join_newsletter::VARIABLE_KEYS, join_newsletter::Variables { newsletter_id: Some(jid.to_string()), }, ); let response = client.mex().mutate(request).await?; ``` **Example — loosely-typed variables with `serde_json`:** ```rust theme={null} use serde_json::json; use wacore::iq::mex::MexDoc; use whatsapp_rust::features::mex::MexRequest; let request = MexRequest { doc: MexDoc { name: "WAWebUpdateGroupPropertyMutation", id: "8014515568645029", }, declared_variables: &["group_id", "property", "value"], variables: json!({ "group_id": group_jid.to_string(), "property": "announcement", "value": true, }), }; let response = client.mex().mutate(request).await?; ``` ### fetch\_new\_chat\_message\_capping\_info Fetch the cap on how many new one-on-one conversations you can start in the current cycle. ```rust theme={null} pub async fn fetch_new_chat_message_capping_info( &self, ) -> Result ``` WhatsApp Web issues this request at app launch. It refreshes the cap on a TTL (`wa_individual_new_chat_msg_capping_fetch_ttl_seconds`, 1 hour), gated on `wa_individual_new_chat_msg_capping_enabled`. If the cap doesn't apply to your account, you still get an answer — the status just reports `NONE`. **Example:** ```rust theme={null} use whatsapp_rust::CappingStatus; let capping = client.mex().fetch_new_chat_message_capping_info().await?; match &capping.capping_status { Some(CappingStatus::Capped) => println!("New-chat cap reached for this cycle"), Some(status) => println!("Capping status: {:?}", status), None => {} } if let Some(remaining) = capping.remaining_quota() { println!("{} new chats left this cycle", remaining); } ``` ### get\_username Read this account's own username, its state, and its username key. ```rust theme={null} pub async fn get_username(&self) -> Result, MexError> ``` **Returns:** * `None` — no username is set on this account. WhatsApp Web reads the same 404 the server sends for this case. * `Some(OwnUsername)` — the account's username info. Only reads are exposed. `set_username` and `set_username_key` are not wrapped. They change the account's identity in a way the server does not undo. A wrong call can burn a handle that someone else could otherwise take, and there is no way to test either safely. **Example:** ```rust theme={null} match client.mex().get_username().await? { Some(own) => println!("Username: {:?} (state: {:?})", own.username, own.state), None => println!("No username set on this account"), } ``` ## Types ### MexDoc A persisted-query descriptor. Re-exported from `wacore::iq::mex`. ```rust theme={null} pub struct MexDoc { /// Human-readable operation name (stable across bundle releases). pub name: &'static str, /// Numeric persisted-query ID (rotates with bundle releases). pub id: &'static str, } ``` ### MexRequest A persisted-query descriptor plus its typed variables. `V` is the variables type — usually the generated `Variables` struct for an operation, but any `Serialize` value works. ```rust theme={null} pub struct MexRequest { pub doc: MexDoc, pub declared_variables: &'static [&'static str], pub variables: V, } ``` * `declared_variables` — the variable names the persisted document declares (an operation module's `VARIABLE_KEYS`), carried so [`missing_variables`](#missing_variables) can check a payload against them even when `variables` is a loosely-typed `Serialize` value. #### new Pair an operation's `NAME`, `DOC_ID`, and `VARIABLE_KEYS` with its variables. Prefer this over building `MexRequest` as a struct literal. ```rust theme={null} pub fn new( name: &'static str, id: &'static str, declared_variables: &'static [&'static str], variables: V, ) -> MexRequest ``` #### missing\_variables Declared variables this request's payload does not carry, found by serializing `variables` and checking which of `declared_variables` are absent. ```rust theme={null} pub fn missing_variables(&self) -> Result, serde_json::Error> ``` A non-empty result isn't automatically wrong — WhatsApp Web itself omits an optional variable it has no value for, and this reports that the same way rather than judging it. It exists mainly for the loosely-typed `Serialize` form of a request, where the compiler can't check a `json!` payload against the operation's declared variables the way it checks a `Variables` struct literal. ```rust theme={null} let request = MexRequest::new( fetch_all_newsletters_metadata::NAME, fetch_all_newsletters_metadata::DOC_ID, fetch_all_newsletters_metadata::VARIABLE_KEYS, json!({ "fetch_wamo_sub": false }), ); assert_eq!(request.missing_variables()?, vec!["fetch_status_metadata"]); ``` ### MexResponse Response from a GraphQL operation. ```rust theme={null} pub struct MexResponse { /// Response data (if successful). pub data: Option, /// List of errors (if any). pub errors: Option>, } ``` **Methods:** * `has_data()` — `true` if the response contains data. * `has_errors()` — `true` if the response contains errors. * `fatal_error()` — the first fatal error (any error with an `error_code`), if any. ### MexGraphQLError ```rust theme={null} pub struct MexGraphQLError { pub message: String, pub extensions: Option, } ``` **Methods:** * `error_code()` — the numeric error code, if present. * `is_summary()` — `true` when the error is marked as a summary. * `has_error_code()` — `true` when an error code is set. `MexGraphQLError` is re-exported from the crate root as `whatsapp_rust::MexGraphQLError`, alongside `MexError`, `MexErrorExtensions`, `MexFatalError`, `MexRequest`, `MexResponse`, and `OwnUsername`. ### MexErrorExtensions ```rust theme={null} pub struct MexErrorExtensions { pub error_code: Option, pub is_summary: Option, pub is_retryable: Option, pub severity: Option, } ``` ### MexFatalError The typed cause behind a fatal GraphQL error, from `wacore::iq::mex`. WhatsApp Web treats a GraphQL error carrying an extension code as fatal to the whole request. This struct is what lets that code survive the trip through `IqError::ParseError`'s `source()` chain instead of being flattened into a plain message. ```rust theme={null} #[non_exhaustive] pub struct MexFatalError { pub query: &'static str, pub code: i32, pub message: String, } ``` You won't normally construct or match this directly. `query`/`mutate` already downcast it for you and hand back [`MexError::ExtensionError`](#error-handling). It's public for callers working with `wacore::iq::mex::MexQuerySpec` directly, below the `whatsapp_rust::features::mex::Mex` wrapper. ### OwnUsername This account's own Meta username, as [`get_username`](#get_username) reports it. Every field is optional because the server omits the ones that don't apply. ```rust theme={null} #[non_exhaustive] pub struct OwnUsername { /// The handle, without the display-only `@` prefix. pub username: Option, /// `ACTIVE` or `RESERVED`. pub state: Option, /// The numeric username key that guards lookups of this account by handle. pub key: Option, } ``` ### NewChatMessageCapping How many new one-on-one conversations you can still start in the current cycle, and why. Every field is optional — the server omits any that don't apply to your account's tier. ```rust theme={null} #[non_exhaustive] pub struct NewChatMessageCapping { pub capping_status: Option, pub ote_status: Option, pub mv_status: Option, pub total_quota: Option, pub used_quota: Option, pub cycle_start_timestamp: Option, pub cycle_end_timestamp: Option, pub server_sent_timestamp: Option, } ``` * `total_quota` / `used_quota` — new chats allowed / already started this cycle * `cycle_start_timestamp` / `cycle_end_timestamp` / `server_sent_timestamp` — Unix seconds. The cap lifts once `cycle_end_timestamp` passes. **Methods:** * `remaining_quota() -> Option` — saturating subtraction of `used_quota` from `total_quota`, when both are present; returns `Some(0)` when `used_quota` exceeds `total_quota` ### CappingStatus Where you stand against the cap. ```rust theme={null} pub enum CappingStatus { None, FirstWarning, SecondWarning, Capped, Other(String), } ``` ### CappingOteStatus Whether you're eligible for the one-time extension that lifts the cap for a cycle. ```rust theme={null} pub enum CappingOteStatus { NotEligible, Eligible, ActiveInCurrentCycle, Exhausted, Other(String), } ``` ### CappingMvStatus Your Meta Verified subscription state, which lifts the cap permanently. ```rust theme={null} pub enum CappingMvStatus { NotEligible, NotActive, Active, ActiveUpgradeAvailable, Other(String), } ``` `NewChatMessageCapping`, `CappingStatus`, `CappingOteStatus`, and `CappingMvStatus` are re-exported from the crate root, alongside the other `Mex` types. ## Error handling ```rust theme={null} pub enum MexError { /// Payload missing or malformed with only a descriptive message. PayloadParsing(String), /// Payload contained an invalid JID. InvalidJid(JidError), /// GraphQL error with a numeric code surfaced as a typed error. ExtensionError { code: i32, message: String }, /// Underlying IQ request failed. Request(IqError), /// JSON serialization or deserialization error. Json(serde_json::Error), } ``` `query` and `mutate` automatically convert any fatal GraphQL error (any error with an `error_code`) into `MexError::ExtensionError`. This lets you match on it without inspecting `response.errors` yourself. This now happens for every MEX operation via a typed [`MexFatalError`](#mexfatalerror) carried inside `IqError::ParseError`'s source chain. Previously the code could be lost, surfacing only as a message inside `MexError::Request(IqError::ParseError(_))`. ```rust theme={null} use whatsapp_rust::features::mex::MexError; match client.mex().query(request).await { Ok(response) => { if response.has_data() { println!("Success: {:?}", response.data); } } Err(MexError::ExtensionError { code, message }) => { eprintln!("MEX error {}: {}", code, message); } Err(e) => eprintln!("Request failed: {}", e), } ``` `get_username` treats a fatal 404 as "no username set" rather than a failure. It arrives as either `MexError::ExtensionError { code: 404, .. }` or `MexError::Request(IqError::ServerError { code: 404, .. })` — the two shapes WhatsApp Web's own MEX client raises a 404 from. Either shape returns `Ok(None)` instead of an error. ## Response handling ### Check for data ```rust theme={null} let response = client.mex().query(request).await?; if response.has_data() { let data = response.data.unwrap(); // Process data } ``` ### Handle non-fatal errors Errors without an `error_code` are non-fatal and surface in `response.errors` alongside any partial data. ```rust theme={null} let response = client.mex().query(request).await?; if response.has_errors() { for error in response.errors.as_ref().unwrap() { if !error.is_summary() && !error.has_error_code() { eprintln!("Warning: {}", error.message); } } } ``` ### Decode the typed response Generated operation modules also expose a `Response` struct that mirrors the GraphQL schema. Decode it from `response.data` with `serde_json`: ```rust theme={null} use wacore::iq::mex_operations::fetch_newsletter; let response = client.mex().query(request).await?; if let Some(data) = response.data { let decoded: fetch_newsletter::Response = serde_json::from_value(data)?; if let Some(newsletter) = decoded.xwa2_newsletter { println!("Newsletter: {:?}", newsletter); } } ``` The persisted `DOC_ID` for each operation is regenerated from the latest WhatsApp Web bundle. Pin to a specific crate version if you need a stable wire format. # Newsletter Source: https://whatsapp-rust.jlucaso.com/api/newsletter Newsletter (channel) operations — create, manage, administer, subscribe, and send messages to WhatsApp channels The `Newsletter` feature provides methods for managing WhatsApp newsletter channels, including creation, subscription management, admin operations, reactions, and live updates. Newsletter operations use MEX (GraphQL) for metadata/management and IQ stanzas for message operations. Newsletter message sending is handled by the unified `client.send_message()` method — see the [Send API](/api/send#send_message). Reactions remain on the `Newsletter` struct because they use a different stanza format. Newsletter messages are **plaintext** — they are not encrypted with the Signal protocol. ## Access Access newsletter operations through the client: ```rust theme={null} let newsletter = client.newsletter(); ``` ## Methods ### list\_subscribed List all newsletters the user is subscribed to. ```rust theme={null} pub async fn list_subscribed(&self) -> Result, NewsletterError> ``` **Returns:** * `Vec` — List of subscribed newsletters **Example:** ```rust theme={null} let newsletters = client.newsletter().list_subscribed().await?; for nl in &newsletters { println!("{}: {} ({} subscribers)", nl.jid, nl.name, nl.subscriber_count); } ``` ### get\_metadata Fetch metadata for a newsletter by its JID. ```rust theme={null} pub async fn get_metadata(&self, jid: &Jid) -> Result ``` **Parameters:** * `jid` — Newsletter JID (server must be `newsletter`) **Returns:** * `NewsletterMetadata` — Full newsletter metadata **Example:** ```rust theme={null} let metadata = client.newsletter().get_metadata(&newsletter_jid).await?; println!("Name: {}", metadata.name); println!("Subscribers: {}", metadata.subscriber_count); println!("Verified: {:?}", metadata.verification); ``` ### get\_metadata\_by\_invite Fetch metadata for a newsletter by its invite code. ```rust theme={null} pub async fn get_metadata_by_invite( &self, invite_code: &str, ) -> Result ``` **Parameters:** * `invite_code` — Newsletter invite code string **Returns:** * `NewsletterMetadata` — Full newsletter metadata **Example:** ```rust theme={null} let metadata = client.newsletter() .get_metadata_by_invite("ABC123") .await?; println!("Found: {} ({})", metadata.name, metadata.jid); ``` ### create Create a new newsletter. ```rust theme={null} pub async fn create( &self, name: &str, description: Option<&str>, ) -> Result ``` **Parameters:** * `name` — Newsletter name * `description` — Optional description **Returns:** * `NewsletterMetadata` — Metadata of the newly created newsletter **Example:** ```rust theme={null} let created = client.newsletter() .create("My Channel", Some("A description")) .await?; println!("Created: {} ({})", created.name, created.jid); ``` ### join Join (subscribe to) a newsletter. ```rust theme={null} pub async fn join(&self, jid: &Jid) -> Result ``` **Parameters:** * `jid` — Newsletter JID to join **Returns:** * `NewsletterMetadata` — Metadata with the viewer's role set to `Subscriber` **Example:** ```rust theme={null} let joined = client.newsletter().join(&newsletter_jid).await?; println!("Joined '{}' as {:?}", joined.name, joined.role); ``` ### leave Leave (unsubscribe from) a newsletter. ```rust theme={null} pub async fn leave(&self, jid: &Jid) -> Result<(), NewsletterError> ``` **Parameters:** * `jid` — Newsletter JID to leave **Example:** ```rust theme={null} client.newsletter().leave(&newsletter_jid).await?; ``` ### update Update a newsletter's name and/or description. ```rust theme={null} pub async fn update( &self, jid: &Jid, name: Option<&str>, description: Option<&str>, ) -> Result ``` **Parameters:** * `jid` — Newsletter JID * `name` — New name, or `None` to keep the current name * `description` — New description, or `None` to keep the current description **Returns:** * `NewsletterMetadata` — Updated metadata **Example:** ```rust theme={null} let updated = client.newsletter() .update(&newsletter_jid, Some("New Name"), None) .await?; println!("Updated: {}", updated.name); ``` ### set\_picture Replace a newsletter's picture. Carried by the same mutation as [`update`](#update) — WhatsApp Web edits the picture as base64-encoded JPEG bytes — so the response is the newsletter's refreshed metadata. ```rust theme={null} pub async fn set_picture( &self, jid: &Jid, jpeg: &[u8], ) -> Result ``` **Parameters:** * `jid` — Newsletter JID * `jpeg` — JPEG-encoded image bytes **Returns:** * `NewsletterMetadata` — Updated metadata **Example:** ```rust theme={null} let updated = client.newsletter() .set_picture(&newsletter_jid, &jpeg_bytes) .await?; ``` ### remove\_picture Remove a newsletter's picture. ```rust theme={null} pub async fn remove_picture(&self, jid: &Jid) -> Result ``` **Parameters:** * `jid` — Newsletter JID **Returns:** * `NewsletterMetadata` — Updated metadata **Example:** ```rust theme={null} client.newsletter().remove_picture(&newsletter_jid).await?; ``` ### delete Delete a newsletter. Owner-only. ```rust theme={null} pub async fn delete(&self, jid: &Jid) -> Result<(), NewsletterError> ``` **Parameters:** * `jid` — Newsletter JID **Example:** ```rust theme={null} client.newsletter().delete(&newsletter_jid).await?; ``` ### change\_owner Transfer a newsletter's ownership to another user. Owner-only. ```rust theme={null} pub async fn change_owner(&self, jid: &Jid, user: &Jid) -> Result<(), NewsletterError> ``` **Parameters:** * `jid` — Newsletter JID * `user` — The new owner. May be a LID or a phone-number JID — a phone-number JID is resolved to its LID before the request. If no LID is known for the target, the call fails with `NewsletterError::InvalidRequest` rather than sending an address the server can't route. **Example:** ```rust theme={null} client.newsletter() .change_owner(&newsletter_jid, &new_owner_jid) .await?; ``` ### demote\_admin Demote an admin of a newsletter back to subscriber. Owner-only. ```rust theme={null} pub async fn demote_admin(&self, jid: &Jid, user: &Jid) -> Result<(), NewsletterError> ``` **Parameters:** * `jid` — Newsletter JID * `user` — The admin to demote. Same LID resolution as [`change_owner`](#change_owner). **Example:** ```rust theme={null} client.newsletter() .demote_admin(&newsletter_jid, &admin_jid) .await?; ``` ### get\_admin\_info Fetch a newsletter's admin-side information, including its admin count. ```rust theme={null} pub async fn get_admin_info(&self, jid: &Jid) -> Result ``` **Parameters:** * `jid` — Newsletter JID **Returns:** * `NewsletterAdminInfo` — Admin count, the viewer's own admin profile, and the admin-profiles setting The admin count has no query of its own — it rides along with the admin profile and the admin-profiles setting in this one response. The server only answers it for admins and owners, so `admin_count` is `None` for everyone else. **Example:** ```rust theme={null} let info = client.newsletter().get_admin_info(&newsletter_jid).await?; println!("Admins: {:?}", info.admin_count); ``` ### get\_followers List up to `count` of a newsletter's followers (subscribers). ```rust theme={null} pub async fn get_followers( &self, jid: &Jid, count: u32, ) -> Result, NewsletterError> ``` **Parameters:** * `jid` — Newsletter JID * `count` — Maximum number of followers to return This operation takes no cursor — WhatsApp Web asks for a single page clamped to its subscriber-list limit, so `count` is the whole request. There is no built-in pagination. **Returns:** * `Vec` — One page of followers **Example:** ```rust theme={null} let followers = client.newsletter() .get_followers(&newsletter_jid, 100) .await?; for f in &followers { println!("{}: {:?}", f.jid, f.role); } ``` ### set\_follower\_mute Mute or unmute a newsletter's **follower-activity** notifications (WhatsApp Web's `MUTE_FOLLOWER_ACTIVITY`). Sent via MEX as a user-setting update. ```rust theme={null} pub async fn set_follower_mute(&self, jid: &Jid, muted: bool) -> Result<(), NewsletterError> ``` **Parameters:** * `jid` — Newsletter JID * `muted` — `true` silences notifications, `false` re-enables them **Example:** ```rust theme={null} // Mute client.newsletter().set_follower_mute(&newsletter_jid, true).await?; // Unmute client.newsletter().set_follower_mute(&newsletter_jid, false).await?; ``` ### set\_admin\_mute Mute or unmute a newsletter's **admin-activity** notifications (WhatsApp Web's `MUTE_ADMIN_ACTIVITY`). Only meaningful for owners/admins. ```rust theme={null} pub async fn set_admin_mute(&self, jid: &Jid, muted: bool) -> Result<(), NewsletterError> ``` **Parameters:** * `jid` — Newsletter JID * `muted` — `true` silences admin-activity notifications, `false` re-enables them **Example:** ```rust theme={null} client.newsletter().set_admin_mute(&newsletter_jid, true).await?; ``` The mute state is sent as `ON`/`OFF`; the mute expiration is local database state and is never put on the wire, matching WhatsApp Web's `WAWebNewsletterUpdateUserSettingJob`. ### Sending messages Newsletter message sending is handled by the unified `client.send_message()` method. See the [Send API reference](/api/send#send_message) for full details. ```rust theme={null} use waproto::whatsapp as wa; let message = wa::Message { conversation: Some("Hello subscribers!".to_string()), ..Default::default() }; // Pass a newsletter JID directly to send_message let msg_id = client.send_message(newsletter_jid, message).await?; ``` The library detects newsletter recipients automatically and sends messages as plaintext (no Signal encryption), with the correct `type` and `mediatype` stanza attributes inferred from the message content. Stanza-level `` nodes (for polls, events, etc.) are also included automatically, matching WhatsApp Web behavior. `Newsletter::send_message()` was removed. Use `client.send_message()` instead — it accepts newsletter, group, and direct message JIDs. ### send\_reaction Send a reaction to a newsletter message. ```rust theme={null} pub async fn send_reaction( &self, jid: &Jid, server_id: u64, reaction: &str, ) -> Result<(), NewsletterError> ``` **Parameters:** * `jid` — Newsletter JID * `server_id` — Server-assigned ID of the message to react to * `reaction` — Emoji code (e.g., `"👍"`, `"❤️"`), or empty string to remove **Example:** ```rust theme={null} // Add a reaction client.newsletter() .send_reaction(&newsletter_jid, server_id, "👍") .await?; // Remove a reaction client.newsletter() .send_reaction(&newsletter_jid, server_id, "") .await?; ``` ### edit\_message Edit a previously-sent newsletter message. Channel messages are plaintext, so the edit is sent as a `` stanza with the new protobuf body — not through the E2E send path. ```rust theme={null} pub async fn edit_message( &self, jid: &Jid, message_id: impl Into, new_content: wa::Message, ) -> Result<(), NewsletterError> ``` **Parameters:** * `jid` — Newsletter JID. Non-newsletter JIDs are rejected with an error; use [`Client::edit_message`](/api/send) for DMs and groups. * `message_id` — The target message's `message_id` (the wire stanza id, as returned by `send_message` or carried on [`NewsletterMessage`](#newslettermessage)). This is **not** the `server_id` used by reactions. Empty IDs are rejected. * `new_content` — Replacement message body. Typically a `wa::Message { conversation: Some(..), .. }` for text edits. **Example:** ```rust theme={null} use waproto::whatsapp as wa; let new_body = wa::Message { conversation: Some("edited text".to_string()), ..Default::default() }; client.newsletter() .edit_message(&newsletter_jid, original_message_id, new_body) .await?; ``` ### revoke\_message Revoke (delete) a previously-sent newsletter message. Like [`edit_message`](#edit_message), this goes through the plaintext channel path, not the E2E send path. ```rust theme={null} pub async fn revoke_message( &self, jid: &Jid, message_id: impl Into, ) -> Result<(), NewsletterError> ``` **Parameters:** * `jid` — Newsletter JID. Non-newsletter JIDs are rejected; use [`Client::revoke_message`](/api/send) for DMs and groups. * `message_id` — The target message's `message_id` (wire stanza id, not `server_id`). Empty IDs are rejected. **Example:** ```rust theme={null} client.newsletter() .revoke_message(&newsletter_jid, message_id) .await?; ``` Newsletter JIDs are now also rejected at the root of the E2E send path. If your code accidentally routes a channel JID through `send_message_impl`, `pin_message`, or the standard `edit_message` / `revoke_message` on `Client`, you get an error that names the mis-route instead of a malformed encrypted fan-out. ### get\_messages Fetch message history from a newsletter. ```rust theme={null} pub async fn get_messages( &self, jid: &Jid, count: u32, before: Option, ) -> Result, NewsletterError> ``` **Parameters:** * `jid` — Newsletter JID * `count` — Maximum number of messages to return * `before` — If set, return messages before this `server_id` (for pagination) **Returns:** * `Vec` — List of newsletter messages **Example:** ```rust theme={null} // Fetch latest 50 messages let messages = client.newsletter() .get_messages(&newsletter_jid, 50, None) .await?; // Paginate backwards if let Some(oldest) = messages.last() { let older = client.newsletter() .get_messages(&newsletter_jid, 50, Some(oldest.server_id)) .await?; } ``` ### subscribe\_live\_updates Subscribe to live updates for a newsletter (reaction counts, message changes). ```rust theme={null} pub async fn subscribe_live_updates( &self, jid: &Jid, ) -> Result ``` **Parameters:** * `jid` — Newsletter JID **Returns:** * `u64` — Subscription duration in seconds (typically 300) The server sends `Event::NewsletterLiveUpdate` events with updated reaction counts. You need to re-subscribe periodically when the duration expires. **Example:** ```rust theme={null} let duration = client.newsletter() .subscribe_live_updates(&newsletter_jid) .await?; println!("Subscribed for {}s", duration); ``` ## Types ### NewsletterMetadata Metadata for a newsletter channel. Implements `PartialEq` and `Eq` for direct comparison. ```rust theme={null} #[derive(Debug, Clone, PartialEq, Eq)] pub struct NewsletterMetadata { pub jid: Jid, pub name: String, pub description: Option, pub subscriber_count: u64, pub verification: NewsletterVerification, pub state: NewsletterState, pub picture_url: Option, pub preview_url: Option, pub invite_code: Option, pub role: Option, pub creation_time: Option, } ``` ### NewsletterVerification ```rust theme={null} #[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub enum NewsletterVerification { Verified, Unverified, } ``` ### NewsletterState ```rust theme={null} #[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub enum NewsletterState { Active, Suspended, Geosuspended, } ``` ### NewsletterRole The viewer's role in a newsletter. ```rust theme={null} #[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub enum NewsletterRole { Owner, Admin, Subscriber, Guest, } ``` All newsletter enums are `#[non_exhaustive]`, so match statements should include a wildcard arm to handle future variants. ### NewsletterAdminProfile An admin's public profile within a newsletter, carried on [`NewsletterAdminInfo`](#newsletteradmininfo) and on a [`NewsletterFollower`](#newsletterfollower) who is an admin. ```rust theme={null} #[derive(Debug, Clone, PartialEq, Eq)] pub struct NewsletterAdminProfile { pub id: Option, pub name: String, pub picture_id: Option, pub picture_direct_path: Option, } ``` `id` is the profile's own identifier, not a JID — the server hands it back as an opaque string and WhatsApp Web never parses it. ### NewsletterAdminInfo Admin-side information about a newsletter, returned by [`get_admin_info`](#get_admin_info). ```rust theme={null} #[derive(Debug, Clone, PartialEq, Eq)] pub struct NewsletterAdminInfo { pub admin_count: Option, pub admin_profile: Option, pub admin_profiles_enabled: Option, } ``` * `admin_count` — How many admins the newsletter has. The server only answers this for admins and owners, so it's `None` for everyone else — never a `0` that would misleadingly read as "no admins". * `admin_profile` — The viewer's own admin profile, present once they've set one up. * `admin_profiles_enabled` — Whether admin profiles are enabled for this newsletter. ### NewsletterFollower A follower (subscriber) of a newsletter, returned by [`get_followers`](#get_followers). ```rust theme={null} #[derive(Debug, Clone, PartialEq, Eq)] pub struct NewsletterFollower { pub jid: Jid, pub phone_jid: Option, pub display_name: Option, pub username: Option, pub role: Option, pub follow_time: Option, pub admin_profile: Option, } ``` * `jid` — The follower's identity JID — a LID on accounts that have migrated. * `phone_jid` — The follower's phone-number JID, withheld by the server when the follower's privacy settings hide it. * `role` — The follower's role in the newsletter. * `follow_time` — When the follower subscribed (Unix seconds). * `admin_profile` — Set when the follower is an admin who has published a profile. ### NewsletterMessage A message from a newsletter's history. ```rust theme={null} pub struct NewsletterMessage { /// Server-assigned message ID (monotonic, used for pagination cursors). pub server_id: u64, /// Message timestamp (Unix seconds). pub timestamp: u64, /// Message type (Text, Media, Reaction, etc.). pub message_type: NewsletterMessageType, /// Whether the viewer is the sender. pub is_sender: bool, /// Decoded protobuf message (from plaintext bytes). pub message: Option, /// Reaction counts on this message. pub reactions: Vec, } ``` ### NewsletterMessageType The type of a newsletter message. Uses a `StringEnum` for type-safe wire-protocol mapping. Implements `PartialEq` and `Eq`. ```rust theme={null} #[non_exhaustive] pub enum NewsletterMessageType { Text, // "text" Media, // "media" Reaction, // "reaction" Revoke, // "revoke" PollCreation, // "poll_creation" PollVote, // "poll_vote" Edit, // "edit" Other(String), // Unknown/future types } ``` **Methods:** * `as_str()` - Returns the wire-protocol string representation * `From<&str>` - Parse from wire string, unknown values become `Other(String)` ### NewsletterReactionCount A reaction count on a newsletter message. ```rust theme={null} pub struct NewsletterReactionCount { pub code: String, pub count: u64, } ``` ## Error handling All newsletter methods return `Result`: ```rust theme={null} #[non_exhaustive] pub enum NewsletterError { #[error("{0}")] Mex(#[from] MexError), #[error("{0}")] Iq(#[from] IqError), #[error("{0}")] Client(#[from] ClientError), #[error("invalid newsletter request: {0}")] InvalidRequest(String), #[error("{0}")] Internal(#[from] anyhow::Error), } ``` ```rust theme={null} use whatsapp_rust::NewsletterError; match client.newsletter().join(&newsletter_jid).await { Ok(metadata) => println!("Joined: {}", metadata.jid), Err(NewsletterError::Mex(e)) => eprintln!("MEX error: {}", e), Err(NewsletterError::Iq(e)) => eprintln!("IQ error: {}", e), Err(e) => eprintln!("Error: {}", e), } ``` # Polls Source: https://whatsapp-rust.jlucaso.com/api/polls Poll creation, voting, and vote decryption API reference The `Polls` struct provides methods for creating polls, casting votes, and decrypting poll results. Votes are end-to-end encrypted using AES-256-GCM with HKDF-SHA256 key derivation. ## Access Access poll operations through the client: ```rust theme={null} let polls = client.polls(); ``` ## Methods ### create Create a new poll in a chat. ```rust theme={null} pub async fn create( &self, to: &Jid, name: &str, options: &[String], selectable_count: u32, ) -> Result<(SendResult, Vec), PollError> ``` Recipient JID. Can be a direct message or group chat. Poll question or title. List of poll options. Must have between 2 and 12 entries, with no duplicates. Maximum number of options a voter can select. Must be between 1 and the number of options. When set to 1, creates a single-select poll (uses `poll_creation_message_v3`). When greater than 1, creates a multi-select poll (uses `poll_creation_message`). A tuple containing a `SendResult` (with `message_id`, `to`, and `message_key()`) and a 32-byte random secret. The `message_secret` is required to decrypt votes — store it securely. See [SendResult](/api/send#sendresult) for details. **Example:** ```rust theme={null} let chat_jid: Jid = "15551234567@s.whatsapp.net".parse()?; let options = vec![ "Yes".to_string(), "No".to_string(), "Maybe".to_string(), ]; let (result, secret) = client .polls() .create(&chat_jid, "Do you agree?", &options, 1) .await?; println!("Poll sent with ID: {}", result.message_id); ``` ### create\_quiz Create a quiz poll — a single-select poll with exactly one correct answer. Quizzes are inherently single-select (WhatsApp Web forces `selectableOptionsCount = 1`); the chosen option is sent as the poll's `correctAnswer` and `poll_type` is set to `QUIZ`. ```rust theme={null} pub async fn create_quiz( &self, to: &Jid, name: &str, options: &[String], correct_index: usize, ) -> Result<(SendResult, Vec), PollError> ``` Recipient JID. Can be a direct message or group chat. Quiz question or title. List of answer options (2–12 entries, no duplicates). 0-based index into `options` of the correct answer. Out-of-range indices return an error. Same shape as [`create`](#create): a [`SendResult`](/api/send#sendresult) plus the 32-byte secret needed to decrypt votes. Votes are cast, decrypted, and aggregated exactly like a regular single-select poll. **Example:** ```rust theme={null} let chat_jid: Jid = "15551234567@s.whatsapp.net".parse()?; let options = vec![ "Paris".to_string(), "London".to_string(), "Berlin".to_string(), ]; // The correct answer is "Paris" (index 0). let (result, secret) = client .polls() .create_quiz(&chat_jid, "Capital of France?", &options, 0) .await?; ``` ### vote Cast a vote on an existing poll. ```rust theme={null} pub async fn vote( &self, chat_jid: &Jid, poll_msg_id: &str, poll_creator_jid: &Jid, message_secret: &[u8], option_names: &[String], ) -> Result ``` Chat JID where the poll was sent. Message ID of the original poll creation message. JID of the user who created the poll. The 32-byte secret returned by `create`. Required for vote encryption. Names of the selected options. Pass an empty slice to clear your vote. Result containing the message ID and recipient JID. See [SendResult](/api/send#sendresult). **Example:** ```rust theme={null} let selected = vec!["Yes".to_string()]; let vote_result = client .polls() .vote( &chat_jid, &poll_msg_id, &poll_creator_jid, &message_secret, &selected, ) .await?; println!("Vote sent with ID: {}", vote_result.message_id); ``` ### decrypt\_vote Decrypt an encrypted poll vote. ```rust theme={null} pub async fn decrypt_vote( &self, enc_payload: &[u8], enc_iv: &[u8], message_secret: &[u8], poll_msg_id: &str, poll_creator_jid: &Jid, voter_jid: &Jid, ) -> Result>, PollError> ``` Since v0.6 this is an `async` instance method (`&self`) rather than a static helper. The client uses its LID↔PN cache to resolve the voter against the poll creator's namespace and falls back to the opposite namespace if the first attempt fails — both encrypt and decrypt now address the vote with the same JID family as the poll itself, so votes authored across LID/PN migrations still decrypt. Update old call sites: `Polls::decrypt_vote(...)` → `client.polls().decrypt_vote(...).await?`. Encrypted vote payload (from the `PollUpdateMessage`). 12-byte initialization vector from the encrypted vote. The 32-byte secret from poll creation. Message ID of the original poll. JID of the poll creator (AD suffix is stripped automatically). JID of the voter (AD suffix is stripped automatically). List of 32-byte SHA-256 hashes of the selected option names. **Example:** ```rust theme={null} use whatsapp_rust::Polls; let selected_hashes = client .polls() .decrypt_vote( &enc_payload, &enc_iv, &message_secret, "3EB0ABC123", &poll_creator_jid, &voter_jid, ) .await?; ``` ### aggregate\_votes Decrypt multiple votes and tally results per option. Later votes from the same voter replace earlier ones (last-vote-wins). Voters are deduped by their canonical (LID-preferred) identity, so a voter who re-votes under the opposite namespace counts once instead of producing a duplicate row. ```rust theme={null} pub async fn aggregate_votes( &self, poll_options: &[String], votes: &[(&Jid, &[u8], &[u8])], message_secret: &[u8], poll_msg_id: &str, poll_creator_jid: &Jid, ) -> Result, PollError> ``` Also async + `&self` since v0.6 for the same LID↔PN reasons. Migrate `Polls::aggregate_votes(...)` → `client.polls().aggregate_votes(...).await?`. The original poll option names (in order). Slice of `(voter_jid, enc_payload, enc_iv)` tuples, ordered oldest-first. The 32-byte secret from poll creation. Message ID of the original poll. JID of the poll creator. One entry per poll option, each containing the option name and a list of voter JID strings. **Example:** ```rust theme={null} use whatsapp_rust::{Polls, PollOptionResult}; let options = vec!["Yes".to_string(), "No".to_string()]; let results = client .polls() .aggregate_votes( &options, &votes, &message_secret, &poll_msg_id, &poll_creator_jid, ) .await?; for result in &results { println!("{}: {} votes", result.name, result.voters.len()); } ``` Votes that fail to decrypt are logged as warnings and skipped. An empty selection from a voter clears their previous vote. ## Types ### PollOptionResult Aggregated result for a single poll option. ```rust theme={null} pub struct PollOptionResult { pub name: String, pub voters: Vec, } ``` | Field | Type | Description | | -------- | ------------- | ---------------------------------------------- | | `name` | `String` | The option name | | `voters` | `Vec` | JID strings of voters who selected this option | `Polls`, `PollOptionResult`, and `PollVoteCiphertext` are re-exported from the crate root. `whatsapp_rust::Polls` and `whatsapp_rust::PollOptionResult` (shown in the examples above) are now the preferred paths; the legacy `whatsapp_rust::features::` import path continues to work as well. ## Low-level utilities The `wacore::poll` module exposes the cryptographic primitives used internally: | Function | Description | | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `compute_option_hash(name: &str) -> [u8; 32]` | SHA-256 hash of an option name | | `derive_vote_encryption_key(secret, stanza_id, creator, voter) -> [u8; 32]` | HKDF-SHA256 key derivation | | `encrypt_poll_vote(hashes, key, stanza_id, voter) -> (Vec, [u8; 12])` | AES-256-GCM encryption | | `decrypt_poll_vote(payload, iv, key, stanza_id, voter) -> Vec>` | AES-256-GCM decryption | | `decrypt_poll_vote_with_secret(payload, iv, secret, stanza_id, creator, voter) -> Vec>` | Single-namespace convenience: derives the vote key from the poll secret and decrypts | | `decrypt_poll_vote_with_fallback(payload, iv, secret, stanza_id, creator, voter, alt_creator, alt_voter) -> Vec>` | Tries the canonical (creator, voter) namespace first and falls back to `(alt_creator, alt_voter)` so LID/PN-mixed votes still decrypt | ## Decrypting secret-encrypted envelopes WhatsApp wraps message edits, poll edits, poll add-option, and event edits in a single `secret_encrypted_message` envelope keyed by a per-use-case secret derived from the parent message's `message_secret`. v0.6 added support for decrypting all four kinds. **The client now decrypts these inline on receive.** Since v0.6 the receive path automatically resolves the parent secret from the [`MsgSecretStore`](/api/store#msgsecretstore), decrypts the envelope, and dispatches the result as a normal [`Event::Messages`](/concepts/events#messages) carrying the decrypted payload — so most apps never call the helpers below. The client captures `MessageContextInfo.message_secret` from inbound messages and seeds secrets from history-sync, so edits decrypt as long as the parent secret is known. Edits whose secret can't be found are skipped silently (no undecryptable event); the raw envelope stays on the message. The manual helpers remain for custom pipelines or when you store secrets yourself. ```rust theme={null} use whatsapp_rust::features::message_edit::{ self, SecretEncKind, SecretEncrypted, }; if let Some(envelope) = message_edit::extract_secret_encrypted(&message)? { let SecretEncrypted { kind, target_message_id, sender, .. } = &envelope; let parent_secret: [u8; 32] = load_message_secret(target_message_id)?; let plaintext: wa::Message = message_edit::decrypt_secret_encrypted_with_fallback( &envelope, &parent_secret, envelope.original_sender_jid(&my_jid).ok().as_ref(), )?; match kind { SecretEncKind::MessageEdit => handle_message_edit(plaintext), SecretEncKind::PollEdit => handle_poll_edit(plaintext), SecretEncKind::PollAddOption => handle_poll_add_option(plaintext), SecretEncKind::EventEdit => handle_event_edit(plaintext), } } ``` | Function | Description | | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `extract_secret_encrypted(msg) -> Option>` | Pulls envelope metadata (`kind`, target id, sender, ciphertext, IV) out of any `secret_encrypted_message` wrapper. Replaces the v0.5 `extract_envelope` helper (which now delegates to this). | | `decrypt_secret_encrypted(envelope, parent_secret) -> wa::Message` | Empty-AAD AES-GCM decrypt against the canonical addressing of the envelope. | | `decrypt_secret_encrypted_with_fallback(envelope, parent_secret, alt_sender) -> wa::Message` | Same as above but retries with `alt_sender` (typically the LID↔PN twin) so edits authored across the migration still decrypt. | | `SecretEncrypted::original_sender_jid(&self, my_jid: &Jid) -> Result` | Resolves the original sender from the target message key: `participant` if present, `my_jid` if `from_me == Some(true)`, otherwise `remote_jid`. Pass the result (as `Option`) as the fallback to recover edits whose sender migrated namespace between the original message and the edit. As of PR #1090 returns [`MessageEditError`](/api/errors#messageediterror) instead of `anyhow::Error`. | | `SecretEncrypted::original_sender_for_dispatch(is_from_me, envelope_sender, my_jid) -> Result` | Dispatch-time resolver. For `MessageEdit` it returns the **envelope sender** (an author edits their own message, so the secret target is written in the editor's frame); for poll/event kinds it falls back to the target-key resolution. Use this — not `original_sender_jid` — when decrypting incoming peer edits. Also returns `MessageEditError` as of PR #1090. | | `rewrap_as_legacy_edit(plaintext)` | Re-emits a decrypted `MessageEdit` envelope as the legacy `protocol_message { MESSAGE_EDIT }` shape for consumers that already handled the old form. | For incoming **peer** message edits (edits authored on a contact's other device, or self-synced from your own linked device), the parent-author resolution must use the envelope sender rather than the target key — otherwise the parent author resolves to the receiver and the GCM tag fails. The client handles this internally via `original_sender_for_dispatch`; replicate it if you decrypt edits yourself. `EncryptedEdit` exposes the same method for the `MessageEdit`-only façade. ```rust theme={null} pub enum SecretEncKind { MessageEdit, // edited text of a previously sent message PollEdit, // poll question / metadata change PollAddOption, // new option appended to a multi-select poll EventEdit, // event title / time change } ``` The `EncryptedEdit` type from v0.5 still exists as a `MessageEdit`-specific façade for callers that only care about text edits; `extract_envelope` now returns `SecretEncrypted` and the older shape is reachable via the same struct. Both `EncryptedEdit::original_sender_jid` and `SecretEncrypted::original_sender_jid` share the same [`MessageEditError`](/api/errors#messageediterror) error type. ## Error handling All five poll methods (`create`, `create_quiz`, `vote`, `decrypt_vote`, and `aggregate_votes`) return `Result`: ```rust theme={null} #[non_exhaustive] pub enum PollError { #[error("{0}")] Send(#[from] SendError), #[error("invalid poll: {0}")] InvalidPoll(String), #[error("client is not logged in")] NotLoggedIn, #[error("poll vote crypto failed: {0}")] Crypto(#[source] anyhow::Error), } ``` ```rust theme={null} use whatsapp_rust::PollError; match client.polls().create(jid, "Question?", &options, 1).await { Ok((result, secret)) => println!("Poll created: {}", result.message_id), Err(PollError::InvalidPoll(msg)) => eprintln!("Invalid poll: {}", msg), Err(PollError::Send(e)) => eprintln!("Send failed: {}", e), Err(e) => eprintln!("Error: {}", e), } ``` ## See also * [Polls guide](/guides/polls) - Step-by-step usage guide * [Sending messages](/guides/sending-messages) - Send other message types * [Send API](/api/send) - Low-level send operations # Presence Source: https://whatsapp-rust.jlucaso.com/api/presence Online/offline status and presence subscription operations The `Presence` struct provides methods for managing your online/offline status and subscribing to contact presence updates. ## Access Access presence operations through the client: ```rust theme={null} let presence = client.presence(); ``` ## Methods ### set Set your presence status (online or offline). ```rust theme={null} pub async fn set(&self, status: PresenceStatus) -> Result<(), PresenceError> ``` **Parameters:** * `status: PresenceStatus` - Either `Available` (online) or `Unavailable` (offline) **Requirements:** * Push name must be set before sending presence * Returns error if push name is empty **Example:** ```rust theme={null} use whatsapp_rust::features::presence::PresenceStatus; // Set status to online client.presence().set(PresenceStatus::Available).await?; // Set status to offline client.presence().set(PresenceStatus::Unavailable).await?; ``` ### set\_available Convenience method to set status to available (online). ```rust theme={null} pub async fn set_available(&self) -> Result<(), PresenceError> ``` **Example:** ```rust theme={null} client.presence().set_available().await?; println!("Now online"); ``` ### set\_unavailable Convenience method to set status to unavailable (offline). ```rust theme={null} pub async fn set_unavailable(&self) -> Result<(), PresenceError> ``` **Example:** ```rust theme={null} client.presence().set_unavailable().await?; println!("Now offline"); ``` ### subscribe Subscribe to a contact's presence updates. ```rust theme={null} pub async fn subscribe(&self, jid: &Jid) -> Result<(), PresenceError> ``` **Parameters:** * `jid` - Contact JID to subscribe to **Behavior:** * Sends a `` stanza * Automatically includes TC token if available for the contact * Tracks the subscription internally so it can be restored on reconnect * Used to receive notifications when the contact goes online/offline **Example:** ```rust theme={null} let contact_jid: Jid = "15551234567@s.whatsapp.net".parse()?; client.presence().subscribe(&contact_jid).await?; println!("Subscribed to {}'s presence", contact_jid); ``` ### unsubscribe Unsubscribe from a contact's presence updates. ```rust theme={null} pub async fn unsubscribe(&self, jid: &Jid) -> Result<(), PresenceError> ``` **Parameters:** * `jid` - Contact JID to unsubscribe from **Behavior:** * Sends a `` stanza * Removes the contact from the internal subscription tracker * You will no longer receive presence updates for this contact **Example:** ```rust theme={null} let contact_jid: Jid = "15551234567@s.whatsapp.net".parse()?; client.presence().unsubscribe(&contact_jid).await?; println!("Unsubscribed from {}'s presence", contact_jid); ``` ## PresenceStatus Enum ```rust theme={null} #[non_exhaustive] pub enum PresenceStatus { Available, // Online Unavailable, // Offline } ``` `PresenceStatus` is `#[non_exhaustive]`, so match statements should include a wildcard arm to handle future variants. **Methods:** * `as_str()` - Returns `"available"` or `"unavailable"` **Conversion:** ```rust theme={null} let status = PresenceStatus::Available; assert_eq!(status.as_str(), "available"); ``` ## Push name requirement WhatsApp requires a push name (display name) to be set before sending presence updates. This matches WhatsApp Web behavior. **Error example:** ```rust theme={null} use whatsapp_rust::features::presence::PresenceError; // If push name not set match client.presence().set_available().await { Ok(_) => println!("Presence set"), Err(PresenceError::PushNameEmpty) => { eprintln!("Cannot send presence without a push name set"); } Err(e) => eprintln!("Other error: {}", e), } ``` The push name is typically set during the pairing/connection process from app state sync. ## Wire Format ### Setting Presence ```xml theme={null} ``` ### Subscribing to Presence ```xml theme={null} ``` ### Unsubscribing from Presence ```xml theme={null} ``` ## TC token handling When subscribing to presence, the library automatically: * Looks up TC token for the target JID * Includes token as child node if available * Skips token if not found (non-error) This matches WhatsApp Web's privacy gating behavior. ## Subscription Tracking The library automatically tracks which contacts you have subscribed to. This enables automatic re-subscription after a reconnect, so you don't lose presence updates when the connection drops. ### How it works * Calling `subscribe(jid)` adds the contact to an internal tracked set * Calling `unsubscribe(jid)` removes the contact from the tracked set * Duplicate subscriptions are deduplicated automatically * On reconnect, the library re-subscribes to all tracked contacts ### Automatic re-subscription on reconnect When the client reconnects after a connection drop, it automatically calls `resubscribe_presence_subscriptions()` to restore all tracked presence subscriptions. This happens transparently — you don't need to manually re-subscribe after a reconnect. As of [whatsapp-rust#1405](https://github.com/oxidezap/whatsapp-rust/pull/1405), the walk is windowed rather than one contact at a time: tracked JIDs are processed 8 at a time (matching the noise sender's own queue depth), with one [`get_tc_tokens`](/api/store#tctoken-storage) call per window instead of one lookup per contact, and the window's `` stanzas issued together so the transport sender can coalesce them into fewer writes. For 24 tracked contacts this is 3 batched calls instead of 24 individual lookups — a backend that hasn't overridden `get_tc_tokens`'s default still runs one query per JID underneath each call, but the built-in `SqliteStore` collapses each window to a single `IN (...)` query, i.e. 3 DB reads instead of 24. The transport writes only shrink to the extent the sender coalesces the window's stanzas; the guarantee is fewer writes than one per contact, not a fixed count. The re-subscription process includes safety checks, now applied per window rather than per JID: * Bails out early if the connection generation changes (a new reconnect occurred), so a reconnect landing mid-walk stops it within one window rather than after the whole tracked set * Skips re-subscription if the client is no longer connected * Re-checks each JID against the tracked set both before and after its window's tcToken lookup, so an `unsubscribe` racing the walk is honored — a JID unsubscribed before its window starts, or while the window's lookup is in flight, is left out of that window's sends rather than resubscribed This matches WhatsApp Web behavior, which re-subscribes to all active presence subscriptions after reconnecting. The per-window (rather than per-JID) safety checks are a narrower guarantee than a strictly per-JID re-check would give, but the batching that motivates it — coalesced backend reads and transport writes — needs a unit wider than one JID to pay off. ## Behavior Notes ### Available (Online) When setting status to `Available`, the library automatically: 1. Validates push name is set 2. Sends unified session (internal protocol requirement) 3. Broadcasts presence stanza with push name ### Unavailable (Offline) When setting status to `Unavailable`: 1. Validates push name is set 2. Broadcasts unavailable presence Note: This marks you as offline but doesn't disconnect the client. ## PresenceError The `set`, `set_available`, and `set_unavailable` methods return `Result<(), PresenceError>`: ```rust theme={null} #[non_exhaustive] #[derive(Debug, Error)] pub enum PresenceError { #[error("cannot send presence without a push name set")] PushNameEmpty, #[error("{0}")] Client(#[from] ClientError), #[error("{0}")] Other(#[from] anyhow::Error), } ``` **Variants:** * `PushNameEmpty` - Push name must be set before sending presence * `Client` — wraps a `ClientError` (connection errors from `set`, `set_available`, `set_unavailable`, `subscribe`, and `unsubscribe`) * `Other` - Wraps any other error (network, connection, etc.) ## Error handling ```rust theme={null} use whatsapp_rust::features::presence::PresenceError; match client.presence().set_available().await { Ok(_) => println!("Successfully set to online"), Err(PresenceError::PushNameEmpty) => { eprintln!("Need to set push name first"); } Err(e) => eprintln!("Unexpected error: {}", e), } ``` ## Complete Example ```rust theme={null} use whatsapp_rust::features::presence::PresenceStatus; // Set yourself online client.presence().set_available().await?; // Subscribe to contacts' presence let contacts: Vec = vec![ "15551111111@s.whatsapp.net".parse()?, "15552222222@s.whatsapp.net".parse()?, ]; for contact in &contacts { client.presence().subscribe(contact).await?; println!("Subscribed to {}", contact); } // Do work while online... // If the connection drops, tracked subscriptions are // automatically re-subscribed on reconnect. // Unsubscribe from a specific contact client.presence().unsubscribe(&contacts[0]).await?; println!("Unsubscribed from {}", contacts[0]); // Set yourself offline when done client.presence().set_unavailable().await?; ``` ## Receiving presence updates After subscribing to a contact's presence, you'll receive presence events through the event handler. See the [Events](/concepts/events) documentation for details on handling incoming presence updates. # Privacy Source: https://whatsapp-rust.jlucaso.com/api/privacy Privacy settings management - fetch, update, and configure account privacy The privacy API allows you to fetch and update account-level privacy settings, such as who can see your last seen, profile photo, about text, and online status. All methods use type-safe enums for categories and values. ## Access Privacy operations are available directly on the `Client`: ```rust theme={null} use whatsapp_rust::privacy_settings::{PrivacyCategory, PrivacyValue}; let settings = client.fetch_privacy_settings().await?; client.set_privacy_setting(PrivacyCategory::Last, PrivacyValue::Contacts).await?; ``` ## Methods ### fetch\_privacy\_settings Fetch all current privacy settings. ```rust theme={null} pub async fn fetch_privacy_settings(&self) -> Result ``` **Returns:** * `PrivacySettingsResponse` - Contains a list of all privacy settings **Example:** ```rust theme={null} use whatsapp_rust::privacy_settings::{PrivacyCategory, PrivacyValue}; let response = client.fetch_privacy_settings().await?; for setting in &response.settings { println!("{:?} = {:?}", setting.category, setting.value); } // Look up a specific category if let Some(value) = response.get_value(&PrivacyCategory::Last) { println!("Last seen: {:?}", value); } ``` The client also fetches privacy settings automatically in the background right after connecting, purely to keep an internal cache current. Today the only setting cached this way is `ReadReceipts`, which gates the wire type of outgoing DM read/played receipts (see [Read receipt privacy gating](/api/receipt#read-receipt-privacy-gating)). That cache is written **only** by the automatic background fetch on connect — calling `fetch_privacy_settings` yourself is a bare passthrough that returns the current server-side values without touching device state, so it does not also refresh the cache. Call it directly when you need the full, current set of values, e.g. to render a settings screen; a reconnect (not a manual call) is what updates the cached read-receipts gate. ### set\_privacy\_setting Update a specific privacy setting. ```rust theme={null} pub async fn set_privacy_setting( &self, category: PrivacyCategory, value: PrivacyValue, ) -> Result ``` **Parameters:** * `category` - A `PrivacyCategory` enum variant * `value` - A `PrivacyValue` enum variant (must be valid for the category) **Returns:** * `SetPrivacySettingResponse` - Contains an optional `dhash` field (present only for `ContactBlacklist` operations) **Example:** ```rust theme={null} use whatsapp_rust::privacy_settings::{PrivacyCategory, PrivacyValue}; // Hide last seen from everyone client.set_privacy_setting(PrivacyCategory::Last, PrivacyValue::None).await?; // Show profile photo only to contacts client.set_privacy_setting(PrivacyCategory::Profile, PrivacyValue::Contacts).await?; // Allow everyone to add you to groups client.set_privacy_setting(PrivacyCategory::GroupAdd, PrivacyValue::All).await?; // Disable read receipts client.set_privacy_setting(PrivacyCategory::ReadReceipts, PrivacyValue::None).await?; // Restrict calls to known contacts only client.set_privacy_setting(PrivacyCategory::CallAdd, PrivacyValue::Known).await?; ``` Each `PrivacyCategory` only accepts specific `PrivacyValue` variants. See the [valid combinations table](#valid-category-value-combinations) below. Setting `ReadReceipts` to `None` takes effect on the **next connect** (the client re-fetches and re-caches the setting on each connect, matching WhatsApp Web, which reads it from local prefs) — calling `fetch_privacy_settings` yourself in the same session does not refresh that cache either (see the note above); a reconnect is currently the only way. From then on, `mark_as_read`/`mark_as_played` on a direct message send `read-self`/`played-self` instead of `read`/`played`, so the other party is no longer notified. This only applies to DMs — group, broadcast list, status-broadcast, and newsletter receipts are unaffected. See [Read receipt privacy gating](/api/receipt#read-receipt-privacy-gating). ### set\_privacy\_disallowed\_list Update a privacy category's disallowed list (contacts-except-specific-users mode). Only available for categories that support `ContactBlacklist`: `Last`, `Profile`, `Status`, and `GroupAdd`. ```rust theme={null} pub async fn set_privacy_disallowed_list( &self, category: PrivacyCategory, update: DisallowedListUpdate, ) -> Result ``` **Parameters:** * `category` - Must be `Last`, `Profile`, `Status`, or `GroupAdd` * `update` - A `DisallowedListUpdate` containing the `dhash` and user entries to add/remove **Returns:** * `SetPrivacySettingResponse` - Contains the updated `dhash` for conflict detection **Example:** ```rust theme={null} use whatsapp_rust::privacy_settings::{ PrivacyCategory, DisallowedListUpdate, DisallowedListUserEntry, DisallowedListAction, }; // First, set the category to contact_blacklist mode client.set_privacy_setting( PrivacyCategory::Last, PrivacyValue::ContactBlacklist, ).await?; // Then update the disallowed list let update = DisallowedListUpdate { dhash: "current_hash".to_string(), // from previous response users: vec![ DisallowedListUserEntry { action: DisallowedListAction::Add, jid: blocked_user_lid, pn_jid: Some(blocked_user_pn), }, ], }; let response = client.set_privacy_disallowed_list( PrivacyCategory::Last, update, ).await?; // Save the new dhash for future updates let new_dhash = response.dhash; ``` ### set\_default\_disappearing\_mode Set the default disappearing messages duration for all new chats. ```rust theme={null} pub async fn set_default_disappearing_mode( &self, duration: u32, ) -> Result<(), IqError> ``` **Parameters:** * `duration` - Timer in seconds. Common values: `86400` (24 hours), `604800` (7 days), `7776000` (90 days). Pass `0` to disable. **Example:** ```rust theme={null} // Enable 7-day default disappearing messages client.set_default_disappearing_mode(604800).await?; // Disable default disappearing messages client.set_default_disappearing_mode(0).await?; ``` This sets the default for **new** chats only. Existing chats keep their current setting. To change disappearing messages for a specific group, use [`set_ephemeral`](/api/groups#set_ephemeral). ## App-state settings (syncd) `set_link_previews_disabled` is a distinct privacy control from everything above. You call `fetch_privacy_settings`, `set_privacy_setting`, and `set_privacy_disallowed_list` through the `set_privacy` IQ namespace and get a synchronous response (`set_default_disappearing_mode` above is the odd one out — it uses a separate `disappearing_mode` IQ namespace, not `set_privacy`). You call `set_link_previews_disabled` through app state sync instead — the same mutation mechanism used by [chat actions](/api/chat-actions#app-state-sync) and [labels](/api/labels) — and it has no query/fetch IQ counterpart at all. ### set\_link\_previews\_disabled Use this method to turn outgoing link previews off or on for the whole account. Call it through `client.app_state_settings()`, not directly on `Client`. ```rust theme={null} pub async fn set_link_previews_disabled(&self, disabled: bool) -> Result<(), AppStateError> ``` **Parameters:** * `disabled` — `true` disables link previews account-wide, `false` re-enables them. **Example:** ```rust theme={null} client.app_state_settings().set_link_previews_disabled(true).await?; ``` This sets the account's stored preference, replicated to your other linked devices. It does **not** stop this client from attaching a preview it was explicitly asked to send — it only governs the default WhatsApp Web / mobile compose behavior. **App state sync:** `regular` collection, action version 8, no index arguments (`setting_disableLinkPreviews`). Subject to the same [conflict-retry behavior](/api/chat-actions#app-state-sync) as chat actions and labels. A change made on a linked device arrives as [`Event::DisableLinkPreviewsUpdate`](/concepts/events#disablelinkpreviewsupdate): ```rust theme={null} use wacore::types::events::Event; .on_event(|event, _client| async move { match &*event { Event::DisableLinkPreviewsUpdate(update) => { println!("Link previews disabled: {}", update.previews_disabled); } _ => {} } }) ``` ## Types ### PrivacyCategory Controls which privacy setting is being read or written. Each category maps to a wire-protocol string value. ```rust theme={null} pub enum PrivacyCategory { Last, // "last" — Last seen visibility Online, // "online" — Online status visibility Profile, // "profile" — Profile photo visibility Status, // "status" — Status/story visibility GroupAdd, // "groupadd" — Who can add you to groups ReadReceipts, // "readreceipts" — Read receipt visibility CallAdd, // "calladd" — Who can call you Messages, // "messages" — Who can message you DefenseMode, // "defense" — Defense mode configuration Other(String), // Unknown/future category } ``` ### PrivacyValue The value assigned to a privacy category. ```rust theme={null} pub enum PrivacyValue { All, // "all" — Visible to everyone Contacts, // "contacts" — Visible only to contacts None, // "none" — Not visible to anyone ContactBlacklist, // "contact_blacklist" — Contacts except specific list MatchLastSeen, // "match_last_seen" — Match the other person's setting Known, // "known" — Known contacts (for calls) Off, // "off" — Feature disabled (defense mode) OnStandard, // "on_standard" — Standard mode (defense mode) Other(String), // Unknown/future value } ``` ### Valid category-value combinations Not all values are valid for every category. Use `PrivacyCategory::is_valid_value()` to check at runtime. | Category | Valid values | | -------------- | --------------------------------------------- | | `Last` | `All`, `Contacts`, `ContactBlacklist`, `None` | | `Online` | `All`, `MatchLastSeen` | | `Profile` | `All`, `Contacts`, `ContactBlacklist`, `None` | | `Status` | `All`, `Contacts`, `ContactBlacklist`, `None` | | `GroupAdd` | `All`, `Contacts`, `ContactBlacklist`, `None` | | `ReadReceipts` | `All`, `None` | | `CallAdd` | `All`, `Known`, `Contacts` | | `Messages` | `All`, `Contacts` | | `DefenseMode` | `Off`, `OnStandard` | ### PrivacySetting ```rust theme={null} pub struct PrivacySetting { pub category: PrivacyCategory, pub value: PrivacyValue, } ``` ### PrivacySettingsResponse ```rust theme={null} pub struct PrivacySettingsResponse { pub settings: Vec, } ``` **Methods:** * `get(&self, category: &PrivacyCategory) -> Option<&PrivacySetting>` - Look up a setting by category * `get_value(&self, category: &PrivacyCategory) -> Option<&PrivacyValue>` - Look up a value directly ### SetPrivacySettingResponse ```rust theme={null} pub struct SetPrivacySettingResponse { pub dhash: Option, } ``` The `dhash` field is populated when setting a `ContactBlacklist` value or updating a disallowed list. Use it for conflict detection on subsequent updates. ### DisallowedListUpdate Used with `set_privacy_disallowed_list` to add or remove users from a category's disallowed list. ```rust theme={null} pub struct DisallowedListUpdate { pub dhash: String, pub users: Vec, } ``` ### DisallowedListUserEntry ```rust theme={null} pub struct DisallowedListUserEntry { pub action: DisallowedListAction, pub jid: Jid, pub pn_jid: Option, } ``` ### DisallowedListAction ```rust theme={null} pub enum DisallowedListAction { Add, // "add" — Add user to disallowed list (default) Remove, // "remove" — Remove user from disallowed list } ``` ## Common patterns ### Fetch and display all settings ```rust theme={null} use whatsapp_rust::privacy_settings::{PrivacyCategory, PrivacyValue}; let response = client.fetch_privacy_settings().await?; let categories = [ PrivacyCategory::Last, PrivacyCategory::Online, PrivacyCategory::Profile, PrivacyCategory::Status, PrivacyCategory::GroupAdd, PrivacyCategory::ReadReceipts, PrivacyCategory::CallAdd, PrivacyCategory::Messages, PrivacyCategory::DefenseMode, ]; for category in &categories { let value = response .get_value(category) .unwrap_or(&PrivacyValue::All); println!("{:?}: {:?}", category, value); } ``` ### Maximum privacy configuration ```rust theme={null} use whatsapp_rust::privacy_settings::{PrivacyCategory, PrivacyValue}; client.set_privacy_setting(PrivacyCategory::Last, PrivacyValue::None).await?; client.set_privacy_setting(PrivacyCategory::Online, PrivacyValue::MatchLastSeen).await?; client.set_privacy_setting(PrivacyCategory::Profile, PrivacyValue::Contacts).await?; client.set_privacy_setting(PrivacyCategory::Status, PrivacyValue::Contacts).await?; client.set_privacy_setting(PrivacyCategory::GroupAdd, PrivacyValue::Contacts).await?; client.set_privacy_setting(PrivacyCategory::ReadReceipts, PrivacyValue::None).await?; client.set_privacy_setting(PrivacyCategory::CallAdd, PrivacyValue::Contacts).await?; client.set_privacy_setting(PrivacyCategory::Messages, PrivacyValue::Contacts).await?; ``` ### Validate before setting ```rust theme={null} use whatsapp_rust::privacy_settings::{PrivacyCategory, PrivacyValue}; let category = PrivacyCategory::Online; let value = PrivacyValue::None; if category.is_valid_value(&value) { client.set_privacy_setting(category, value).await?; } else { eprintln!("Invalid value for category"); } ``` ## See also * [Client API](/api/client#fetch_privacy_settings) - Client-level privacy methods * [Receipt API](/api/receipt#read-receipt-privacy-gating) - How `ReadReceipts` gates DM read/played receipts * [TC Token API](/api/tctoken) - Trusted contact tokens used for privacy-gated operations * [Blocking API](/api/blocking) - Block and unblock contacts * [Events](/concepts/events#disablelinkpreviewsupdate) - Handle `DisableLinkPreviewsUpdate` # Profile Source: https://whatsapp-rust.jlucaso.com/api/profile Manage your own profile - push name, status text, and profile picture The `Profile` feature provides methods for managing your own account's display name, status text (about), and profile picture. ## Access Access profile operations through the client: ```rust theme={null} let profile = client.profile(); ``` ## Methods ### set\_push\_name Set your display name (push name). ```rust theme={null} pub async fn set_push_name(&self, name: &str) -> Result<(), ProfileError> ``` **Parameters:** * `name` - The new display name (cannot be empty) Updates the local device store, sends a presence stanza with the new name, and propagates the change via app state sync for cross-device synchronization. **Example:** ```rust theme={null} client.profile().set_push_name("My Bot").await?; ``` The push name change takes effect immediately via presence, but app state sync may fail if keys aren't available yet (e.g., right after pairing before initial sync completes). ### set\_status\_text Set your status text (about). ```rust theme={null} pub async fn set_status_text(&self, text: &str) -> Result<(), ProfileError> ``` **Parameters:** * `text` - The new status text Sets the profile "About" text. This is different from ephemeral text status updates. **Example:** ```rust theme={null} client.profile().set_status_text("Available 24/7").await?; ``` ### set\_profile\_picture Set your profile picture. ```rust theme={null} pub async fn set_profile_picture( &self, image_data: Vec ) -> Result ``` **Parameters:** * `image_data` - JPEG image bytes **Returns:** * `SetProfilePictureResponse` - Contains the new picture ID The image should already be properly sized/cropped by the caller. WhatsApp typically uses 640x640 images. **Example:** ```rust theme={null} use std::fs; let image_bytes = fs::read("profile.jpg")?; let response = client.profile().set_profile_picture(image_bytes).await?; println!("New picture ID: {:?}", response.id); ``` The image must be a valid JPEG. Other formats are not supported. ### remove\_profile\_picture Remove your profile picture. ```rust theme={null} pub async fn remove_profile_picture(&self) -> Result ``` **Returns:** * `SetProfilePictureResponse` - Confirmation of removal **Example:** ```rust theme={null} client.profile().remove_profile_picture().await?; ``` Since v0.6 the remove IQ omits the `` child entirely, matching WA Web's `SendProfilePictureJob`. Sending an empty `` child was rejected by some hosts. ## Types ### SetProfilePictureResponse Response from profile picture operations. ```rust theme={null} pub struct SetProfilePictureResponse { /// The new picture ID (or None if removed) pub id: Option, } ``` ## Complete example ```rust theme={null} use whatsapp_rust::Client; use std::sync::Arc; use std::fs; async fn setup_profile(client: &Arc) -> anyhow::Result<()> { // Set display name client.profile().set_push_name("My WhatsApp Bot").await?; // Set status text client.profile().set_status_text("Automated responses").await?; // Set profile picture let image_bytes = fs::read("bot_avatar.jpg")?; let response = client.profile().set_profile_picture(image_bytes).await?; if let Some(id) = response.id { println!("Profile picture updated: {}", id); } Ok(()) } ``` ## Error handling All methods return `Result`: ```rust theme={null} #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum ProfileError { #[error("{0}")] Iq(#[from] IqError), #[error("{0}")] Client(#[from] ClientError), #[error("invalid argument: {0}")] InvalidArgument(String), #[error("{0}")] Internal(#[from] anyhow::Error), } ``` Common errors: * `InvalidArgument` — e.g., empty push name * `Iq` — server rejected the status text or picture IQ * `Client(ClientError::NotLoggedIn)` — not authenticated * `Internal` — network errors, encoding failures ```rust theme={null} use whatsapp_rust::ProfileError; match client.profile().set_push_name("").await { Ok(_) => println!("Name updated"), Err(ProfileError::InvalidArgument(msg)) => eprintln!("Invalid: {}", msg), Err(e) => eprintln!("Failed: {}", e), } ``` ## See also * [Contacts](/api/contacts) - Get profile pictures for other users * [Presence](/api/presence) - Set online/offline status # Quick Replies Source: https://whatsapp-rust.jlucaso.com/api/quick-replies Create, edit, and delete WhatsApp Business quick replies, and react to changes from linked devices Use `QuickReplies` to manage WhatsApp Business quick replies — saved `/`-triggered shortcuts that expand to a full message. You create, edit, or delete a quick reply with outbound calls. You receive changes made on a linked device (such as WhatsApp Web or the phone) as inbound events. Quick replies sync across all linked devices via WhatsApp's app state sync mechanism (the `regular` collection, action version 2). ## Access Access quick reply operations through the client: ```rust theme={null} let quick_replies = client.quick_replies(); ``` ## Create or update a quick reply ### set\_quick\_reply Create a new quick reply or update an existing one. Because app state is an upsert keyed by `id`, calling this with an existing `id` edits that quick reply — WhatsApp Web itself builds add and edit from the same mutation. ```rust theme={null} pub async fn set_quick_reply( &self, id: &str, shortcut: &str, message: &str, keywords: Vec, count: i32, ) -> Result<(), AppStateError> ``` **Parameters:** * `id` — Stable identifier for the quick reply. Must be non-empty. Reuse the same `id` to edit it later. * `shortcut` — The `/`-typed trigger text. Must be non-empty. * `message` — The expanded message text. Must be non-empty. * `keywords` — Extra search terms WhatsApp indexes the reply under. You can pass an empty vector. * `count` — Usage counter. Pass `0` for a new quick reply. **Example:** ```rust theme={null} // Create a new quick reply client.quick_replies() .set_quick_reply("5", "/hours", "We're open 9am-5pm, Mon-Fri.", vec![], 0) .await?; // Edit the same quick reply later client.quick_replies() .set_quick_reply("5", "/hours", "We're open 9am-6pm, Mon-Sat.", vec!["hours".into()], 12) .await?; ``` This call always sends `associatedLabelIds` empty, matching WhatsApp Web's own builder. You cannot associate a quick reply with a label through this call. ## Delete a quick reply ### delete\_quick\_reply Delete a quick reply. ```rust theme={null} pub async fn delete_quick_reply(&self, id: &str) -> Result<(), AppStateError> ``` **Parameters:** * `id` — The quick reply to delete. Must be non-empty. **Example:** ```rust theme={null} client.quick_replies().delete_quick_reply("5").await?; ``` Deletion sends the same `Set` mutation as `set_quick_reply`, with `deleted = true` and the payload fields cleared — not a syncd `Remove`. WhatsApp Web's own delete builder (`getQuickReplyDeleteMutation`) works the same way. This means the [inbound event](#quickreplyupdate) below always fires as a `QuickReplyUpdate`; check `action.deleted` to tell a delete apart from a create or edit. ## Inbound events ### QuickReplyUpdate You receive a quick reply change made on a linked device through the event bus. The event carries the underlying app state action, so read `action.deleted` first to tell a deletion apart from a create or edit. ```rust theme={null} use wacore::types::events::Event; .on_event(|event, _client| async move { match &*event { Event::QuickReplyUpdate(update) => { if update.action.deleted == Some(true) { println!("Quick reply {} deleted", update.id); } else { println!( "Quick reply {} = {:?} -> {:?}", update.id, update.action.shortcut, update.action.message, ); } } _ => {} } }) ``` `from_full_sync` is `true` when the event came from an initial app state full sync, so you can suppress UI notifications during bootstrap. ## App state sync | Action | Collection | Action version | | ------------------------------------ | ---------- | -------------- | | Create / update / delete quick reply | `regular` | 2 | App state sync requires the relevant encryption keys, which arrive during initial sync after pairing. Outbound quick reply calls may fail if invoked immediately after pairing before sync completes. `set_quick_reply` and `delete_quick_reply` are thin wrappers over the same app-state send path as [chat actions](/api/chat-actions#app-state-sync). They share its conflict-retry behavior, added in PR [#1158](https://github.com/oxidezap/whatsapp-rust/pull/1158). A call that loses a version race with another linked device is no longer silently dropped. The client applies the winning patches, rebuilds the mutation, and resends. It retries up to 5 times before returning `Err`. See [Chat actions — App state sync](/api/chat-actions#app-state-sync) for the full explanation. ## Error handling All methods return `Result<(), AppStateError>`. If you pass an empty `id`, `shortcut`, or `message` to `set_quick_reply` (or an empty `id` to `delete_quick_reply`), or a negative `count`, the call fails immediately with `AppStateError::InvalidRequest` before any network work is done. A call can also return `AppStateError::Internal` if no app state sync key is available yet, a network error occurred, or an app-state version conflict with another device could not be resolved after 5 rebuild-and-resend attempts (see [App state sync](#app-state-sync) above). ```rust theme={null} use whatsapp_rust::AppStateError; if let Err(AppStateError::InvalidRequest(msg)) = client.quick_replies().set_quick_reply("", "/hi", "Hello!", vec![], 0).await { eprintln!("Validation failed: {msg}"); // "id cannot be empty" } ``` ## Complete example ```rust theme={null} use whatsapp_rust::Client; use std::sync::Arc; async fn seed_quick_replies(client: &Arc) -> anyhow::Result<()> { client.quick_replies() .set_quick_reply("greeting", "/hi", "Hi! How can I help?", vec![], 0) .await?; client.quick_replies() .set_quick_reply( "hours", "/hours", "We're open 9am-5pm, Mon-Fri.", vec!["hours".into(), "open".into()], 0, ) .await?; // Retire an old one client.quick_replies().delete_quick_reply("legacy-promo").await?; Ok(()) } ``` ## See also * [Events](/concepts/events#quickreplyupdate) — Handle `QuickReplyUpdate` * [Chat actions](/api/chat-actions#generic-app-state-action) — The generic `send_app_state_action` escape hatch * [Labels](/api/labels) — A similarly-shaped app-state feature with the same upsert-by-id pattern * [State management](/advanced/state-management) — How app state sync works under the hood # receipt Source: https://whatsapp-rust.jlucaso.com/api/receipt Send read receipts, delivery receipts, and played receipts ## mark\_as\_read Send read receipts for one or more messages. Read receipts inform the sender that you've read their message(s). For group messages, you must pass the original sender's JID as the `sender` parameter. ```rust theme={null} pub async fn mark_as_read( &self, chat: &Jid, sender: Option<&Jid>, message_ids: &[&str], ) -> Result<(), anyhow::Error> ``` `message_ids` is a borrowed slice `&[&str]` to avoid per-call allocations — pass `&["ID"]` or `&["ID_1", "ID_2"]`. Chat JID where the messages were received. Can be: * Direct message: `15551234567@s.whatsapp.net` * Group: `120363040237990503@g.us` Message sender JID. Required for group messages, `None` for direct messages. * For DMs: Pass `None` * For groups: Pass the JID of the user who sent the message(s) List of message IDs to mark as read. Can be a single ID or multiple IDs. If empty, this function returns immediately without sending anything. ### Example: mark DM as read ```rust theme={null} use wacore_binary::jid::Jid; let chat_jid: Jid = "15551234567@s.whatsapp.net".parse()?; client.mark_as_read( &chat_jid, None, // No sender for DMs &["MESSAGE_ID_123"], ).await?; ``` ### Example: mark group message as read ```rust theme={null} let group_jid: Jid = "120363040237990503@g.us".parse()?; let sender_jid: Jid = "15551234567@s.whatsapp.net".parse()?; client.mark_as_read( &group_jid, Some(&sender_jid), // Must specify sender in groups &["MESSAGE_ID_456"], ).await?; ``` ### Example: mark multiple messages as read ```rust theme={null} let message_ids = ["MSG_1", "MSG_2", "MSG_3"]; client.mark_as_read(&chat_jid, None, &message_ids).await?; ``` Read receipts are **not sent automatically** by the library. You must explicitly call `mark_as_read()` when you want to notify the sender that messages have been read. `mark_as_read` adapts the wire shape to the chat, matching WhatsApp Web. A newsletter read is sent as `read-self`. A `status@broadcast` read carries `class="status"`. When the status author is a LID, it also adds `peer_participant_pn` (the resolved LID→PN). For a direct message, the receipt also downgrades to `read-self` when the account's `readreceipts` privacy setting is `none` — the sender is not notified, matching WhatsApp Web. Groups and broadcast lists always send the notifying `read` type regardless of that setting. The same call handles all these cases — you don't pass anything extra. See [Read receipt privacy gating](#read-receipt-privacy-gating) below. *** ## mark\_as\_played Send played receipts for one or more voice notes or video notes. Played receipts tell the sender that you've listened to their voice note or watched their video note — they're the equivalent of "read" for playable media. Call this only after the user has actually played the media; if you just want to acknowledge that the message was opened, use [`mark_as_read`](#mark_as_read) instead. ```rust theme={null} pub async fn mark_as_played( &self, chat: &Jid, sender: Option<&Jid>, message_ids: &[&str], ) -> Result<(), anyhow::Error> ``` `message_ids` is a borrowed slice `&[&str]`, matching [`mark_as_read`](#mark_as_read). Chat JID where the media message was received. Can be a direct message, group, broadcast list, or newsletter JID. Original sender JID. * For DMs: pass `None` (the `participant` attribute is dropped on the wire, matching WhatsApp Web). * For groups, broadcast lists, and status broadcasts: pass the JID of the user who sent the media. * For newsletters: the receipt is sent as `played-self`; `sender` is ignored. Message IDs of the voice or video notes to mark as played. The first ID becomes the receipt's `id` attribute; any additional IDs are batched into a `` child, the same shape as `mark_as_read`. If empty, this function returns immediately without sending anything. ### Example: mark a DM voice note as played ```rust theme={null} use wacore_binary::jid::Jid; let chat_jid: Jid = "15551234567@s.whatsapp.net".parse()?; client.mark_as_played( &chat_jid, None, // No participant in DMs &["VOICE_MSG_ID"], ).await?; ``` ### Example: mark a group voice note as played ```rust theme={null} let group_jid: Jid = "120363040237990503@g.us".parse()?; let sender_jid: Jid = "15551234567@s.whatsapp.net".parse()?; client.mark_as_played( &group_jid, Some(&sender_jid), // Required in groups and broadcasts &["VOICE_MSG_ID"], ).await?; ``` ### Example: mark multiple voice notes as played ```rust theme={null} client.mark_as_played( &chat_jid, None, &["MSG_1", "MSG_2", "MSG_3"], ).await?; ``` Played receipts are **not sent automatically** — call `mark_as_played()` from your media player when the user finishes (or starts) playing the audio or video note. Like `mark_as_read`, a DM played receipt downgrades to `played-self` when the account's `readreceipts` privacy setting is `none` (the sender is not notified); groups, broadcast lists, and status broadcasts are unaffected by that setting. See [Read receipt privacy gating](#read-receipt-privacy-gating) below. *** ## Read receipt privacy gating The `readreceipts` privacy category (see [`PrivacyCategory::ReadReceipts`](/api/privacy#privacycategory)) controls whether `mark_as_read` and `mark_as_played` notify the other party for direct messages: | `readreceipts` value | DM receipt type sent | | -------------------- | ------------------------------------------------------------------------------------------------------------- | | `all` (default) | `read` / `played` — the sender is notified | | `none` | `read-self` / `played-self` — marks the message read on your own devices only; the sender is **not** notified | This gate only applies to one-on-one direct messages. Group messages, broadcast lists, and status broadcasts always send the notifying `read`/`played` type — WhatsApp Web does not apply this privacy setting to them. Newsletters are unaffected too; they always use `read-self`/`played-self` regardless of this setting. The value is populated from a background call to [`fetch_privacy_settings`](/api/privacy#fetch_privacy_settings) that the client runs automatically after connecting, and is persisted on the device. On a reconnect where the setting hasn't changed since the last fetch, the persisted value already reflects it, so the correct receipt type is available before that connect's background fetch completes. If the setting *did* change — from another linked device, or from this client's own [`set_privacy_setting`](/api/privacy#set_privacy_setting) call — the persisted value is stale until that reconnect's background fetch runs and updates it. Toggle the setting with `client.set_privacy_setting(PrivacyCategory::ReadReceipts, PrivacyValue::None).await?` (or `PrivacyValue::All` to re-enable). See the [privacy API](/api/privacy) for details. This call only updates the setting on the server — the local cache `mark_as_read`/`mark_as_played` read from is refreshed only by the background fetch that runs on connect, so a receipt sent later in the *same* session, before the next connect, can still go out as the old type. Calling `fetch_privacy_settings()` yourself does **not** refresh that cache either — like `set_privacy_setting`, it's a bare passthrough that returns the current server values without touching device state. Reconnecting (or restarting) is currently the only way to guarantee the new gating applies within the running process. *** ## send\_delivery\_receipt (Internal) Sends a delivery receipt to the sender of a message. This is an internal method called automatically by the library when messages are received. You typically don't need to call this directly. ```rust theme={null} pub(crate) async fn send_delivery_receipt( &self, info: &Arc ) ``` Message metadata containing: * `id` - Message ID * `source.chat` - Chat JID * `source.sender` - Sender JID * `source.is_from_me` - Whether this is your own message * `source.is_group` - Whether this is a group message ### Behavior Delivery receipts are automatically sent for all incoming messages **except**: * Your own messages (`is_from_me = true`) * Messages without an ID * Status broadcast messages (`status@broadcast`) * Newsletter messages For group messages, the receipt includes a `participant` attribute identifying the sender. Delivery receipts are sent automatically. Unlike other receipt types (e.g., `type="read"`, `type="played"`), delivery receipts have **no `type` attribute** on the wire — delivery is the implicit default. The library omits the `type` attribute from ack responses to delivery receipts accordingly, since including an explicit `type="delivery"` would cause `` disconnections from the server. This is different from read receipts (type=`"read"`), which you send manually with `mark_as_read()`. ### Wire format Internally, delivery receipts pass JID references directly to the `.attr()` method, avoiding allocations on the hot path: ```rust theme={null} let is_status = info.source.chat.is_status_broadcast(); // For 1:1 DMs the `to` echoes the sender JID verbatim so the multi-device // LID device byte (e.g. `…:7@lid`) survives. Group / status receipts stay // addressed at the chat JID since those never carry a device. let to = if info.source.is_group || is_status { &info.source.chat } else { &info.source.sender }; let mut builder = NodeBuilder::new("receipt") .attr("id", &info.id) .attr("to", to); if info.category == MessageCategory::Peer { builder = builder.attr("type", "peer_msg"); } if info.source.is_group { builder = builder.attr("participant", &info.source.sender); } let receipt_node = builder.build(); ``` v0.6 split the `to` attribute by addressing case. Earlier versions always used `info.source.chat`, which strips the device byte (`to_non_ad`). For multi-device LID senders that arrived with `from="USER:DEV@lid"`, the device-less receipt was rejected by the LID server: it replayed the stanza from the offline queue and eventually closed the stream with ``. Matching whatsmeow's `buildBaseReceipt` (which echoes `node.Attrs["from"]` verbatim) and WA Web's `sendDeliveryReceiptsAfterDecryption` resolves the issue. Read receipts (`mark_as_read`) batch multiple message IDs using a `` child node with `` elements: ```rust theme={null} let mut builder = NodeBuilder::new("receipt") .attr("to", chat) .attr("type", "read") .attr("id", &message_ids[0]) .attr("t", ×tamp); if let Some(sender) = sender { builder = builder.attr("participant", sender); } // Additional message IDs beyond the first if message_ids.len() > 1 { let items: Vec = message_ids[1..] .iter() .map(|id| NodeBuilder::new("item").attr("id", id).build()) .collect(); builder = builder.children(vec![ NodeBuilder::new("list").children(items).build() ]); } ``` *** ## Retry receipts and decrypt-fail visibility A retry receipt (`type="retry"`, see [`ReceiptType::Retry`](#receipt-types) below) can carry an extra `` child reporting whether the stanza that triggered it was one the client was told to hide on decrypt failure — i.e. whether the recipient's UI showed nothing for it (`decrypt-fail="hide"` on the original ``; see [Decrypt-fail mode](/guides/receiving-messages#decrypt-fail-mode)). ```rust theme={null} pub const RECEIPT_MODE_HID_FAILED_DECRYPT: u32 = 1 << 2; pub fn build_receipt_meta_node(mode: u32) -> Option ``` `RECEIPT_MODE_HID_FAILED_DECRYPT` is bit position 2 of WhatsApp Web's `` bitmask, already shifted — the only bit this client sets. The other two positions WA Web defines (`ORPHAN`, `NO_CHECKMARK_UX`) name states this client does not model. `build_receipt_meta_node` returns `None` for an all-zero mask rather than emitting ``, matching WA Web, which omits the node entirely when nothing is set. The bit is set only when **all** of the following hold: * the retry was requested with [`RetryRequestOptions::with_decrypt_fail_mode(DecryptFailMode::Hide)`](/guides/receiving-messages#requesting-a-retry-manually) (the automatic retry pipeline supplies this from the failing stanza's own `decrypt-fail` attribute — no action needed for the built-in retry flow) * the `receipt_mode_bitmask_enabled` ab-prop is enabled (introduces the `` node at all) * the `web_send_hid_failed_decrypt_in_receipts_enabled` ab-prop is enabled (a separate experiment gating this specific bit) Both props default off, and both are watched (`iq::props::WATCHED`) so a server-pushed value actually takes effect instead of silently falling back to the registry default forever. A cold props cache — or either prop disabled — sends the retry receipt exactly as before this feature existed, with no `` child at all. For manual retry callers (`Client::request_message_retry`), pass the mode explicitly: ```rust theme={null} client.request_message_retry( &stanza, RetryRequestOptions::new() .with_reason(RetryReason::BadMac) .with_decrypt_fail_mode(DecryptFailMode::Hide), ).await?; ``` See [Requesting a retry manually](/guides/receiving-messages#requesting-a-retry-manually) for the full `RetryRequestOptions` builder. *** ## Receipt Types WhatsApp supports multiple receipt types: ```rust theme={null} pub enum ReceiptType { Delivered, // Message delivered to device Sender, // Sender receipt Retry, // Decryption retry request EncRekeyRetry, // VoIP call encryption re-keying retry Read, // Message read by recipient ReadSelf, // Message read on another device Played, // Media played by recipient PlayedSelf, // Media played on another device ServerError, // Server error Inactive, // Inactive participant PeerMsg, // Peer message HistorySync, // History sync Other(String), // Unknown receipt type } ``` Delivery receipt (type=`""`). Confirms message was delivered to the recipient's device. Sent automatically by the library. Read receipt (type=`"read"`). Confirms message was read by the recipient. Sent manually via `mark_as_read()`. Read receipt from your own device (type=`"read-self"`). Received when you read a message on another device, or sent for a DM when your own `readreceipts` privacy setting is `none` (see [Read receipt privacy gating](#read-receipt-privacy-gating)). Played receipt (type=`"played"`). Confirms media (audio/video) was played by the recipient. Played receipt from your own device (type=`"played-self"`). Received when you play media on another device, or sent for a DM when your own `readreceipts` privacy setting is `none` (see [Read receipt privacy gating](#read-receipt-privacy-gating)). Retry receipt (type=`"retry"`). Recipient failed to decrypt the message and is requesting a retry. Automatically handled by the library. Can carry the `HID_FAILED_DECRYPT` bit — see [Retry receipts and decrypt-fail visibility](#retry-receipts-and-decrypt-fail-visibility) above. VoIP call encryption re-keying retry receipt (type=`"enc_rekey_retry"`). Sent when a peer fails to decrypt VoIP call encryption data and needs the sender to re-key. Uses an `` child element (with `call-creator`, `call-id`, `count` attributes) instead of the standard `` child. Automatically handled by the library. Sender receipt (type=`"sender"`). Acknowledges message was sent. Server error receipt (type=`"server-error"`). Message delivery failed on server. *** ## Receipt Events You can listen for receipt events to track message delivery and read status using the Bot event handler: ```rust theme={null} use wacore::types::events::Event; let mut bot = Bot::builder() .with_backend(backend) .with_transport_factory(transport_factory) .with_http_client(http_client) .on_event(|event, client| async move { if let Event::Receipt(receipt) = &*event { println!("Receipt type: {:?}", receipt.r#type); println!("From: {}", receipt.source.sender); println!("Message IDs: {:?}", receipt.message_ids); match receipt.r#type { ReceiptType::Delivered => { println!("Message delivered"); } ReceiptType::Read => { println!("Message read"); } ReceiptType::Played => { println!("Media played"); } _ => {} } } }) .build() .await?; ``` ### Receipt event structure ```rust theme={null} #[non_exhaustive] pub struct Receipt { pub source: MessageSource, pub message_ids: Vec, pub timestamp: DateTime, pub r#type: ReceiptType, pub offline: bool, } ``` `Receipt` is `#[non_exhaustive]` and constructed internally via a `bon` builder (`Receipt::builder()…build()`), so a struct-pattern destructure needs a `..` rest — e.g. `Receipt { source, r#type, .. }`. See [Payload stability](/concepts/events#event-enum) for the full policy. Source information: * `chat` - Chat JID where the receipt originated * `sender` - JID of the user who sent the receipt * `is_group` - Whether this is from a group `true` when the receipt carried the `offline` attribute. That means it was drained from the server's offline queue on reconnect rather than delivered live. Use it to tell a backlog of receipts received at login apart from real-time ones. List of message IDs this receipt applies to. Usually contains a single ID, but can have multiple. When the receipt was received (local time) Type of receipt (Delivered, Read, Played, etc.) *** ## Message tracking example Track message delivery and read status: ```rust theme={null} use std::collections::HashMap; use wacore::types::events::{Event, ReceiptType}; #[derive(Default)] struct MessageTracker { delivered: HashMap, read: HashMap, } impl MessageTracker { fn track_receipt(&mut self, receipt: &Receipt) { for msg_id in &receipt.message_ids { match receipt.r#type { ReceiptType::Delivered => { self.delivered.insert(msg_id.clone(), true); } ReceiptType::Read => { self.read.insert(msg_id.clone(), true); } _ => {} } } } fn is_delivered(&self, msg_id: &str) -> bool { self.delivered.get(msg_id).copied().unwrap_or(false) } fn is_read(&self, msg_id: &str) -> bool { self.read.get(msg_id).copied().unwrap_or(false) } } let tracker = Arc::new(Mutex::new(MessageTracker::default())); // Use within Bot event handler let tracker_clone = tracker.clone(); let mut bot = Bot::builder() // ... configure backend, transport, http_client ... .on_event(move |event, _client| { let tracker = tracker_clone.clone(); async move { if let Event::Receipt(receipt) = &*event { tracker.lock().await.track_receipt(receipt); } } }) .build() .await?; ``` *** ## Played receipts (media) For voice notes and video notes, send played receipts with [`mark_as_played`](#mark_as_played) once the user has played the media. Newsletters send `played-self`; everything else sends `played`, unless the DM privacy gate (see [above](#read-receipt-privacy-gating)) downgrades it to `played-self`. The wire shape mirrors read receipts: ```xml theme={null} ``` You can also listen for incoming played receipts via the `Event::Receipt` event with `ReceiptType::Played` or `ReceiptType::PlayedSelf`. *** ## Best Practices ### Read receipt privacy The library already respects the account's `readreceipts` privacy setting for direct messages (see [Read receipt privacy gating](#read-receipt-privacy-gating)) — a DM `mark_as_read`/`mark_as_played` call automatically goes out as `read-self`/`played-self` when that setting is `none`. The pattern below is for an additional **local** opt-out, e.g. a per-app toggle that skips sending receipts entirely rather than sending the silent `-self` variant. ```rust theme={null} struct Settings { send_read_receipts: bool, } if settings.send_read_receipts { client.mark_as_read(&chat_jid, sender, &message_ids).await?; } ``` ### Batching multiple receipts Send read receipts for multiple messages at once to reduce network overhead: ```rust theme={null} let mut pending_receipts: Vec<&str> = Vec::new(); // Collect message IDs pending_receipts.push(msg_id_1); pending_receipts.push(msg_id_2); pending_receipts.push(msg_id_3); // Send batch if !pending_receipts.is_empty() { client.mark_as_read(&chat_jid, None, &pending_receipts).await?; } ``` ### Group message receipts Always include the sender JID for group messages: ```rust theme={null} if message_info.source.is_group { client.mark_as_read( &message_info.source.chat, Some(&message_info.source.sender), // Required for groups &[message_info.id.as_str()], ).await?; } else { client.mark_as_read( &message_info.source.chat, None, // No sender for DMs &[message_info.id.as_str()], ).await?; } ``` # send Source: https://whatsapp-rust.jlucaso.com/api/send Send, forward, edit, revoke, pin, and unpin messages with advanced options, including album support ## send\_message Send a message to a user, group, or newsletter. Newsletter messages are sent as plaintext (no E2E encryption) automatically when the recipient is a newsletter JID. ```rust theme={null} pub async fn send_message( &self, to: impl Into, message: wa::Message, ) -> Result ``` Recipient JID. Can be: * Direct message: `15551234567@s.whatsapp.net` * Group: `120363040237990503@g.us` * Newsletter: `120363999999999999@newsletter` When the recipient is a newsletter JID, the message is sent as plaintext with the correct `type` and `mediatype` stanza attributes inferred automatically. Protobuf message to send. Set one of the message fields: * `conversation` - Plain text message * `extended_text_message` - Text with formatting/links * `image_message` - Image with caption * `video_message` - Video with caption * `document_message` - Document/file * `audio_message` - Audio/voice note * `sticker_message` - Sticker * `sticker_pack_message` - Sticker pack (grouped sticker collection) * `location_message` - GPS location * `contact_message` - Contact card * `album_message` - Album (grouped media) parent message Contains the `message_id` (unique ID for tracking receipts, edits, revokes), `to` (resolved recipient JID), `message` (the `wa::Message` this send encoded — see below), and — for a DM — `recipient_fanout` (see [`RecipientFanout`](#recipientfanout) below). Use `send_result.message_key()` to get a `wa::MessageKey` for album child linking, pinning, or other operations that reference this message. The outbound Signal ratchet advance is persisted through a batched counter lease, for DMs and for group/status sends alike. For DMs, `SessionRecord` reserves its sender-chain counter 64 at a time; for group sends and status posts sent via `client.status()`, `SenderKeyRecord` reserves its chain iteration 64 at a time the same way. Most sends are already covered by a durable lease and only schedule a coalesced write-behind — though the pre-wire flush check is global, so a pending flush on an unrelated session or sender key can still force this send to flush synchronously. The send that exhausts the current lease, roughly 1 in 64, persists to the backend **synchronously, before the stanza is transmitted**. Status *reactions* (`send_reaction` targeting `status@broadcast`) are the exception: they route through the same DM branch as an ordinary 1:1 message, addressed to the status author's device, so they follow the DM counter-lease behavior instead of the group/status one. Reusing an outbound counter or iteration would reuse its message key and IV, so the advance is always durable before it can be reused. If a required persistence write fails, `send_message` returns `Err` instead of transmitting an advance that couldn't be saved. See [Signal Protocol — flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive) for the full durability model. ### SendResult Result of a successfully sent message. Provides the message ID, the encoded message itself, and a convenience method to construct a `MessageKey` for follow-up operations like album child linking. ```rust theme={null} #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub struct SendResult { pub message_id: String, pub to: Jid, pub message: std::sync::Arc, pub recipient_fanout: Option, } impl SendResult { /// Returns a MessageKey for this sent message. /// Useful for album child linking, pinning, and other operations. pub fn message_key(&self) -> wa::MessageKey; } ``` `SendResult` is `#[non_exhaustive]`: struct-literal construction and exhaustive struct destructuring from outside the crate are both disallowed. Field reads are unaffected; add `..` to any exhaustive destructuring patterns. Added in [#1406](https://github.com/oxidezap/whatsapp-rust/pull/1406): `message` is the `wa::Message` exactly as the send pipeline handed it to the encoder — the caller's content plus whatever the send applied on its behalf (`SendOptions::ephemeral_expiration`, forward preparation), and, for an edit, a revoke, a pin, a poll, or a status, the whole protocol message this crate built. A host sharing one `Client` between several consumers needs this: WhatsApp echoes a send to every other device on the account, so the sending consumer is the one place that echo never reaches, and this field is that consumer's own copy of what went out. It is **not** a copy of the wire plaintext. When the message carries no `message_context_info` of its own, the plaintext the recipient decrypts additionally carries the reporting-token context (a `message_secret` and its version) the send pipeline splices on, and the own-device copy is wrapped in a `DeviceSentMessage` — both are delivery metadata, not content, and reconstructing them here would cost decoding the plaintext just encoded. A caller that needs the exact wire form reads it from the echo the account's other devices receive. `message` is an `Arc` rather than an owned value, so cloning a `SendResult` — including for a multi-consumer host — is a refcount bump, not a copy of the message. This also dropped `SendResult`'s `Eq` derive (`wa::Message` carries floating-point fields); `PartialEq` and `Clone` are unaffected. ### RecipientFanout Added in [#1362](https://github.com/oxidezap/whatsapp-rust/pull/1362). A DM whose recipient half loses some but not all of its devices still builds a stanza, transmits it, and returns `Ok(SendResult)` with a real message id — `send_message` does not wait for the server's ack before returning (see [Phash validation](#phash-validation-stale-device-list-detection)). (When *every* recipient device fails, the send instead returns `Err(SendError::NoRecipientDevice(..))` regardless of whether the sender's own devices encrypted successfully — see [`NoRecipientDeviceError`](/api/errors#norecipientdeviceerror).) The partial case matches WA Web, whose `` node is built from whichever per-device encryptions survived and which rejects the whole send only when every device in the fan-out — recipient and own alike — fails. `SendResult::recipient_fanout` is how a caller sees the partial case, since the wire outcome alone can't distinguish a full delivery from a partial one: ```rust theme={null} #[non_exhaustive] pub struct RecipientFanout { pub addressed: usize, pub encrypted: usize, pub skipped_primary: bool, pub had_unregistered_device: bool, } impl RecipientFanout { /// `true` when `encrypted < addressed` — fewer recipient devices /// produced an `` node than were addressed. pub fn is_partial(&self) -> bool; } ``` **Fields:** * `addressed` — how many of the recipient's devices the fan-out attempted to encrypt for. * `encrypted` — how many of those actually produced an `` node. Equal to `addressed` on a full delivery. * `skipped_primary` — `true` when the recipient's primary device (device 0, their phone) was addressed but did not encrypt. This is encryption coverage, not a delivery or read signal: the phone may still have received the message through the phash-mismatch repair below, or hold the chat via a linked companion that did encrypt. A recipient's phone is the device most likely to be open, so this field is worth watching independent of `is_partial()`, but it is not proof the person missed anything. * `had_unregistered_device` — `true` when one of the addressed devices had no registered identity to encrypt against. This is the flag the DM path already computed internally; it's now exposed alongside the counts instead of being discarded. `Some(RecipientFanout)` only for a DM send (`send_message`, `send_message_with_options`, and status reactions, which route through the DM branch addressed to the status author's device). `None` for a group, a status post, a peer-sync message, and newsletter plaintext — each of those answers a different "who was reached" question, and reusing a DM's counts there would misrepresent it. This is purely additive. A caller that ignores the field, or that matches on an older `SendResult` shape without it (not actually possible across the `#[non_exhaustive]` boundary, but worth stating), sees no change in behavior. The wire stanza and the DM's success on a partial fan-out are unchanged: erroring on a partial fan-out was considered and rejected, since it would diverge from WA Web. Whether to treat a partial fan-out as a problem stays the caller's own policy, opted into by inspecting `is_partial()` and `skipped_primary`. ```rust theme={null} let result = client.send_message(to.clone(), message).await?; if let Some(fanout) = &result.recipient_fanout { if fanout.skipped_primary { log::warn!("{}'s primary device did not encrypt message {}", to, result.message_id); } else if fanout.is_partial() { log::info!( "message {} encrypted for {}/{} of {}'s devices", result.message_id, fanout.encrypted, fanout.addressed, to ); } } ``` ### ChatMessageId Identifies a specific message within a chat. Useful for operations that need both the chat and message ID together. ```rust theme={null} pub struct ChatMessageId { pub chat: Jid, pub id: MessageId, } ``` ### Example: text message ```rust theme={null} use waproto::whatsapp as wa; let message = wa::Message { conversation: Some("Hello, world!".to_string()), ..Default::default() }; let result = client.send_message( "15551234567@s.whatsapp.net".parse()?, message ).await?; println!("Message sent with ID: {}", result.message_id); ``` ### Example: Newsletter message ```rust theme={null} use waproto::whatsapp as wa; let newsletter_jid: Jid = "120363999999999999@newsletter".parse()?; let message = wa::Message { conversation: Some("Hello subscribers!".to_string()), ..Default::default() }; // Newsletter messages are sent as plaintext automatically let result = client.send_message(newsletter_jid, message).await?; ``` Newsletter reactions use a different stanza format and are still sent through `client.newsletter().send_reaction()`. See the [Newsletter API](/api/newsletter#send_reaction). ### Example: image with caption ```rust theme={null} use waproto::whatsapp as wa; // First upload the image let upload_result = client.upload(image_bytes, MediaType::Image, Default::default()).await?; let message = wa::Message { image_message: buffa::MessageField::some(wa::message::ImageMessage { url: Some(upload_result.url), direct_path: Some(upload_result.direct_path), media_key: Some(upload_result.media_key_vec()), file_enc_sha256: Some(upload_result.file_enc_sha256_vec()), file_sha256: Some(upload_result.file_sha256_vec()), file_length: Some(upload_result.file_length), media_key_timestamp: Some(upload_result.media_key_timestamp), caption: Some("Check out this image!".to_string()), mimetype: Some("image/jpeg".to_string()), ..Default::default() }), ..Default::default() }; let result = client.send_message(chat_jid, message).await?; ``` ### Example: Album (grouped media) Send multiple images and/or videos as a single grouped album. First send the parent `AlbumMessage` with expected counts, then send each child media wrapped with `wrap_as_album_child`: ```rust theme={null} use waproto::whatsapp as wa; use whatsapp_rust::proto_helpers::wrap_as_album_child; // 1. Send the parent album message with expected media counts let album_parent = wa::Message { album_message: buffa::MessageField::some(wa::message::AlbumMessage { expected_image_count: Some(2), expected_video_count: Some(1), ..Default::default() }), ..Default::default() }; let parent_result = client.send_message(&chat_jid, album_parent).await?; let parent_key = parent_result.message_key(); // 2. Send each child media wrapped as an album child let image1 = wa::Message { image_message: buffa::MessageField::some(wa::message::ImageMessage { url: Some(upload1.url), direct_path: Some(upload1.direct_path), media_key: Some(upload1.media_key_vec()), file_sha256: Some(upload1.file_sha256_vec()), file_enc_sha256: Some(upload1.file_enc_sha256_vec()), file_length: Some(upload1.file_length), media_key_timestamp: Some(upload1.media_key_timestamp), mimetype: Some("image/jpeg".to_string()), ..Default::default() }), ..Default::default() }; let wrapped = wrap_as_album_child(image1, parent_key.clone()); client.send_message(&chat_jid, wrapped).await?; // Repeat for each additional image/video in the album ``` See the [Sending Messages guide](/guides/sending-messages#album-messages) for a complete walkthrough. *** ## forward\_message Forward an existing message to a chat. Builds a forward-ready copy of `message` and sends it via `send_message`. ```rust theme={null} pub async fn forward_message( &self, to: impl Into, message: &wa::Message, ) -> Result ``` Recipient JID (DM, group, or newsletter). Source message to forward. May be a received body or a wrapper (ephemeral / view-once); the inner content is unwrapped automatically before sending. Same shape as [`send_message`](#send_message): contains the new `message_id` and the resolved recipient JID. The helper applies WhatsApp's standard forwarding rules: * Sets `context_info.is_forwarded = true` so the recipient sees the **Forwarded** label. * Bumps `forwarding_score`. At 5 it jumps to the `127` sentinel that clients render as **Forwarded many times**. * Strips the reply/quote chain and mentions from the source message. * Drops the source `message_context_info` so the send path mints a fresh `message_secret`. * Promotes a bare `conversation` to `extended_text_message` so the forward marker can attach. * Relays existing media from the same CDN blob (`media_key`, `url`, and friends are carried over) — no re-download or re-upload. ### Example: forward a received message ```rust theme={null} // `received` is a `wa::Message` from an incoming event. let result = client.forward_message(destination_jid, &received).await?; println!("Forwarded as {:?}", result.message_id); ``` For lower-level access, see [`MessageExt::prepare_for_forward`](/guides/sending-messages#preparing-messages-for-forwarding), which returns the prepared `wa::Message` without sending it. *** ## send\_message\_with\_options Send a message with additional customization options. ```rust theme={null} pub async fn send_message_with_options( &self, to: impl Into, message: wa::Message, options: SendOptions, ) -> Result ``` Recipient JID Protobuf message to send Additional send options (see below) Contains the message ID and recipient JID ### SendOptions Options for customizing message sending behavior. ```rust theme={null} #[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct SendOptions { /// Override the auto-generated message ID. /// Useful for resending a failed message with the same ID or idempotency. pub message_id: Option, /// Extra XML child nodes on the message stanza. A node here that repeats /// one the send already derives from the message content — ``, and /// `` on a DM — makes the whole send fail with /// [`SendError::InvalidRequest`] instead of transmitting a stanza with /// both, which the receiving client renders as nothing. pub extra_stanza_nodes: Vec, /// Ephemeral duration in seconds. Sets `contextInfo.expiration` on the /// message for disappearing messages support. /// Common values: 86400 (24h), 604800 (7d), 7776000 (90d). pub ephemeral_expiration: Option, /// Force the `` attribute instead of deriving it from /// content. Escape hatch for a type the classifier can't infer. pub stanza_type_override: Option, /// Whether to read the recipient's device list from cache or force a /// refresh before sending. Defaults to `Freshness::CachePreferred`. Set /// to `Freshness::Refresh` when retrying after /// [`SendError::NoRecipientDevice`](/api/errors#norecipientdeviceerror) — /// an immediate resend with a cached (and possibly stale) device list /// hits the same empty result. pub device_freshness: Freshness, } ``` `SendOptions` is `#[non_exhaustive]`, so it can no longer be constructed as a struct literal (even with `..Default::default()`) from outside the crate. Build it by chaining the `with_*` setters off `SendOptions::default()` instead: `SendOptions::default().with_message_id(id)`. Each field above has a matching `with_*` method (`with_message_id`, `with_extra_stanza_nodes`, `with_ephemeral_expiration`, `with_stanza_type_override`, `with_device_freshness`). Override the auto-generated message ID. When set, the provided ID is used instead of generating a new one. Useful for resending a failed message with the same ID or ensuring idempotency. Additional XML nodes to include in the message stanza. Used for advanced protocol features like quoted replies, mentions, or custom metadata. Only `` and, on a DM, `` are covered by this. When a caller-supplied node here has either tag and this send also derives one of its own from the message content (see [Automatic business node detection](#automatic-business-node-detection)), the library rejects the send with `SendError::InvalidRequest`. A stanza carrying both — the old behavior — renders as an empty message on the recipient's client, with no error on either end. `` is not part of this rule — WA Web itself emits `` twice on some messages, so a caller-supplied `` is always allowed, even when this send also infers one. Sets the ephemeral (disappearing) message duration in seconds by injecting `contextInfo.expiration` on the protobuf message. When the recipient's chat has disappearing messages enabled, set this to match the chat's ephemeral timer. Common values: `86400` (24 hours), `604800` (7 days), `7776000` (90 days). Pass `0` or `None` to send a non-ephemeral message. Forces the `` attribute on the outgoing stanza instead of letting the content classifier pick one. Leave as `None` for normal sends — the library infers the correct type from the protobuf payload. Set this only when you're sending a message variant the classifier can't recognize and the server requires a specific wire type. See [Stanza types](#stanza-types) for the available values. The override is applied when the stanza is first sent. The retry path reclassifies from content, so an override doesn't follow a message through resends. Controls whether the recipient's device list is read from cache or re-resolved before sending. Leave as the default for normal sends. Pass `Freshness::Refresh` when retrying a DM after [`SendError::NoRecipientDevice`](/api/errors#norecipientdeviceerror) — the cached device list produced the failure, so an immediate resend with the same cache would hit the same empty result. ### Example: Send with a custom message ID ```rust theme={null} use whatsapp_rust::send::SendOptions; let options = SendOptions::default().with_message_id("3EB0ABC123"); let result = client.send_message_with_options( chat_jid, message, options ).await?; assert_eq!(result.message_id, "3EB0ABC123"); ``` ### Example: Send an ephemeral (disappearing) message ```rust theme={null} use whatsapp_rust::send::SendOptions; let options = SendOptions::default().with_ephemeral_expiration(604800); // 7 days let result = client.send_message_with_options( chat_jid, message, options ).await?; ``` The `ephemeral_expiration` value should match the chat's disappearing messages timer. You can get this from `GroupMetadata.ephemeral` (via `metadata.ephemeral.as_ref().and_then(|e| e.expiration)`) for groups, or from `MessageInfo.ephemeral_expiration` on received messages. See the [sending messages guide](/guides/sending-messages#ephemeral-disappearing-messages) for a complete walkthrough. ### Example: Send with extra stanza nodes ```rust theme={null} use whatsapp_rust::send::SendOptions; use wacore_binary::builder::NodeBuilder; let options = SendOptions::default().with_extra_stanza_nodes(vec![ NodeBuilder::new("custom-tag") .attr("key", "value") .build() ]); let result = client.send_message_with_options( chat_jid, message, options ).await?; ``` This only applies to a tag the send already derives — a plain custom tag like `custom-tag` above never collides. Passing a caller-supplied `` alongside an interactive message that also derives one (see [Automatic business node detection](#automatic-business-node-detection)) returns `SendError::InvalidRequest` instead of sending a stanza with two. *** ## edit\_message Edit a previously sent message. ```rust theme={null} pub async fn edit_message( &self, to: impl Into, original_id: impl Into, new_content: wa::Message, ) -> Result ``` Chat JID where the original message was sent ID of the message to edit (from `send_message` return value) New message content to replace the original `message_id` is the edit stanza's own fresh id — never `original_id`, which the server would deduplicate against the original message and drop. `message` is the `protocolMessage` this crate built around `new_content`, keyed by `original_id`. See [`SendResult`](#sendresult). ### Example: Edit a message ```rust theme={null} use waproto::whatsapp as wa; // Send original message let result = client.send_message( &chat_jid, wa::Message { conversation: Some("Hello!".to_string()), ..Default::default() } ).await?; // Edit it let edit = client.edit_message( &chat_jid, &result.message_id, wa::Message { conversation: Some("Hello, edited!".to_string()), ..Default::default() } ).await?; ``` The edit is sent as a top-level `protocolMessage` with `type = MESSAGE_EDIT`, matching the WhatsApp Web wire shape. In group chats, the correct participant JID (LID or PN) is resolved for you, and a fresh stanza ID is used so the server does not deduplicate the edit against the original message. *** ## edit\_message\_with\_options Edit-path counterpart to [`send_message_with_options`](#send_message_with_options). Builds the same `protocolMessage` edit as [`edit_message`](#edit_message), but accepts an `EditOptions` struct for cases where the default fresh-stanza-id behavior isn't what you want. ```rust theme={null} pub async fn edit_message_with_options( &self, to: impl Into, original_id: impl Into, new_content: wa::Message, options: EditOptions, ) -> Result ``` Chat JID where the original message was sent ID of the message to edit (from `send_message` return value) New message content to replace the original Edit options (see below) Same shape as [`edit_message`](#edit_message), except `message_id` is the borrowed `stanza_id` when one was set, instead of a fresh id. See [`SendResult`](#sendresult). ### EditOptions ```rust theme={null} #[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct EditOptions { /// Override the outer stanza id (default: a fresh id, like `edit_message`). pub stanza_id: Option, } ``` `EditOptions` is `#[non_exhaustive]`, so it can no longer be constructed as a struct literal from outside the crate. Build it with `EditOptions::default().with_stanza_id(id)` instead. Overrides the outer stanza id normally auto-generated by `edit_message`. The edit-path counterpart of [`SendOptions::message_id`](#sendoptions) — pin the outer id to an existing message's id so clients re-render that slot instead of appending a new one. Pinning `stanza_id` to a **borrowed** id (one that already belongs to another message) is a best-effort, side-effect-aware operation: * The edit does **not** persist a retry-cache entry or an outbound message secret under the borrowed id, so the original message's retry content and secret are left intact. * The edit does not register a phash ack-waiter under the borrowed id either, since the waiter map is keyed by outer stanza id and registering one would risk resolving the wrong send. * Whether the server and recipient clients actually honor the collision (dedupe/re-render onto the borrowed id) is server- and client-dependent — treat the visible outcome as non-guaranteed, not a reliable primitive. An empty string is rejected with `SendError::InvalidRequest`, same as an empty `SendOptions::message_id`. ### Example: edit with a pinned stanza id ```rust theme={null} use waproto::whatsapp as wa; use whatsapp_rust::send::EditOptions; let edit = client.edit_message_with_options( &chat_jid, &original_id, wa::Message { conversation: Some("Hello, edited!".to_string()), ..Default::default() }, EditOptions::default().with_stanza_id(existing_message_id.clone()), ).await?; assert_eq!(edit.message_id, existing_message_id); ``` Leave `stanza_id` as `None` (or use plain [`edit_message`](#edit_message)) for ordinary edits — a fresh id is what prevents the server from deduplicating the edit against the original message. Only set `stanza_id` when you specifically need to collide the outer stanza id with an existing message. *** ## edit\_message\_encrypted Edit a message via the **message-secret encrypted** path (a `secret_encrypted_message` with `secret_enc_type = MESSAGE_EDIT`) instead of the plaintext `protocolMessage` edit produced by [`edit_message`](#edit_message). This is the form Community Announcement Groups require, and the shape WhatsApp Web sends when its `message_edit_to_message_secret_sender_enabled` flag is on. The new content is encrypted under the original message's secret. ```rust theme={null} pub async fn edit_message_encrypted( &self, to: impl Into, original_id: impl Into, message_secret: &[u8], new_content: wa::Message, ) -> Result ``` Chat JID where the original message was sent. Newsletter/channel JIDs are rejected — use [`Newsletter::edit_message`](/api/newsletter#edit_message) for channels. ID of the message to edit. You can only edit your own messages, so the original sender and the editor are both you. The 32-byte secret of the original message. Must be exactly 32 bytes. Persist it from `MessageContextInfo.message_secret` on the sent message and retrieve it by message ID from your store. Replacement message content. `message_id` is the edit stanza's own fresh id. `message` is the `secretEncryptedMessage` envelope — `new_content` is not readable from it, since it is encrypted under `message_secret`. See [`SendResult`](#sendresult). ### Example: encrypted edit ```rust theme={null} use waproto::whatsapp as wa; let edit = client.edit_message_encrypted( &chat_jid, &original_id, &message_secret, wa::Message { conversation: Some("edited (encrypted)".to_string()), ..Default::default() }, ).await?; ``` Use [`edit_message`](#edit_message) for ordinary DM and group edits. Reach for `edit_message_encrypted` only when the chat requires the message-secret edit form (e.g. Community Announcement Groups). Inbound encrypted edits are decrypted automatically on receive — see [decrypting secret-encrypted envelopes](/api/polls#decrypting-secret-encrypted-envelopes). *** ## revoke\_message Delete a message for everyone in the chat (revoke). This sends a revoke protocol message that removes the message for all participants. The message will show as "This message was deleted" for recipients. ```rust theme={null} pub async fn revoke_message( &self, to: impl Into, message_id: impl Into, revoke_type: RevokeType, ) -> Result ``` Chat JID (direct message or group) ID of the message to delete (from `send_message` return value) Who is revoking the message: * `RevokeType::Sender` - Delete your own message * `RevokeType::Admin { original_sender }` - Admin deleting another user's message in a group `message_id` is the revoke stanza's own fresh id. `message` is the `protocolMessage` it carried, keyed by `message_id` (the message being deleted). See [`SendResult`](#sendresult). ### RevokeType Specifies who is revoking (deleting) the message. ```rust theme={null} #[non_exhaustive] pub enum RevokeType { /// The message sender deleting their own message Sender, /// A group admin deleting another user's message /// `original_sender` is the JID of the user who sent the message Admin { original_sender: Jid }, } ``` `RevokeType` is `#[non_exhaustive]`, so match statements should include a wildcard arm to handle future variants. Default variant. Use when deleting your own message. Works in both DMs and groups. Use when a group admin is deleting another user's message. Only valid in groups. Requires `original_sender` JID. ### Example: revoke own message ```rust theme={null} // Send a message let result = client.send_message( &chat_jid, message ).await?; // Delete it (sender revoke) client.revoke_message( &chat_jid, &result.message_id, RevokeType::Sender ).await?; ``` ### Example: admin revoke in group ```rust theme={null} use wacore_binary::jid::Jid; // Admin deleting another user's message let original_sender: Jid = "15551234567@s.whatsapp.net".parse()?; client.revoke_message( group_jid, &message_id, RevokeType::Admin { original_sender } ).await?; ``` Admin revoke is only valid for group chats. Attempting to use it in a direct message will return an error. Since v0.6 admin revoke fan-outs are propagated correctly to every recipient device on retry: the original stanza carries `edit="8"` (`EditAttribute::AdminRevoke`), and `prepare_dm_retry_stanza` now accepts that attribute and re-emits it on each retry stanza. Previously the retry path stripped the edit attribute, so a single dropped device fan-out left the message un-deleted on that device. The library infers the right attribute from the protocol-message type via `EditAttribute::infer_from_message(...)`, so applications calling `revoke_message` need no code changes. When you send an admin revoke (`RevokeType::Admin`), the client no longer forces a full sender-key redistribution to deliver it. It uses the same [per-device incremental targeting](/advanced/signal-protocol#per-device-sender-key-tracking) as any other group message. Devices already tracked as holding the key are skipped. Your own companion devices are the exception — they're never marked `has_key=true`, so they stay SKDM targets on every send. ``/`` are omitted only when there are no remaining SKDM targets at all: for an account with no companion devices, that's every send once the group is warm; an account with companions still carries a small own-device-only distribution list even then. Previously every admin revoke re-sent the sender key to the whole device list regardless of tracking state, which on a large warm group could turn a small revoke payload into tens of kilobytes. A cold group (or one mid-rotation) still gets full distribution, same as any other cold send. No code changes are needed; this only affects stanza size on the wire. *** ## pin\_message Pin a message in a chat for all participants. ```rust theme={null} pub async fn pin_message( &self, chat: impl Into, key: wa::MessageKey, duration: PinDuration, ) -> Result ``` Chat JID where the message to pin is located The message key identifying which message to pin. Construct this from the message's chat JID, message ID, sender info, and participant (for groups). How long the message should remain pinned (see below) `message_id` is the pin stanza's own fresh id. `message` is the `pinInChatMessage` that was sent. See [`SendResult`](#sendresult). ### PinDuration Specifies how long a message stays pinned. Defaults to 7 days (matches WhatsApp Web behavior). ```rust theme={null} #[non_exhaustive] pub enum PinDuration { Hours24, Days7, // default Days30, } ``` `PinDuration` is `#[non_exhaustive]`, so match statements should include a wildcard arm to handle future variants. Pin for 24 hours Pin for 7 days (default) Pin for 30 days ### Example: Pin a message for 7 days ```rust theme={null} use waproto::whatsapp as wa; use whatsapp_rust::send::PinDuration; let key = wa::MessageKey { remote_jid: Some(chat_jid.to_string()), id: Some(message_id.clone()), from_me: Some(false), participant: None, }; client.pin_message(chat_jid, key, PinDuration::Days7).await?; ``` ### Example: Pin a group message for 30 days ```rust theme={null} use waproto::whatsapp as wa; use whatsapp_rust::send::PinDuration; let key = wa::MessageKey { remote_jid: Some(group_jid.to_string()), id: Some(message_id.clone()), from_me: Some(false), participant: Some(sender_jid.to_string()), }; client.pin_message(group_jid, key, PinDuration::Days30).await?; ``` *** ## unpin\_message Unpin a previously pinned message. ```rust theme={null} pub async fn unpin_message( &self, chat: impl Into, key: wa::MessageKey, ) -> Result ``` Chat JID where the pinned message is located The message key identifying which message to unpin Same shape as [`pin_message`](#pin_message): the unpin's own fresh `message_id` and the `pinInChatMessage` it sent. See [`SendResult`](#sendresult). ### Example: Unpin a message ```rust theme={null} use waproto::whatsapp as wa; let key = wa::MessageKey { remote_jid: Some(chat_jid.to_string()), id: Some(message_id.clone()), from_me: Some(false), participant: None, }; client.unpin_message(chat_jid, key).await?; ``` *** ## keep\_message Keep (or un-keep) a message in a disappearing chat for everyone. This is the `keepInChatMessage` add-on used by WhatsApp Web's "Keep in chat" action: when a chat has disappearing messages enabled, keeping a message prevents it from being deleted when the ephemeral timer expires. ```rust theme={null} pub async fn keep_message( &self, chat: impl Into, key: wa::MessageKey, keep: bool, ) -> Result ``` Chat JID where the target message lives. The message key identifying the message to keep or un-keep. Construct this from the target message's chat JID, message ID, sender info, and participant (for groups). `true` requests `KEEP_FOR_ALL` (keep the message past the disappearing timer). `false` requests `UNDO_KEEP_FOR_ALL` (reverse a previous keep). The keep stanza itself is sent with a fresh message id; only the target message's key is carried in the body, and the body's `timestamp_ms` records the send time (not the kept message's timestamp). The send path classifies this as a text add-on and maps the undo case to a sender-revoke edit attribute automatically, so no extra wiring is required. ### Example: Keep a message for everyone ```rust theme={null} use waproto::whatsapp as wa; let key = wa::MessageKey { remote_jid: Some(chat_jid.to_string()), id: Some(message_id.clone()), from_me: Some(false), participant: None, }; client.keep_message(chat_jid, key, true).await?; ``` ### Example: Undo a previous keep ```rust theme={null} use waproto::whatsapp as wa; let key = wa::MessageKey { remote_jid: Some(group_jid.to_string()), id: Some(message_id.clone()), from_me: Some(false), participant: Some(sender_jid.to_string()), }; client.keep_message(group_jid, key, false).await?; ``` *** ## set\_chat\_disappearing\_timer Turn disappearing messages on or off for a **1:1 chat**. Sends an `EPHEMERAL_SETTING` protocol message, mirroring WhatsApp Web's chat-action. ```rust theme={null} pub async fn set_chat_disappearing_timer( &self, chat: Jid, duration: u32, ) -> Result ``` The 1:1 chat (PN or LID). Group, status, and newsletter JIDs are rejected — for groups use [`Groups::set_ephemeral`](/api/groups#set_ephemeral); for the account default use `Client::set_default_disappearing_mode`. Timer in **seconds**. Common values: `86400` (24h), `604800` (7 days), `7776000` (90 days). Pass `0` to turn disappearing messages off. Result of the setting message send. See [SendResult](#sendresult). ### Example: enable and disable ```rust theme={null} let chat: Jid = "15551234567@s.whatsapp.net".parse()?; // Enable 7-day disappearing messages client.set_chat_disappearing_timer(chat.clone(), 604_800).await?; // Turn it off client.set_chat_disappearing_timer(chat, 0).await?; ``` This sets the chat-wide timer. To keep an individual message past the timer, use [`keep_message`](#keep_message). *** ## send\_reaction React to a DM, group, or `status@broadcast` message with an emoji. The helper builds the `ReactionMessage` payload (including `sender_timestamp_ms`) and routes it through the standard send path, so the same retry, fan-out, and phash logic that applies to other messages applies here. ```rust theme={null} pub async fn send_reaction( &self, chat: impl Into, target_key: wa::MessageKey, emoji: &str, ) -> Result ``` Chat JID where the reaction is delivered. For `status@broadcast`, this is the broadcast JID; the reaction fans out to the status author's devices using `target_key.participant`. Identifies the message being reacted to. * `remote_jid` — chat JID of the target message * `from_me` — `true` if you sent the original message, otherwise `false` * `id` — message ID of the target message * `participant` — original sender JID. **Required for groups and `status@broadcast`**; leave as `None` for DMs. Emoji to send (e.g. `"👍"`, `"❤️"`). Pass an empty string (`""`) to remove a previous reaction — this matches WhatsApp Web's empty-text-as-revoke behavior. Contains the reaction's `message_id` and resolved recipient `to` JID. See [`SendResult`](#sendresult). ### Example: react to a DM ```rust theme={null} use waproto::whatsapp as wa; let target_key = wa::MessageKey { remote_jid: Some(chat_jid.to_string()), from_me: Some(false), id: Some(target_message_id.clone()), participant: None, // DMs omit participant }; client.send_reaction(&chat_jid, target_key, "👍").await?; ``` ### Example: react to a group message ```rust theme={null} use waproto::whatsapp as wa; let target_key = wa::MessageKey { remote_jid: Some(group_jid.to_string()), from_me: Some(false), id: Some(target_message_id.clone()), // The original sender — required for groups so the receipt can be attributed. participant: Some(sender_jid.to_string()), }; client.send_reaction(&group_jid, target_key, "🎉").await?; ``` ### Example: remove a reaction ```rust theme={null} client.send_reaction(&chat_jid, target_key, "").await?; ``` Inside an event handler, prefer [`MessageContext::react`](/api/bot#react) — it derives `chat`, `target_key`, and `participant` from the incoming message for you. Newsletter (channel) reactions use a different plaintext stanza format and are not handled here. Use [`client.newsletter().send_reaction()`](/api/newsletter#send_reaction) for newsletters. *** ## Phash validation (stale device list detection) When sending group, status, or DM messages, the library automatically validates the participant hash (`phash`) from the server's acknowledgment against the locally computed value. Group sends only compare it as of [#1328](https://github.com/oxidezap/whatsapp-rust/pull/1328) — before that, a group ack's `phash` attribute was never checked at all. If the hashes differ: * **Group messages**: group metadata is invalidated (participants are re-queried on the next send); sender-key device tracking is **not** reset, since forcing full SKDM redistribution on every mismatch would cost a fan-out per message for as long as the divergence lasted * **Status messages**: sender key device cache is invalidated (forces full SKDM redistribution) * **DM messages**: the device registry cache is invalidated for both the recipient and your own phone number (PN), matching WA Web's `syncDeviceListJob([recipient, me])` behavior. As of [#1362](https://github.com/oxidezap/whatsapp-rust/pull/1362), a DM mismatch also re-resolves the recipient's device list and resends the message, under the original message id, to any device that list holds and the original stanza did not cover — see [Signal Protocol — phash repair](/advanced/signal-protocol#phash-validation-for-stale-device-list-detection) For DMs, the phash is computed locally from the sent device set but is **not** sent on the wire (WA Web only sends phash for groups). The phash is returned via the `PreparedDmStanza.phash` field and compared against the server's ACK phash. This runs asynchronously in the background and does not block the send path. See [Signal Protocol — Phash validation](/advanced/signal-protocol#phash-validation-for-stale-device-list-detection) for implementation details. *** ## Automatic stanza metadata When you call `send_message` or `send_message_with_options`, the library automatically infers and injects stanza-level metadata that WhatsApp servers expect for certain message types. This applies to all recipient types — direct messages, groups, and newsletters. You never need to set these manually — the library handles it for you. | Message type | Auto-injected metadata | | ---------------------------------------------------------- | -------------------------------------------------------------------------------------- | | `pin_in_chat_message` | Sets the `edit="2"` attribute on the message stanza | | `poll_creation_message` (v1, v2, v3) | Adds a `` child node | | `poll_update_message` (with vote) | Adds a `` child node | | `event_message` | Adds a `` child node | | `enc_event_response_message` | Adds a `` child node | | `secret_encrypted_message` with `SecretEncType::EventEdit` | Adds a `` child node | | view-once media (image/video/voice wrapped as view-once) | Adds the `` attribute so recipients render the one-time bubble | | group send to tagged members (member labels) | Adds the `appdata` / `tag_reason` meta attributes | This means you can construct a raw `wa::Message` with any of these fields set and pass it directly to `send_message_with_options` — the correct protocol metadata is derived automatically. Any nodes you provide via `extra_stanza_nodes` in `SendOptions` are merged with the auto-inferred nodes above. A caller-supplied `` node, or a caller-supplied `` node on a DM, is a separate case: the library rejects the send instead of merging when this send also derives one of those from the message content — see [`extra_stanza_nodes`](#sendoptions) and [Automatic business node detection](#automatic-business-node-detection). The `pin_message()` and `unpin_message()` convenience methods already handle this internally. Auto-detection is most useful when you build a `wa::Message` manually and send it through `send_message` or `send_message_with_options`. *** ## Automatic business node detection When you send an `InteractiveMessage` with a `NativeFlowMessage` (used for business features like payments, CTAs, and catalogs), the library automatically injects a `` stanza child node. You don't need to construct this manually. The detection works by: 1. Inspecting the outgoing message for an `InteractiveMessage` with a `NativeFlowMessage` 2. Extracting the first button's `name` field 3. Mapping the button name to a WhatsApp flow name 4. Building the `` XML node with the correct structure The resulting stanza child looks like: ```xml theme={null} ``` ### Supported button-to-flow mappings | Button name | Flow name | | ------------------------------ | -------------------------- | | `review_and_pay` | `order_details` | | `payment_info` | `payment_info` | | `review_order`, `order_status` | `order_status` | | `payment_status` | `payment_status` | | `payment_method` | `payment_method` | | `payment_reminder` | `payment_reminder` | | `open_webview` | `message_with_link` | | `message_with_link_status` | `message_with_link_status` | | `cta_url` | `cta_url` | | `cta_call` | `cta_call` | | `cta_copy` | `cta_copy` | | `cta_catalog` | `cta_catalog` | | `catalog_message` | `catalog_message` | | `quick_reply` | `quick_reply` | | `galaxy_message` | `galaxy_message` | | `booking_confirmation` | `booking_confirmation` | | `call_permission_request` | `call_permission_request` | Unrecognized button names pass through as-is. ### Payment vs nested-form vs fallback shapes The `` node is emitted in one of three shapes depending on the button content, matching WA Web's reproducer for native-flow stanzas: 1. **Payment buttons** (`review_and_pay`, `payment_info`, `payment_status`, …) — emitted as a flat `` with a `privacy_mode_ts` attribute. `privacy_mode_ts` is the current Unix timestamp from the new `wacore::time::now_secs_u64()` helper, which safely handles clocks set before 1970 by returning `0` instead of panicking. 2. **Nested-form buttons** (`cta_*`, `quick_reply`, `galaxy_message`, …) — emitted with the `` wrapper shown above. 3. **Mixed / unrecognized** — falls back to the wrapper form for forward compatibility. `bot_invoke_message` continues to emit a `` stanza child instead of `` and is unaffected by the above shapes. The `` node is merged with any other auto-inferred metadata (like `` nodes for polls or events) and with any non-colliding `extra_stanza_nodes` you provide in `SendOptions` — see the next note for the ``/`` collision case. The library also checks inside `document_with_caption_message` wrappers for interactive messages. On a DM (not a group), deriving a `` node also emits a `` node immediately before it. A caller-supplied `` in `extra_stanza_nodes` collides with this — as does a caller-supplied `` on a DM. The library rejects the send with `SendError::InvalidRequest` naming the tag, instead of sending a stanza with two. The two nodes can encode different intentions: a caller-supplied `native_flow_name`, for instance, might disagree with the one this button derives. Which one the server or recipient would honor isn't observable from here, so neither side silently wins — see [`extra_stanza_nodes`](#sendoptions). `` is not covered by this rule: a caller-supplied `` node is always allowed, even when this send also infers one. *** ## Decrypt-fail suppression Certain infrastructure messages set `decrypt-fail="hide"` on their `` nodes so recipients don't see "waiting for this message" placeholders when decryption fails. The library applies this automatically based on message content — you don't need to handle it manually. The following message types are marked with `decrypt-fail="hide"`: | Message type | Condition | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `reaction_message` | Always | | `enc_reaction_message` | Always | | `pin_in_chat_message` | Always | | `edited_message` | Always | | `keep_in_chat_message` | Always | | `enc_event_response_message` | Always | | `poll_update_message` | Only when `.vote` is present | | `message_history_notice` | Always | | `conditional_reveal_message` | Always | | `secret_encrypted_message` | Only when `SecretEncType` is `EVENT_EDIT`, `POLL_EDIT`, or `POLL_ADD_OPTION` | | `bot_invoke_message` | Only when the inner `protocol_message` has type `REQUEST_WELCOME_MESSAGE` | | `protocol_message` | When type is `EPHEMERAL_SYNC_RESPONSE`, `REQUEST_WELCOME_MESSAGE`, or `GROUP_MEMBER_LABEL_CHANGE`, or when `edited_message` is present | Additionally, `decrypt-fail="hide"` is applied for: * Messages with an `edit` attribute (except `Empty`, `AdminRevoke`, and `SenderRevoke` — WA Web never hides revokes and the server rejects revoke stanzas carrying this attribute) * Sender Key Distribution Message (SKDM) stanzas — always hidden since they are infrastructure-only Wrapper messages (`ephemeral_message`, `view_once_message`, etc.) are unwrapped before checking. The `decrypt-fail` attribute is set on the inner `` node, not the outer `` stanza. *** ## Privacy token attachment When you send a 1:1 message (not to groups, newsletters, or yourself), the library automatically attaches a privacy token to the outgoing stanza. This follows WhatsApp Web's `MsgCreateFanoutStanza.js` fallback chain: | Priority | Token type | Condition | Stanza node | | -------- | ----------- | ------------------------------------------------------------------------ | ------------- | | 1 | **tctoken** | Stored TC token exists and hasn't expired (within 28-day rolling window) | `` | | 2 | **cstoken** | No valid TC token, but NCT salt and recipient LID are available | `` | | 3 | None | Neither token nor salt available | No token node | After sending, if the TC token bucket boundary has been crossed (7-day buckets), the library automatically issues a new TC token to the recipient in the background. Privacy token selection is fully automatic. The library resolves the recipient's LID (using the LID-PN cache), looks up stored TC tokens, and falls back to cstoken computation when needed. See the [TC Token API](/api/tctoken) for details on the token lifecycle and NCT salt provisioning. *** ## Stanza types When you send a message, the library automatically determines two protocol-level type attributes based on the protobuf message content. You don't need to set these manually, but understanding them can help with debugging. ### Message stanza type The `type` attribute on the outer `` XML node is determined by `stanza_type_from_message`: | Stanza type | Message types | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"text"` | `conversation`, `protocol_message`, `keep_in_chat_message`, `edited_message`, `pin_in_chat_message`, `album_message`, `extended_text_message` (without `matched_text`), `secret_encrypted_message` with `SecretEncType::MESSAGE_EDIT`, `poll_result_snapshot_message`, `poll_result_snapshot_message_v3`, `request_payment_message`, `send_payment_message`, `payment_invite_message`, `decline_payment_request_message`, `cancel_payment_request_message` | | `"media"` | `image_message`, `video_message`, `audio_message`, `document_message`, `sticker_message`, `sticker_pack_message`, `location_message`, `contact_message`, `extended_text_message` (with `matched_text`), and all other media types | | `"reaction"` | `reaction_message`, `enc_reaction_message` | | `"poll"` | `poll_creation_message`, `poll_creation_message_v2`, `poll_creation_message_v3`, `poll_creation_message_v5`, `poll_update_message`, `secret_encrypted_message` with `SecretEncType::POLL_EDIT` or `SecretEncType::POLL_ADD_OPTION` | | `"event"` | `event_message`, `enc_event_response_message`, `secret_encrypted_message` with `SecretEncType::EVENT_EDIT` | The payment-family messages (`request_payment_message`, `send_payment_message`, `payment_invite_message`, `decline_payment_request_message`, `cancel_payment_request_message`) classify as `"text"`. These message types only exist on the Android client and are silently dropped by the server when sent as `"media"` (no `mediatype`) or as `"pay"`; `"text"` is the wire type that actually delivers. #### Overriding the stanza type When the classifier can't recognize a message variant, set `SendOptions.stanza_type_override` to force the `` attribute. Use the `StanzaType` enum: ```rust theme={null} use whatsapp_rust::{StanzaType, send::SendOptions}; let options = SendOptions::default().with_stanza_type_override(StanzaType::Text); let result = client .send_message_with_options(to, message, options) .await?; ``` `StanzaType` variants map to the wire values listed above plus `"pay"`: | Variant | Wire value | | ---------------------- | ------------ | | `StanzaType::Text` | `"text"` | | `StanzaType::Media` | `"media"` | | `StanzaType::Reaction` | `"reaction"` | | `StanzaType::Poll` | `"poll"` | | `StanzaType::Event` | `"event"` | | `StanzaType::Pay` | `"pay"` | Leave `stanza_type_override` as `None` for normal sends — the classifier already handles every supported message type. ### Encrypted media type The `mediatype` attribute on the inner `` XML node provides a more specific media classification. This is set by `media_type_from_message` and is omitted for text-only messages: | Media type | Condition | | ----------------- | -------------------------------------------------------------------------------- | | `"image"` | `image_message` present | | `"video"` | `video_message` with `gif_playback` not set or `false` | | `"gif"` | `video_message` with `gif_playback = true` | | `"ptv"` | `ptv_message` present | | `"ptt"` | `audio_message` with `ptt = true` | | `"audio"` | `audio_message` with `ptt` not set or `false` | | `"document"` | `document_message` present | | `"sticker"` | `sticker_message` present | | `"sticker_pack"` | `sticker_pack_message` present | | `"location"` | `location_message` with `is_live` not set or `false` | | `"livelocation"` | `location_message` with `is_live = true`, or `live_location_message` | | `"vcard"` | `contact_message` present | | `"contact_array"` | `contacts_array_message` present | | `"url"` | `extended_text_message` with non-empty `matched_text`, or `group_invite_message` | Both stanza type and encrypted media type are resolved automatically by the library before encryption. Wrapper messages are unwrapped first to determine the underlying content type. Since v0.6 `unwrap_message` peels a much wider set of `FutureProofMessage` wrappers — `group_status_mention_message` / `groupStatusV2`, `spoiler`, `question`, `newsletter`, `lottie`, and \~15 others — so classification follows the inner content rather than defaulting the wrapper to `"text"` (aligning with WA Web). *** ## Message types The `wa::Message` protobuf supports various message types. Set exactly one of these fields: ### Text Messages Simple text message without formatting Text with formatting, links, quoted replies, or mentions Key fields: * `text` - Message text * `contextInfo` - Quoted message, mentions * `previewType` - Link preview behavior ### Media Messages Image with optional caption. Upload the image first using `client.upload()`, then populate: * `url`, `direct_path`, `media_key`, `file_enc_sha256`, `file_sha256`, `file_length`, `media_key_timestamp` * `caption` - Image caption * `mimetype` - e.g., `"image/jpeg"` Video with optional caption. Same upload pattern as images. Audio file or voice note: * `ptt` - Set to `true` for voice notes (Push-To-Talk) * `mimetype` - e.g., `"audio/ogg; codecs=opus"` Document/file with metadata: * `file_name` - Original filename * `mimetype` - File MIME type * `caption` - Optional description Sticker image (WebP format) Sticker pack containing multiple stickers as a ZIP. Build using `create_sticker_pack_zip` and `build_sticker_pack_message` from `wacore::sticker_pack`. Requires two uploads: the sticker pack ZIP (`MediaType::StickerPack`) and a JPEG thumbnail (`MediaType::StickerPackThumbnail`) sharing the same `media_key`. See [sticker packs](/guides/sending-messages#sticker-packs). ### Other Messages GPS location with latitude, longitude, and optional name/address Contact card with vCard data Multiple contact cards Real-time location sharing Emoji reaction to another message Poll with multiple options. Use `client.polls().create()` for a higher-level API that handles message secret generation automatically. See [Polls API](/api/polls). Calendar event message. The required `` stanza node is injected automatically when sent through `send_message` or `send_message_with_options`. Album parent message declaring the expected number of grouped media items. Set `expected_image_count` and/or `expected_video_count`. After sending the parent, use `wrap_as_album_child` to wrap each child media message and link it to the parent via `SendResult::message_key()`. See [album messages](/guides/sending-messages#album-messages). ### Example: Extended text with quote Use `build_quote_context_with_info` to create a reply with the correct `participant` and `remote_jid` fields. It takes both the quoted message's chat (`quoted_chat_jid`) and the chat you're sending into (`target_chat_jid`): ```rust theme={null} use waproto::whatsapp as wa; use wacore::proto_helpers::build_quote_context_with_info; let context = build_quote_context_with_info( quoted_message_id, "ed_sender_jid, &chat_jid, // quoted_chat_jid &chat_jid, // target_chat_jid (same as quoted for in-place replies) "ed_message, ); let message = wa::Message { extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Reply to your message".to_string()), context_info: buffa::MessageField::some(context), ..Default::default() }), ..Default::default() }; ``` `remote_jid` is emitted only when `quoted_chat_jid` and `target_chat_jid` refer to different chats (a cross-chat quote, such as quoting a status into a DM). Same-chat replies omit it, matching WhatsApp Web. For newsletter chats, the `participant` field is automatically set to the newsletter JID instead of the sender. *** ## Error types ### `SendError` All send-path methods return `Result`: ```rust theme={null} #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum SendError { #[error("{0}")] Client(ClientError), #[error("client is not logged in")] NotLoggedIn, #[error("IQ request failed: {0}")] Iq(#[from] IqError), #[error("invalid send request: {0}")] InvalidRequest(String), #[error("{0}")] NoRecipientDevice(#[source] wacore::send::NoRecipientDeviceError), #[error("{0}")] PrimaryDeviceRejected(#[source] wacore::send::PrimaryDeviceRejected), #[error("{0}")] Internal(#[from] anyhow::Error), } ``` **Variants:** * `NotLoggedIn` — client is not authenticated; check connection state before sending * `Iq` — IQ request required by the send path failed * `InvalidRequest` — the send request was malformed (e.g., invalid JID, bad message shape) * `NoRecipientDevice` — added in PR #1299. A DM had no recipient device available: every resolved recipient device failed encryption, or no recipient device resolved for a non-self destination, so nothing was sent. See [`NoRecipientDeviceError` in the Error Types reference](/api/errors#norecipientdeviceerror) for the two variants it can carry. * `PrimaryDeviceRejected` — added in [PR #1362](https://github.com/oxidezap/whatsapp-rust/pull/1362). The pre-key fetch ahead of a DM send got back a `406` naming a primary device (device 0), recipient's or sender's own, so nothing was built or sent. A companion's `406` does not trigger this. See [`SendError` in the Error Types reference](/api/errors#senderror) for the WA Web parity this matches and the retry it calls for. * `Client` — underlying transport/connection error * `Internal` — catch-all for errors not yet assigned a typed variant. Includes a failure to durably persist the outbound Signal ratchet advance before the stanza was sent — see the durability note under [`send_message`](#send_message). **Example:** ```rust theme={null} use whatsapp_rust::SendError; match client.send_message(jid.clone(), message).await { Ok(result) => println!("Sent: {}", result.message_id), Err(SendError::NotLoggedIn) => eprintln!("Not authenticated"), Err(SendError::Iq(e)) => eprintln!("IQ failed: {}", e), Err(SendError::InvalidRequest(msg)) => eprintln!("Bad request: {}", msg), Err(SendError::NoRecipientDevice(e)) => eprintln!("No recipient device reached: {}", e), Err(SendError::PrimaryDeviceRejected(e)) => eprintln!("A primary device was rejected fetching pre-keys: {}", e), Err(e) => eprintln!("Send failed: {}", e), } ``` # Signal Source: https://whatsapp-rust.jlucaso.com/api/signal Low-level Signal protocol operations for encryption, decryption, and session management The `Signal` struct provides direct access to Signal protocol operations including message encryption/decryption for both 1:1 and group conversations, session management, and participant node creation. These are low-level APIs that bypass the high-level message sending pipeline. Most users should use [`client.send_message()`](/api/client#send_message) which handles encryption automatically. Use these methods only when you need direct control over the Signal protocol layer. ## Access Access Signal protocol operations through the client: ```rust theme={null} let signal = client.signal(); ``` ## Methods ### encrypt\_message Encrypt plaintext for a single recipient using the Signal protocol. ```rust theme={null} pub async fn encrypt_message( &self, jid: &Jid, plaintext: &[u8], ) -> Result<(EncType, Vec), SignalError> ``` **Parameters:** * `jid` - Recipient JID. PN JIDs are resolved to LID and `Hosted` JIDs to `HostedLid` when a mapping exists, matching WA Web's `SignalAddress.toString()` and the internal send path. See [Signal address resolution](/advanced/signal-protocol#signal-address-resolution). * `plaintext` - Raw bytes to encrypt. The caller is responsible for padding if needed. **Returns:** * `(EncType, Vec)` - The encryption type and ciphertext bytes **EncType variants:** * `EncType::PreKeyMessage` - Session was just established (includes prekey bundle) * `EncType::Message` - Standard encrypted message **Example:** ```rust theme={null} use whatsapp_rust::EncType; let plaintext = b"Hello, world!"; let (enc_type, ciphertext) = client.signal().encrypt_message(&jid, plaintext).await?; match enc_type { EncType::PreKeyMessage => println!("New session established"), EncType::Message => println!("Existing session used"), _ => {} } ``` ### decrypt\_message Decrypt a Signal protocol message from a sender. ```rust theme={null} pub async fn decrypt_message( &self, jid: &Jid, enc_type: EncType, ciphertext: &[u8], ) -> Result, SignalError> ``` **Parameters:** * `jid` - Sender JID. PN JIDs are resolved to LID and `Hosted` JIDs to `HostedLid` when a mapping exists. * `enc_type` - The encryption type (`EncType::PreKeyMessage` or `EncType::Message`) * `ciphertext` - Encrypted bytes to decrypt **Returns:** * `Vec` - Raw padded plaintext. Use `MessageUtils::unpad_message_ref` with the stanza's `v` attribute if WhatsApp message unpadding is needed. Passing `EncType::SenderKey` returns an error — use [`decrypt_group_message`](#decrypt_group_message) for sender-key encrypted group messages. **Example:** ```rust theme={null} let plaintext = client.signal().decrypt_message( &sender_jid, EncType::Message, &ciphertext, ).await?; ``` ### encrypt\_group\_message Encrypt plaintext for a group using sender keys. ```rust theme={null} pub async fn encrypt_group_message( &self, group_jid: &Jid, plaintext: &[u8], ) -> Result<(Option>, Vec), SignalError> ``` **Parameters:** * `group_jid` - Group JID (`@g.us`) * `plaintext` - Raw bytes to encrypt **Returns:** * `(Option>, Vec)` - A tuple of optional SKDM bytes and ciphertext bytes. The SKDM is `Some` only when a new sender key was created (first encrypt for this group or after key rotation). You must distribute the SKDM to all group participants when present. Concurrent calls are serialized on a per-`(group_jid, sender_jid)` chain lock (`sender_key_lock`), keyed here by your own JID as the sender. Two overlapping `encrypt_group_message` calls for the same group safely queue behind one another. A concurrent `decrypt_group_message` call only shares this lock when its `sender_jid` is your own JID (an unusual case) — decrypting messages from other participants uses a different chain and runs fully in parallel. See [sender-key chain locking](/advanced/signal-protocol#parallelized-group-encrypt-fan-out). **Example:** ```rust theme={null} let (skdm, ciphertext) = client.signal().encrypt_group_message( &group_jid, &plaintext, ).await?; if let Some(skdm_bytes) = skdm { // Distribute SKDM to all group participants println!("New sender key created, SKDM must be distributed"); } ``` ### decrypt\_group\_message Decrypt a group (sender-key) message. ```rust theme={null} pub async fn decrypt_group_message( &self, group_jid: &Jid, sender_jid: &Jid, ciphertext: &[u8], ) -> Result, SignalError> ``` **Parameters:** * `group_jid` - Group JID * `sender_jid` - Sender's JID within the group * `ciphertext` - Encrypted bytes to decrypt **Returns:** * `Vec` - Raw padded plaintext. Use `MessageUtils::unpad_message_ref` with the stanza's `v` attribute if WhatsApp message unpadding is needed. Concurrent calls are serialized on a per-`(group_jid, sender_jid)` chain lock (`sender_key_lock`), so two overlapping `decrypt_group_message` calls for the same sender safely queue behind one another. Calls for different senders in the same group — or a concurrent `encrypt_group_message` call, which uses your own JID as the chain identity — touch a different chain and run in parallel. See [sender-key chain lock (group receive)](/concepts/architecture#sender-key-chain-lock-group-receive). **Example:** ```rust theme={null} let plaintext = client.signal().decrypt_group_message( &group_jid, &sender_jid, &ciphertext, ).await?; ``` ### sender\_key\_distribution Create (or lazily initialize) and serialize the current outgoing sender-key distribution message for a group. ```rust theme={null} pub async fn sender_key_distribution( &self, group_jid: &Jid, sender_jid: &Jid, ) -> Result, SignalError> ``` **Parameters:** * `group_jid` - Group JID * `sender_jid` - Your own JID as it should appear to other group members (the sender key chain owner) **Returns:** * `Vec` - Serialized `SenderKeyDistributionMessage` bytes, ready to send to a new or existing group member (e.g. when adding a participant who needs to decrypt future messages) This is the same distribution payload [`encrypt_group_message`](#encrypt_group_message) returns automatically on first use — call it directly when you need to (re)distribute a sender key out of band, such as when a new member joins and needs the current chain without waiting for the next group message. The distribution is durably persisted before this method returns. **Example:** ```rust theme={null} let distribution = client.signal().sender_key_distribution(&group_jid, &my_jid).await?; // Send `distribution` to the new participant, e.g. wrapped in a SenderKeyDistributionMessage protocol node ``` ### process\_sender\_key\_distribution Process an incoming sender-key distribution message for a group, installing the sender's chain so future `skmsg` stanzas from them can be decrypted. ```rust theme={null} pub async fn process_sender_key_distribution( &self, group_jid: &Jid, sender_jid: &Jid, distribution: &[u8], ) -> Result<(), SignalError> ``` **Parameters:** * `group_jid` - Group JID * `sender_jid` - JID of the participant who distributed the sender key * `distribution` - Serialized `SenderKeyDistributionMessage` bytes received from the sender (typically extracted from an incoming SKDM node) The sender-key chain is durably persisted before this method returns. **Example:** ```rust theme={null} client.signal().process_sender_key_distribution( &group_jid, &sender_jid, &distribution_bytes, ).await?; ``` ### has\_sender\_key Check whether sender-key state already exists for a group and sender. ```rust theme={null} pub async fn has_sender_key( &self, group_jid: &Jid, sender_jid: &Jid, ) -> Result ``` **Parameters:** * `group_jid` - Group JID * `sender_jid` - Sender's JID within the group **Returns:** * `bool` - `true` if a sender-key chain is already stored for this `(group_jid, sender_jid)` pair **Example:** ```rust theme={null} if !client.signal().has_sender_key(&group_jid, &author_jid).await? { // No chain yet — process the SKDM before decrypting skmsg from this sender } ``` ### delete\_sender\_key Durably delete a sender-key chain for a group and sender, e.g. on group exit or key rotation. ```rust theme={null} pub async fn delete_sender_key( &self, group_jid: &Jid, sender_jid: &Jid, ) -> Result<(), SignalError> ``` **Parameters:** * `group_jid` - Group JID * `sender_jid` - Sender's JID within the group The deletion waits for any in-flight chain mutation (e.g. a concurrent `encrypt_group_message` ratchet advance) to finish before removing the chain, and is flushed to the persistent backend before returning. **Example:** ```rust theme={null} // On leaving a group, drop the local copy of your own sender key chain client.signal().delete_sender_key(&group_jid, &my_jid).await?; ``` ### validate\_session Check whether a Signal session exists for a JID. ```rust theme={null} pub async fn validate_session(&self, jid: &Jid) -> Result ``` **Parameters:** * `jid` - JID to check. PN JIDs are resolved to LID and `Hosted` JIDs to `HostedLid` when a mapping exists. **Returns:** * `bool` - `true` if a session exists, `false` otherwise **Example:** ```rust theme={null} if client.signal().validate_session(&jid).await? { println!("Session exists for {}", jid); } else { println!("No session — need to establish one first"); } ``` ### session\_info Inspect an existing pairwise Signal session, migrating legacy PN-addressed state to its resolved LID namespace when needed. ```rust theme={null} pub async fn session_info(&self, jid: &Jid) -> Result, SignalError> ``` **Parameters:** * `jid` - JID to inspect. PN JIDs are resolved to LID and `Hosted` JIDs to `HostedLid` when a mapping exists. **Returns:** * `Option` - `Some` with the session's base key and remote registration id if a session exists, `None` otherwise If only a legacy PN-addressed session exists and the resolved address is a LID, this method migrates it first (moving session and identity state to the LID namespace) and then reports on the migrated session — mirroring the on-the-fly migration used by the decrypt path. This means `session_info` is not a purely read-only probe: it can itself perform the migration, so a subsequent [`migrate_sessions`](#migrate_sessions) call on the same pair may find there is nothing left to move. See [`SignalSessionInfo`](#signalsessioninfo) and [PN→LID session migration](/advanced/signal-protocol). **Example:** ```rust theme={null} if let Some(info) = client.signal().session_info(&jid).await? { println!("registration_id={} base_key_len={}", info.registration_id, info.base_key.len()); } ``` ### delete\_sessions Delete Signal sessions and identity keys for the given JIDs. ```rust theme={null} pub async fn delete_sessions(&self, jids: &[Jid]) -> Result<(), SignalError> ``` **Parameters:** * `jids` - JIDs whose sessions and identity keys should be deleted. PN JIDs are resolved to LID and `Hosted` JIDs to `HostedLid` when a mapping exists. This matches WhatsApp Web's `deleteRemoteSession` behavior, which removes both the session and identity key as a paired operation. Changes are flushed to the persistent backend before returning. **Example:** ```rust theme={null} // Delete sessions for specific contacts client.signal().delete_sessions(&[jid1, jid2]).await?; ``` ### install\_prekey\_bundle Durably install a supplied pre-key bundle for a JID, establishing (or replacing) a pairwise session from it. ```rust theme={null} pub async fn install_prekey_bundle( &self, jid: &Jid, bundle: &PreKeyBundle, ) -> Result ``` **Parameters:** * `jid` - JID to install the session for. Resolved the same way as [`encrypt_message`](#encrypt_message). * `bundle` - A `PreKeyBundle` obtained out of band — e.g. from a manual/custom prekey fetch — rather than through [`assert_sessions`](#assert_sessions)'s normal usync + fetch flow **Returns:** * `IdentityChange` - `IdentityChange::NewOrUnchanged` if the peer had no identity key or it matched, or `IdentityChange::ReplacedExisting` if this bundle's identity key replaced a previously trusted one The session is durably persisted before this method returns. **Example:** ```rust theme={null} let bundle: PreKeyBundle = /* fetched via a custom IQ */; let identity_change = client.signal().install_prekey_bundle(&jid, &bundle).await?; if identity_change == IdentityChange::ReplacedExisting { println!("warning: {jid}'s identity key changed"); } ``` ### migrate\_sessions Move pairwise session and identity state from one JID namespace to another for the same underlying account (PN→LID, or Hosted→HostedLid). ```rust theme={null} pub async fn migrate_sessions( &self, from: &Jid, to: &Jid, ) -> Result ``` **Parameters:** * `from` - Source JID namespace (must be `Pn` or `Hosted`) * `to` - Destination JID namespace (must be `Lid` for a `Pn` source, or `HostedLid` for a `Hosted` source) **Returns:** * `SignalSessionMigration` - Counts of sessions and identities moved, discarded, or skipped. See [`SignalSessionMigration`](#signalsessionmigration). Scans known device slots under `from`, moving each pairwise session and identity to `to` when the destination doesn't already have one, and discarding the stale source entry when it does. This is the same logic the client runs automatically on LID discovery and on-the-fly during decryption — exposed here for callers that want to trigger it manually. See [PN→LID session migration](/advanced/signal-protocol). Mismatched namespace pairs (e.g. a `Pn` source with a `HostedLid` destination, or two otherwise unrelated JIDs) are rejected with `SignalError::InvalidInput`. **Example:** ```rust theme={null} let outcome = client.signal().migrate_sessions(&pn_jid, &lid_jid).await?; if outcome.has_state_changes() { println!( "migrated {} sessions, {} identities", outcome.migrated, outcome.migrated_identities ); } ``` ### create\_participant\_nodes Create encrypted participant `` nodes for the given recipient JIDs. ```rust theme={null} pub async fn create_participant_nodes( &self, recipient_jids: &[Jid], message: &waproto::whatsapp::Message, ) -> Result<(Vec, bool), SignalError> ``` **Parameters:** * `recipient_jids` - JIDs to encrypt for * `message` - Protobuf message to encrypt **Returns:** * `(Vec, bool)` - The encrypted participant XML nodes and a boolean indicating whether a device identity node should be included in the stanza (true when any participant received a PreKey message). This method resolves devices, ensures Signal sessions exist, encrypts the message for each device, and returns the resulting XML nodes. It acquires session locks matching the DM send path via `session_guards_for()` (bare recipient JID for the recipient, per-device for own companion devices) — each lock is taken as its mutex is resolved rather than resolving the whole set first (PR [#1131](https://github.com/oxidezap/whatsapp-rust/pull/1131)). **Example:** ```rust theme={null} use waproto::whatsapp as wa; let message = wa::Message { conversation: Some("Hello!".to_string()), ..Default::default() }; let (nodes, include_identity) = client.signal().create_participant_nodes( &[recipient_jid], &message, ).await?; ``` ### assert\_sessions Ensure E2E sessions exist for the given JIDs. ```rust theme={null} pub async fn assert_sessions(&self, jids: &[Jid]) -> Result<(), SignalError> ``` **Parameters:** * `jids` - JIDs to ensure sessions for If sessions do not exist, this method fetches prekey bundles from the server and establishes new sessions. **Example:** ```rust theme={null} // Ensure sessions exist before manual encryption client.signal().assert_sessions(&[jid1, jid2]).await?; ``` ### get\_user\_devices Get all known device JIDs for the given user JIDs via usync. ```rust theme={null} pub async fn get_user_devices(&self, jids: &[Jid]) -> Result, SignalError> ``` **Parameters:** * `jids` - User JIDs to query **Returns:** * `Vec` - All device JIDs for the given users **Example:** ```rust theme={null} let devices = client.signal().get_user_devices(&[user_jid]).await?; println!("User has {} devices", devices.len()); ``` ## EncType The `EncType` enum represents the Signal protocol encryption type used for a message: ```rust theme={null} pub enum EncType { /// Standard Signal message (existing session) Message, /// PreKey Signal message (new session establishment) PreKeyMessage, /// Sender key message (group encryption) SenderKey, /// Bot message secret (``) — Meta AI / fbid bot /// replies. Decrypted with the outbound `messageSecret`, not a Signal /// session. See [Bot message decryption](#bot-message-decryption-msmsg). MessageSecret, } ``` `EncType` exposes two predicate helpers: `is_session()` (true for `Message` / `PreKeyMessage`, **excludes** `MessageSecret`) and `is_bot_secret()` (true only for `MessageSecret`). `EncType` is re-exported from the crate root as `whatsapp_rust::EncType` — prefer that over the internal `wacore::message_processing::EncType` path shown in older examples. ## SignalSessionInfo Read-only information from a currently open pairwise session, returned by [`session_info`](#session_info): ```rust theme={null} pub struct SignalSessionInfo { /// Local base key identifying the active session state. pub base_key: Vec, /// Remote registration identifier recorded by the session. pub registration_id: u32, } ``` `SignalSessionInfo` is re-exported from the crate root, so it's also available as `whatsapp_rust::SignalSessionInfo`. ## SignalSessionMigration Result of moving pairwise session state between address namespaces, returned by [`migrate_sessions`](#migrate_sessions): ```rust theme={null} #[non_exhaustive] pub struct SignalSessionMigration { /// Pairwise sessions moved to the destination namespace. pub migrated: usize, /// Pairwise session lookups skipped after a storage error. pub skipped: usize, /// Pairwise sessions found or unsuccessfully queried. pub total: usize, /// Identity records moved when the destination had no identity. pub migrated_identities: usize, /// Source identity records removed in favor of an existing destination. pub discarded_identities: usize, /// Identity lookups skipped after a storage error. pub skipped_identities: usize, } ``` **Methods:** * `has_state_changes(self) -> bool` - `true` if any source state was moved or removed (`migrated != 0 || migrated_identities != 0 || discarded_identities != 0`). Useful for deciding whether a migration was a meaningful no-op (nothing to move) versus one that changed persisted state. `SignalSessionMigration` is `#[non_exhaustive]` and re-exported from the crate root as `whatsapp_rust::SignalSessionMigration`. ## Bot message decryption (msmsg) When you message Meta AI or another `@bot` account, the bot's replies arrive as `` stanzas. These are **not** Signal-session encrypted — they use a dual-HKDF derivation over the 32-byte `messageSecret` from the prompt you sent, then AES-256-GCM. The client handles this end to end and **transparently**: 1. **On send to a bot**, the outbound `MessageContextInfo.messageSecret` is persisted (keyed by `(chat, sender, msg_id)`) so the reply can be decrypted later. 2. **On receive**, an `msmsg` stanza is decrypted and decoded into a `wa::Message`, then dispatched as a normal [`Event::Messages`](/concepts/events#messages) — there is no separate bot event. The sender is the bot JID (e.g. `…@bot`) and `MsgMetaInfo.target_id` points back at your original prompt. 3. **On failure** (missing secret, GCM tag mismatch, malformed proto) the client nacks with reason `495` (`MissingMessageSecret`) instead of silently dropping, and group bot replies are acked with a bare `` matching WA Web. You don't need to call anything — receiving bot replies works as soon as you've sent a message to the bot from the same client. The low-level primitive is `wacore::bot_message::decrypt_bot_message(message_secret, enc_iv, enc_payload, ctx)`, and persistence is backed by the [`MsgSecretStore`](/api/store#msgsecretstore) trait. ## Usage examples ### Manual 1:1 encryption round-trip ```rust theme={null} // Ensure a session exists client.signal().assert_sessions(&[recipient_jid.clone()]).await?; // Encrypt let plaintext = b"Secret message"; let (enc_type, ciphertext) = client.signal().encrypt_message( &recipient_jid, plaintext, ).await?; // The recipient would decrypt with: // let decrypted = client.signal().decrypt_message(&sender_jid, enc_type, &ciphertext).await?; ``` ### Check session before sending ```rust theme={null} let has_session = client.signal().validate_session(&jid).await?; if !has_session { // Establish session first client.signal().assert_sessions(&[jid.clone()]).await?; } let (enc_type, ciphertext) = client.signal().encrypt_message(&jid, plaintext).await?; ``` ### Group encryption with SKDM handling ```rust theme={null} let (skdm, ciphertext) = client.signal().encrypt_group_message( &group_jid, &plaintext, ).await?; if skdm.is_some() { // First message in this group or after key rotation. // The SKDM must be distributed to all participants // so they can decrypt future messages. } ``` ### Add a participant to an existing sender-key group ```rust theme={null} // New member needs the current sender key chain to decrypt future skmsg if !client.signal().has_sender_key(&group_jid, &my_jid).await? { // Nothing sent to this group yet — no chain to distribute } else { let distribution = client.signal().sender_key_distribution(&group_jid, &my_jid).await?; // Send `distribution` to the new participant } ``` ### Reset a broken session Use this for a session that decodes fine but is logically wrong — for example, after a known identity compromise. A session row that fails to decode from storage doesn't need this: the next send or decrypt for that address recovers it automatically, by fetching a fresh pre-key bundle and replacing the row. See [undecodable session rows](/concepts/storage#signalstorecache) for the mechanics. ```rust theme={null} // Delete a session you know is logically wrong (e.g. after an identity compromise) client.signal().delete_sessions(&[jid.clone()]).await?; // Re-establish client.signal().assert_sessions(&[jid.clone()]).await?; // Now encryption should work again let (enc_type, ciphertext) = client.signal().encrypt_message(&jid, plaintext).await?; ``` ### Manually migrate a session to LID addressing ```rust theme={null} // Migrate first — session_info(&pn_jid) would trigger this same migration as a // side effect, which would leave nothing here for migrate_sessions to move. let outcome = client.signal().migrate_sessions(&pn_jid, &lid_jid).await?; if outcome.has_state_changes() { println!("moved {} session(s) to LID addressing", outcome.migrated); } // Inspect the session under its new LID address if let Some(info) = client.signal().session_info(&lid_jid).await? { println!("registration_id={}", info.registration_id); } ``` ## Error types ### `SignalError` All signal methods return `Result`: ```rust theme={null} #[non_exhaustive] pub enum SignalError { #[error("{0}")] Protocol(#[from] SignalProtocolError), #[error("unsupported signal operation: {0}")] Unsupported(String), #[error("invalid signal input: {0}")] InvalidInput(String), #[error("{0}")] Internal(#[from] anyhow::Error), } ``` **Variants:** * `Protocol` — Signal protocol error (session mismatch, decode failure, etc.) * `Unsupported` — Operation not supported for the given parameters * `InvalidInput` — The operation is supported but one of its inputs is malformed — e.g. a sender-key distribution message that fails to decode, or a [`migrate_sessions`](#migrate_sessions) call with a source/destination pair that isn't a valid PN→LID or Hosted→HostedLid namespace match * `Internal` — Catch-all for other errors ## See also * [Signal Protocol implementation](/advanced/signal-protocol) - Deep dive into the protocol internals * [Client](/api/client) - Core client API * [Send](/api/send) - High-level message sending (handles encryption automatically) # Spam reporting Source: https://whatsapp-rust.jlucaso.com/api/spam-report Report messages and contacts as spam to WhatsApp The spam reporting API lets you report messages, contacts, or groups as spam to WhatsApp. Reports are sent as `spam_list` IQ stanzas. ## Access Send a spam report directly through the client: ```rust theme={null} let result = client.send_spam_report(request).await?; ``` ## send\_spam\_report ```rust theme={null} pub async fn send_spam_report( &self, request: SpamReportRequest, ) -> Result ``` The spam report request containing message details, sender info, and the report flow type. Contains an optional `report_id` returned by the server. **Example:** ```rust theme={null} use whatsapp_rust::spam_report::{SpamReportRequest, SpamFlow}; let result = client.send_spam_report(SpamReportRequest { message_id: "3EB0ABC123".to_string(), message_timestamp: 1234567890, from_jid: Some("15551234567@s.whatsapp.net".parse()?), spam_flow: SpamFlow::MessageMenu, ..Default::default() }).await?; if let Some(report_id) = result.report_id { println!("Report submitted: {}", report_id); } ``` When `from_jid` is set, `send_spam_report` automatically looks up and attaches that contact's TC token to the IQ, gated behind the `enable_spam_report_iq_with_privacy_token` AB prop. This matches WhatsApp Web's `OutSpamTCTokenMixin` and lets the report be accepted for privacy-restricted accounts. No caller action is needed — see [TC Token](/api/tctoken#automatic-usage). ### Group spam report ```rust theme={null} use whatsapp_rust::spam_report::{SpamReportRequest, SpamFlow}; let result = client.send_spam_report(SpamReportRequest { message_id: "3EB0DEF456".to_string(), message_timestamp: 1234567890, group_jid: Some("120363025918861132@g.us".parse()?), group_subject: Some("Suspicious Group".to_string()), participant_jid: Some("15551234567@s.whatsapp.net".parse()?), spam_flow: SpamFlow::GroupInfoReport, ..Default::default() }).await?; ``` ## Types ### SpamReportRequest ```rust theme={null} #[derive(Debug, Clone, Default)] pub struct SpamReportRequest { pub message_id: String, pub message_timestamp: u64, pub from_jid: Option, pub participant_jid: Option, pub group_jid: Option, pub group_subject: Option, pub spam_flow: SpamFlow, pub raw_message: Option>, pub media_type: Option, pub local_message_type: Option, } ``` | Field | Type | Description | | -------------------- | ----------------- | ----------------------------------------------- | | `message_id` | `String` | The message ID being reported | | `message_timestamp` | `u64` | Unix timestamp of the message | | `from_jid` | `Option` | Sender JID | | `participant_jid` | `Option` | For group messages, the participant who sent it | | `group_jid` | `Option` | For group reports, the group JID | | `group_subject` | `Option` | For group reports, the group name | | `spam_flow` | `SpamFlow` | The context in which the report was triggered | | `raw_message` | `Option>` | Raw protobuf-encoded message bytes | | `media_type` | `Option` | Media type of the message (e.g., `"image"`) | | `local_message_type` | `Option` | Local message type identifier | ### SpamFlow The context from which the spam report was triggered. ```rust theme={null} pub enum SpamFlow { MessageMenu, GroupSpamBannerReport, GroupInfoReport, ContactInfo, StatusReport, } ``` | Variant | Wire value | Description | | ----------------------- | ------------------------- | ------------------------------------------ | | `MessageMenu` | `"MessageMenu"` | Report from message context menu (default) | | `GroupSpamBannerReport` | `"GroupSpamBannerReport"` | Report from group spam banner | | `GroupInfoReport` | `"GroupInfoReport"` | Report from group info screen | | `ContactInfo` | `"ContactInfo"` | Report from contact info screen | | `StatusReport` | `"StatusReport"` | Report from status view | ### SpamReportResult ```rust theme={null} pub struct SpamReportResult { pub report_id: Option, } ``` | Field | Type | Description | | ----------- | ---------------- | -------------------------------------- | | `report_id` | `Option` | Server-assigned report ID, if returned | ## Error handling The method returns `Result`. Common errors include network failures and server-side rejections. ```rust theme={null} match client.send_spam_report(request).await { Ok(result) => println!("Report ID: {:?}", result.report_id), Err(e) => eprintln!("Failed to report spam: {}", e), } ``` ## See also * [Client API](/api/client) - Core client methods * [Blocking](/api/blocking) - Block and unblock contacts # Status Source: https://whatsapp-rust.jlucaso.com/api/status Post, react to, and manage WhatsApp status/story updates The `Status` feature provides APIs for posting text, image, and video status updates, reacting to statuses (e.g. the "like" heart), and revoking previously sent statuses. ## Access Access status operations through the client: ```rust theme={null} let status = client.status(); ``` ## Methods ### send\_text Send a text status update with background color and font style. ```rust theme={null} pub async fn send_text( &self, text: &str, background_argb: u32, font: wa::message::extended_text_message::FontType, recipients: &[Jid], options: StatusSendOptions, ) -> Result ``` **Parameters:** * `text` - Status text content * `background_argb` - Background color as ARGB (e.g., `0xFF1E6E4F`) * `font` - Pass one of the `FontType` variants (e.g. `FontType::SYSTEM`). This is a typed protocol enum, so you can't pass a value it doesn't define — an older `i32` form silently dropped out-of-range values at encode time * `recipients` - Slice of recipient JIDs * `options` - Privacy and delivery options **Returns:** * `SendResult` containing the `message_id` and `to` JID. Use `result.message_key()` to get a `wa::MessageKey` for revocation or other follow-up operations. **Example:** ```rust theme={null} use waproto::whatsapp as wa; use whatsapp_rust::{Jid, StatusSendOptions}; let recipients = [ Jid::pn("15551234567"), Jid::pn("15559876543"), ]; let result = client.status() .send_text( "Hello from Rust!", 0xFF1E6E4F, // Green background wa::message::extended_text_message::FontType::SYSTEM, &recipients, StatusSendOptions::default(), ) .await?; println!("Status sent: {}", result.message_id); ``` ### send\_image Send an image status update. ```rust theme={null} pub async fn send_image( &self, upload: UploadResponse, thumbnail: Vec, caption: Option<&str>, recipients: &[Jid], options: StatusSendOptions, ) -> Result ``` **Parameters:** * `upload` - Upload response from `client.upload()` (takes ownership) * `thumbnail` - JPEG thumbnail bytes * `caption` - Optional caption text * `recipients` - Slice of recipient JIDs * `options` - Privacy options **Example:** ```rust theme={null} use wacore::download::MediaType; // Upload the image first let image_data = std::fs::read("photo.jpg")?; let upload = client.upload(image_data, MediaType::Image, Default::default()).await?; // Create thumbnail (simplified - use proper JPEG encoding) let thumbnail = create_thumbnail(&image_data)?; let result = client.status() .send_image( upload, thumbnail, Some("Check this out!"), &[Jid::pn("15551234567")], StatusSendOptions::default(), ) .await?; ``` ### send\_video Send a video status update. ```rust theme={null} pub async fn send_video( &self, upload: UploadResponse, thumbnail: Vec, duration_seconds: u32, caption: Option<&str>, recipients: &[Jid], options: StatusSendOptions, ) -> Result ``` **Parameters:** * `upload` - Upload response from `client.upload()` (takes ownership) * `thumbnail` - JPEG thumbnail bytes * `duration_seconds` - Video duration * `caption` - Optional caption text * `recipients` - Slice of recipient JIDs * `options` - Privacy options **Example:** ```rust theme={null} use wacore::download::MediaType; let video_data = std::fs::read("video.mp4")?; let upload = client.upload(video_data, MediaType::Video, Default::default()).await?; let thumbnail = extract_video_thumbnail(&video_data)?; let result = client.status() .send_video( upload, thumbnail, 30, // 30 seconds None, &[Jid::pn("15551234567")], StatusSendOptions::default(), ) .await?; ``` ### send\_raw Send a custom message type as a status update. ```rust theme={null} pub async fn send_raw( &self, message: wa::Message, recipients: &[Jid], options: StatusSendOptions, ) -> Result ``` **Example:** ```rust theme={null} use waproto::whatsapp as wa; let message = wa::Message { extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Custom message".to_string()), ..Default::default() }), ..Default::default() }; let result = client.status() .send_raw(message, recipients, StatusSendOptions::default()) .await?; ``` ### revoke Delete a previously sent status update. ```rust theme={null} pub async fn revoke( &self, message_id: impl Into, recipients: &[Jid], options: StatusSendOptions, ) -> Result ``` **Parameters:** * `message_id` - ID of the status to revoke * `recipients` - Same recipients the status was sent to * `options` - Privacy options **Example:** ```rust theme={null} use waproto::whatsapp::message::extended_text_message::FontType; // Send a status let result = client.status() .send_text("Temporary status", 0xFF000000, FontType::SYSTEM, &recipients, Default::default()) .await?; // Later, revoke it client.status() .revoke(&result.message_id, &recipients, Default::default()) .await?; ``` ### send\_reaction React to a status update with an emoji. You send status reactions via `Client::send_reaction` (not `client.status()`) using `status@broadcast` as the chat JID and a `wa::MessageKey` whose `participant` field identifies the status owner. ```rust theme={null} pub async fn send_reaction( &self, chat: impl Into, target_key: wa::MessageKey, emoji: &str, ) -> Result ``` **Parameters:** * `chat` - `Jid::status_broadcast()` (the broadcast JID) * `target_key` - Identifies the status to react to; set `participant` to the status owner's JID and `id` to the status message ID * `emoji` - Emoji to send (e.g. `"💚"`). Pass `""` to remove a previous reaction **Example:** ```rust theme={null} use waproto::whatsapp as wa; let status_jid = Jid::status_broadcast(); let target_key = wa::MessageKey { remote_jid: Some(status_jid.to_string()), from_me: Some(false), id: Some(status_message_id.clone()), participant: Some(Jid::pn("15551234567").to_string()), }; // React to a status client.send_reaction(&status_jid, target_key.clone(), "💚").await?; // Remove a previous reaction client.send_reaction(&status_jid, target_key, "").await?; ``` See [Send API — send\_reaction](/api/send#send_reaction) for the full parameter reference and additional examples. ## Types ### StatusPrivacySetting Privacy setting for status delivery. ```rust theme={null} #[non_exhaustive] pub enum StatusPrivacySetting { /// Send to all contacts in address book (default) Contacts, /// Send only to contacts in an allow list AllowList, /// Send to all contacts except those in a deny list DenyList, } ``` `StatusPrivacySetting` is `#[non_exhaustive]`, so match statements should include a wildcard arm to handle future variants. ### StatusSendOptions Options for sending status updates. ```rust theme={null} pub struct StatusSendOptions { /// Privacy setting for this status. Sent in the `` stanza node. pub privacy: StatusPrivacySetting, /// Override the generated message ID. pub message_id: Option, /// Extra child nodes appended to the status stanza. pub extra_stanza_nodes: Vec, /// Freshness policy for the recipient device lists used by this send. pub device_freshness: Freshness, } ``` * `message_id` - Set this to resend a failed status with its original ID, or to otherwise ensure idempotency. Leave it `None` to auto-generate an ID. Same override use case as `SendOptions::message_id` on 1:1/group sends — see [Send API — SendOptions](/api/send#sendoptions). * `extra_stanza_nodes` - Add neutral child nodes here to include them on the outgoing `` stanza. Don't use this for structural children the send path owns (e.g. ``, ``) — those are rejected before any cryptographic state changes. * `device_freshness` - Leave this at `Freshness::CachePreferred` (default) to reuse the cached recipient device list, or set `Freshness::Refresh` to force a fresh device-list fetch before sending. See [`Freshness`](/api/groups#freshness). **Example:** ```rust theme={null} use whatsapp_rust::{Freshness, StatusSendOptions, StatusPrivacySetting}; // Send to all contacts let options = StatusSendOptions::default(); // Send to allow list only, forcing a fresh device-list fetch let options = StatusSendOptions { privacy: StatusPrivacySetting::AllowList, device_freshness: Freshness::Refresh, ..Default::default() }; ``` ## Font styles WhatsApp Web supports 5 font styles (0-4): | Index | Style | | ----- | ---------------------- | | 0 | Default (sans-serif) | | 1 | Serif | | 2 | Typewriter (monospace) | | 3 | Bold script | | 4 | Condensed | ## Background colors Background colors use ARGB format (`0xAARRGGBB`): ```rust theme={null} // Solid green let green = 0xFF1E6E4F_u32; // Solid blue let blue = 0xFF1A73E8_u32; // Solid red let red = 0xFFD93025_u32; // Semi-transparent black let overlay = 0x80000000_u32; ``` ## Recipient management Recipients should be JIDs of users who can see the status. You can pass any `&[Jid]` — an array literal, a slice of a `Vec`, or a fixed-size array: ```rust theme={null} // Single recipient (array literal) let recipients = &[Jid::pn("15551234567")]; // Multiple recipients let recipients = &[ Jid::pn("15551234567"), Jid::pn("15559876543"), "15557654321@s.whatsapp.net".parse()?, ]; // From an existing Vec let jids = vec![Jid::pn("15551234567")]; let recipients = &jids; ``` The `recipients` list should match your privacy settings. When revoking a status, use the same recipients list that was used when posting. ## Phash validation After sending a status update, the library validates the participant hash (`phash`) from the server's acknowledgment against the locally computed value. On mismatch, the sender key device cache is invalidated so the next status send re-fetches current device lists. This runs in the background and does not affect the send result. See [Signal Protocol — Phash validation](/advanced/signal-protocol#phash-validation-for-stale-device-list-detection) for details. # Storage Traits Source: https://whatsapp-rust.jlucaso.com/api/store Storage backend traits and SQLite implementation for persistent state ## Overview whatsapp-rust uses a trait-based storage system to persist device state, cryptographic keys, and protocol metadata. The storage layer is split into five domain-specific traits: * **SignalStore** - Signal protocol cryptographic operations (identity keys, sessions, pre-keys, sender keys) * **AppSyncStore** - WhatsApp app state synchronization (sync keys, versions, mutation MACs) * **ProtocolStore** - WhatsApp protocol alignment (SKDM tracking, LID-PN mapping, device registry) * **MsgSecretStore** - `messageSecret` persistence for poll/edit/bot-reply decryption (added in v0.6) * **DeviceStore** - Device persistence operations All five traits are combined into the `Backend` trait for convenience. ## The backend trait Any type implementing all five domain traits automatically implements `Backend`: ```rust theme={null} pub trait Backend: SignalStore + AppSyncStore + ProtocolStore + MsgSecretStore + DeviceStore + Send + Sync {} impl Backend for T where T: SignalStore + AppSyncStore + ProtocolStore + MsgSecretStore + DeviceStore + Send + Sync {} ``` `MsgSecretStore` became a required member of `Backend` in v0.6, so a custom backend must implement it (the bundled `SqliteStore` already does). Its methods have defaults that keep the surface small — see [MsgSecretStore](#msgsecretstore) for which methods you actually need to write. ## SignalStore Trait Handles Signal protocol cryptographic storage for end-to-end encryption. ### Identity Operations ```rust theme={null} /// Store an identity key for a remote address async fn put_identity(&self, address: &str, key: [u8; 32]) -> Result<()>; /// Load an identity key for a remote address async fn load_identity(&self, address: &str) -> Result>>; /// Delete an identity key async fn delete_identity(&self, address: &str) -> Result<()>; /// Delete several identity keys in one backend operation (default loops over delete_identity) async fn delete_identities_batch(&self, addresses: &[Arc]) -> Result<()>; ``` ### Session Operations ```rust theme={null} /// Get an encrypted session for an address async fn get_session(&self, address: &str) -> Result>>; /// Store an encrypted session async fn put_session(&self, address: &str, session: &[u8]) -> Result<()>; /// Delete a session async fn delete_session(&self, address: &str) -> Result<()>; /// Delete several sessions in one backend operation (default loops over delete_session) async fn delete_sessions_batch(&self, addresses: &[Arc]) -> Result<()>; /// Check if a session exists (default implementation uses get_session) async fn has_session(&self, address: &str) -> Result; ``` ### PreKey Operations ```rust theme={null} /// Store a pre-key async fn store_prekey(&self, id: u32, record: &[u8], uploaded: bool) -> Result<()>; /// Store multiple pre-keys in a single batch operation (default loops over store_prekey) async fn store_prekeys_batch(&self, keys: &[(u32, Vec)], uploaded: bool) -> Result<()>; /// Load a pre-key by ID async fn load_prekey(&self, id: u32) -> Result>>; /// Load multiple pre-keys by ID in a single batch operation (default loops over load_prekey) async fn load_prekeys_batch(&self, ids: &[u32]) -> Result)>>; /// Remove a pre-key async fn remove_prekey(&self, id: u32) -> Result<()>; /// Remove several pre-keys in one backend operation (default loops over remove_prekey) async fn remove_prekeys_batch(&self, ids: &[u32]) -> Result<()>; /// Get the highest pre-key ID currently stored async fn get_max_prekey_id(&self) -> Result; ``` ### Signed PreKey operations ```rust theme={null} /// Store a signed pre-key async fn store_signed_prekey(&self, id: u32, record: &[u8]) -> Result<()>; /// Load a signed pre-key by ID async fn load_signed_prekey(&self, id: u32) -> Result>>; /// Load all signed pre-keys (returns id, record pairs) async fn load_all_signed_prekeys(&self) -> Result)>>; /// Remove a signed pre-key async fn remove_signed_prekey(&self, id: u32) -> Result<()>; ``` ### Sender key operations For group messaging encryption: ```rust theme={null} /// Store a sender key for group messaging async fn put_sender_key(&self, address: &str, record: &[u8]) -> Result<()>; /// Get a sender key async fn get_sender_key(&self, address: &str) -> Result>>; /// Delete a sender key async fn delete_sender_key(&self, address: &str) -> Result<()>; /// Delete several sender keys in one backend operation (default loops over delete_sender_key) async fn delete_sender_keys_batch(&self, addresses: &[Arc]) -> Result<()>; ``` `delete_identities_batch`, `delete_sessions_batch`, `remove_prekeys_batch`, and `delete_sender_keys_batch` were added so [`SignalStoreCache::flush`](/concepts/storage#signalstorecache) can drop many rows in one backend call instead of one call per row — the shape an offline drain or an identity reset produces, where the flush deletes dozens of sessions, pre-keys, or sender keys at once. Each has a default that loops over the single-item method, so existing custom backends keep working unchanged; the bundled `SqliteStore` overrides all four with one transaction and a chunked SQL `IN` list. Override them the same way if your backend supports transactions — see [Batched deletes](/concepts/storage#signalstorecache) for measured numbers. ## AppSyncStore Trait Handles WhatsApp app state synchronization storage. ### Sync key operations ```rust theme={null} /// Get an app state sync key by ID async fn get_sync_key(&self, key_id: &[u8]) -> Result>; /// Set an app state sync key async fn set_sync_key(&self, key_id: &[u8], key: AppStateSyncKey) -> Result<()>; /// Get the latest sync key ID async fn get_latest_sync_key_id(&self) -> Result>>; ``` ### Version Tracking ```rust theme={null} /// Get the app state version for a collection, or `None` if it has never synced. async fn get_version(&self, name: &str) -> Result>; /// Forget a collection's version, returning it to the never-synced state. async fn delete_version(&self, name: &str) -> Result<()>; /// Set the app state version for a collection async fn set_version(&self, name: &str, state: HashState) -> Result<()>; ``` `get_version` returns `Option` — previously it returned `HashState`, with a missing row silently coerced to `HashState::default()`. The absence is meaningful. WA Web treats "no record" as needing a bootstrap snapshot. A collection that synced and is legitimately empty sits at version 0 *with* a record instead, and asks for patches. Those two states used to be indistinguishable, and collapsing them made an empty collection re-request a snapshot forever. `delete_version` is new in the same release: it's how you express a rebuild, by removing the record entirely rather than zeroing it, so the next sync bootstraps from scratch. If you maintain a custom backend, update `get_version`'s return type and implement `delete_version`. `HashState` also gained a `bootstrapped: bool` field. `#[serde(default)]` only helps a self-describing format — it lets an existing row decode this field as `false`. It doesn't help every encoding: bincode, for example, reads a fixed field sequence and fails outright on a row written before the field existed, rather than defaulting it. A custom backend using a non-self-describing format needs to treat that decode failure as an absent row (see [Upgrading from a bincode-encoded database](/concepts/storage#upgrading-from-a-bincode-encoded-database)) or migrate its stored rows to backfill the field. `bootstrapped` is set only once a bootstrap run reaches its terminal page — a deferred or partial bootstrap leaves it `false` even though the version already moved. Before building an outgoing patch, the client checks `bootstrapped` directly, and syncs the collection first if it's not set. `HashState::has_baseline()` (`bootstrapped || version > 0`) answers a related but different question — whether there's a real ltHash to sync patches against at all — and decides whether an *incoming* sync asks for a snapshot or for patches. ### Mutation MAC Operations ```rust theme={null} /// Store mutation MACs for a version async fn put_mutation_macs( &self, name: &str, version: u64, mutations: &[AppStateMutationMAC], ) -> Result<()>; /// Get a mutation MAC by index async fn get_mutation_mac(&self, name: &str, index_mac: &[u8]) -> Result>>; /// Batch variant of get_mutation_mac: fetch many previous-MAC values in one /// call, returning index_mac -> value_mac. The default loops over /// get_mutation_mac; backends with set-membership queries (SQL `IN (...)`) /// should override to avoid an N+1 (one round-trip per mutation) during sync. async fn get_mutation_macs( &self, name: &str, index_macs: &[[u8; 32]], ) -> Result>>; /// Delete mutation MACs by their index MACs async fn delete_mutation_macs(&self, name: &str, index_macs: &[Vec]) -> Result<()>; /// Persist one applied patch — its new version, the index MACs it removed, /// and the MACs it added — as a unit. Use the default if your backend has no /// transactions: it runs `set_version`, then `delete_mutation_macs`, then /// `put_mutation_macs`, so your current behavior stays unchanged. Override it /// with a single transaction otherwise. async fn commit_patch( &self, name: &str, state: HashState, removed_index_macs: &[Vec], added: &[AppStateMutationMAC], ) -> Result<()>; ``` `get_mutation_macs` was added in v0.6 to collapse the app-state sync's per-mutation previous-MAC lookups (which were N+1) into a single batched query. It has a default implementation, so custom backends that do **not** override it keep working without changes — override it with a `WHERE index_mac IN (…)` query for the performance win. The SQLite store chunks the `IN` list at 500 entries. An index MAC is always a full HMAC-SHA256 output, so this method's signature uses inline `[u8; 32]` arrays (`wacore::appstate_sync::IndexMac`) instead of `Vec` — zero per-MAC heap allocations on either side of the batch lookup. This is a breaking change for custom backends that override `get_mutation_macs`: update the parameter and return-map key types to `[u8; 32]`. `get_mutation_mac`, `put_mutation_macs`, and `delete_mutation_macs` are unaffected. `commit_patch` was added in [whatsapp-rust#1401](https://github.com/oxidezap/whatsapp-rust/pull/1401) so the app-state sync loop can persist a patch's version and MAC changes in one backend call instead of three, replacing the sync loop's previous sequence of a `set_version` call followed by conditional `delete_mutation_macs`/`put_mutation_macs` calls. Its default implementation matches that sequence, so your existing custom backend compiles and behaves the same without changes. Override it with a single transaction if your backend supports them (the SQLite store does): a paged incremental sync commits hundreds of small patches, and each of the three separate writes previously paid its own connection permit and commit. A transactional override is also strictly safer for you: the new version can no longer land without the MACs it pairs with. ## ProtocolStore Trait Handles WhatsApp protocol alignment and tracking. ### Per-device sender key tracking Tracks sender key distribution status per device in groups, matching WhatsApp Web's `participant.senderKey Map` model. Each device has a boolean indicating whether it holds a valid sender key (`true`) or needs a fresh SKDM (`false`). ```rust theme={null} /// Get sender key distribution status for all known devices in a group. /// Returns (device_jid_string, has_key) pairs. async fn get_sender_key_devices(&self, group_jid: &str) -> Result>; /// Set sender key status for devices. Use has_key=true after successful /// SKDM distribution (WA Web: markHasSenderKey), or has_key=false to mark /// devices as needing fresh SKDM (WA Web: markForgetSenderKey). async fn set_sender_key_status(&self, group_jid: &str, entries: &[(&str, bool)]) -> Result<()>; /// Clear all sender key device tracking for a group (on sender key rotation). async fn clear_sender_key_devices(&self, group_jid: &str) -> Result<()>; ``` ### LID-PN Mapping Manages mappings between LID (Locally Indexed Device) and phone numbers: ```rust theme={null} /// Get a mapping by LID async fn get_lid_mapping(&self, lid: &str) -> Result>; /// Get a mapping by phone number (returns the most recent LID) async fn get_pn_mapping(&self, phone: &str) -> Result>; /// Store or update a LID-PN mapping async fn put_lid_mapping(&self, entry: &LidPnMappingEntry) -> Result<()>; /// Get all LID-PN mappings (for cache warm-up) async fn get_all_lid_mappings(&self) -> Result>; ``` ### Base key collision detection ```rust theme={null} /// Save the base key for a session address during retry collision detection async fn save_base_key(&self, address: &str, message_id: &str, base_key: &[u8]) -> Result<()>; /// Check if the current session has the same base key as the saved one async fn has_same_base_key( &self, address: &str, message_id: &str, current_base_key: &[u8], ) -> Result; /// Delete a base key entry async fn delete_base_key(&self, address: &str, message_id: &str) -> Result<()>; /// Delete base keys recorded before `cutoff_timestamp` (unix seconds). /// Returns the count deleted. Defaulted to `Ok(0)` — the keepalive sweep /// calls it unconditionally for every backend, so a backend that never /// implements it just never prunes. async fn delete_expired_base_keys(&self, cutoff_timestamp: i64) -> Result { // default: Ok(0) } ``` `delete_expired_base_keys` was added in [whatsapp-rust#1411](https://github.com/oxidezap/whatsapp-rust/pull/1411). A base key is written when a peer's retry #2 arrives and previously had no deletion path for the common case where no retry #3 ever follows — the row stayed for the life of the database. The client now sweeps rows older than one hour on a keepalive tick (see [Retention and periodic maintenance](#retention-and-periodic-maintenance)); the built-in `SqliteStore` and `InMemoryBackend` both implement the sweep, so only a fully custom backend needs to override the default no-op. ### Device Registry ```rust theme={null} /// Update the device list for a user (called after usync responses) async fn update_device_list(&self, record: DeviceListRecord) -> Result<()>; /// Batched variant — update the device list for many users in one call, /// used by the parallelized group encrypt fan-out so the device-registry /// write doesn't serialize per-recipient. async fn update_device_lists(&self, records: Vec) -> Result<()>; /// Get all known devices for a user async fn get_devices(&self, user: &str) -> Result>; /// Batched variant of `get_devices`: the records stored under any of /// `users`, in no particular order, with absent users left out. Backends /// should override with one query — the default loops over `get_devices` /// for correctness. async fn get_devices_batch(&self, users: &[&str]) -> Result>; ``` `get_devices_batch` was added in [whatsapp-rust#1400](https://github.com/oxidezap/whatsapp-rust/pull/1400) so resolving a cold large group's devices (`get_user_devices_owned`) and the usync response path read every member in one round trip instead of one `get_devices` call per member. It has a default implementation that loops, so existing custom backends compile and behave the same without changes. Override it with a single chunked `IN (...)` query if your backend supports one — the built-in `SqliteStore` does, and the exploration behind this PR measured the per-member form at 22.1 ms against 0.68 ms for one query at 256 members on a file-backed store. ### TcToken Storage Trusted contact privacy tokens: ```rust theme={null} /// Get a trusted contact token for a JID (stored under LID) async fn get_tc_token(&self, jid: &str) -> Result>; /// Get the trusted contact tokens for several JIDs at once, in the order /// asked, `None` where the backend holds no row. The default loops over /// `get_tc_token`; override with a single `WHERE jid IN (...)` query. async fn get_tc_tokens(&self, jids: &[String]) -> Result>> { // default: one get_tc_token call per JID } /// Store or update a trusted contact token for a JID async fn put_tc_token(&self, jid: &str, entry: &TcTokenEntry) -> Result<()>; /// Delete a trusted contact token for a JID async fn delete_tc_token(&self, jid: &str) -> Result<()>; /// Get all JIDs that have stored tc tokens async fn get_all_tc_token_jids(&self) -> Result>; /// Delete tc tokens that have no live state left. A row is removed only when /// its received token is expired-or-absent (token_timestamp < token_cutoff, or /// empty) AND its sender bucket is expired-or-absent (sender_timestamp < /// sender_cutoff, or null) — so recent sender-side rate-limit state is never /// dropped just because the received token expired. Returns count deleted. async fn delete_expired_tc_tokens(&self, token_cutoff: i64, sender_cutoff: i64) -> Result; /// Advance sender_timestamp for a contact toward `sender_timestamp`, inserting /// a byte-less placeholder when no entry exists and preserving any existing /// token bytes. The stored value only ever moves forward (max), so concurrent /// writers (post-send issuance, history sync) converge regardless of ordering. /// Must be atomic w.r.t. `put_tc_token`/`store_received_tc_token` — the default /// is a read-modify-write; override with a single atomic upsert (`SqliteStore` /// and `InMemoryBackend` both do). async fn touch_tc_token_sender_timestamp(&self, jid: &str, sender_timestamp: i64) -> Result<()> { // default: read-modify-write via get_tc_token + put_tc_token } /// Store a token received from a contact, preserving any existing /// `sender_timestamp`. The symmetric counterpart of /// `touch_tc_token_sender_timestamp` — each writer owns its own field, so the /// notification path never drops a sender bucket the issuance path wrote /// concurrently. /// /// **Newer-wins**: the stored `(token, token_timestamp)` pair is overwritten /// only when the existing token is a byte-less placeholder, or the incoming /// `token_timestamp` is at least as new as the stored one — a stale write must /// never clobber a fresher real token. `SqliteStore` enforces this atomically /// inside an `IMMEDIATE` transaction; `InMemoryBackend` under its state lock. /// This is what lets concurrent history-sync chunks and the /// privacy-notification path converge on the same row without an external /// lock. The default read-modify-write below is a best-effort for /// third-party backends — same atomicity caveat as /// `touch_tc_token_sender_timestamp`. async fn store_received_tc_token(&self, jid: &str, token: &[u8], token_timestamp: i64) -> Result<()> { // default: read-modify-write via get_tc_token + put_tc_token, newer-wins } ``` `get_tc_tokens` was added in [whatsapp-rust#1405](https://github.com/oxidezap/whatsapp-rust/pull/1405) so the reconnect presence re-subscribe (see [Automatic re-subscription on reconnect](/api/presence#automatic-re-subscription-on-reconnect)) can look up a whole batch of tracked contacts' tokens in one backend call instead of one query per contact. It has a defaulted implementation, so existing custom backends compile and behave the same without changes — override it with a `WHERE jid IN (...)` query for the performance win, the same way `SqliteStore` chunks it at 500 entries. `delete_expired_tc_tokens` gained a second `sender_cutoff` parameter — **this is a breaking change** for any custom backend that overrides it; update the signature to `(&self, token_cutoff: i64, sender_cutoff: i64) -> Result` and prune on both windows independently (see the built-in `SqliteStore`/`InMemoryBackend` implementations for the two-filter pattern). `touch_tc_token_sender_timestamp` and `store_received_tc_token` are defaulted methods — existing custom backends compile and work unchanged, but should override both with an atomic upsert if the backend supports one, since the default read-modify-write can race a concurrent writer touching the same row (post-send issuance vs. an incoming `privacy_token` notification). For `store_received_tc_token` specifically, a non-atomic override that races two callers can let an older token's write land last and clobber a fresher one — the built-in backends close this by making the newer-wins check part of the same read+write. ### Sent message store Persists sent message payloads for retry handling. Matches WhatsApp Web's `getMessageTable` pattern where retry receipts look up the original message from storage. ```rust theme={null} /// Store a sent message's serialized payload for retry handling. /// Called after each send_message(); the payload is the protobuf-encoded Message. async fn store_sent_message( &self, chat_jid: &str, message_id: &str, payload: &[u8], ) -> Result<()>; /// Retrieve and delete a sent message (atomic take). Returns serialized payload. /// Called when a retry receipt arrives; consuming prevents double-retry. async fn take_sent_message( &self, chat_jid: &str, message_id: &str, ) -> Result>>; /// Delete sent messages older than cutoff (unix timestamp seconds). /// Returns count deleted. async fn delete_expired_sent_messages( &self, cutoff_timestamp: i64, ) -> Result; ``` The `take_sent_message` method is an atomic read-and-delete operation. Once a message payload is taken for retry, it is removed from storage to prevent double-retry. For status broadcasts where multiple devices may retry, the client re-adds the message after taking it. ### MsgSecretStore The fifth required member of [`Backend`](#the-backend-trait). It persists the 32-byte `messageSecret` values needed to decrypt later add-ons keyed off an original message: poll votes, poll/event edits, message edits (`secret_encrypted_message`), and Meta AI / fbid bot replies (``). Secrets are keyed by `(chat, sender, msg_id)` and carry an absolute expiry so they can be pruned by policy (see [messageSecret retention](/api/bot#messagesecret-retention)). ```rust theme={null} /// Store a single secret with no expiry (expires_at = 0, kept forever). async fn put_msg_secret( &self, chat: &str, sender: &str, msg_id: &str, secret: &[u8; 32], // MessageSecret, the protocol-fixed message-secret size ) -> Result<()>; /// Batch upsert. Each MsgSecretEntry carries its own absolute expires_at /// deadline and parent message_ts. On conflict the later deadline wins /// (0 = never) and the later non-zero message_ts wins. Returns count stored. async fn put_msg_secrets(&self, entries: Vec) -> Result; /// Look up a secret for decryption. async fn get_msg_secret( &self, chat: &str, sender: &str, msg_id: &str, ) -> Result>>; /// Look up a secret together with the parent message's event time, used to /// enforce the per-add-on edit window. async fn get_msg_secret_with_ts( &self, chat: &str, sender: &str, msg_id: &str, ) -> Result, i64)>>; /// Delete secrets whose expires_at <= cutoff. Rows with expires_at = 0 are /// kept forever. Returns count removed. async fn delete_expired_msg_secrets(&self, cutoff_timestamp: i64) -> Result; ``` `get_msg_secret` and `get_msg_secret_with_ts` still return `Vec` rather than `MessageSecret`. Reads don't carry the same fixed-length guarantee as writes, since the persisted `secret BLOB` column has no length constraint at the SQL level. ```rust theme={null} pub type MessageSecret = [u8; 32]; // wacore::reporting_token::MESSAGE_SECRET_SIZE pub struct MsgSecretEntry { pub chat: Arc, // shared per conversation instead of one String per row pub sender: Arc, pub msg_id: Arc, pub secret: MessageSecret, pub expires_at: i64, pub message_ts: i64, } ``` The `chat`, `sender`, and `msg_id` fields are `Arc` rather than `String`. This allows buffered batch inserts to clone entries cheaply. The `secret` field uses the fixed-size `MessageSecret` array rather than `Vec`. This makes an invalid-length secret unrepresentable, so you no longer need a runtime length check. This is a breaking change if you build `MsgSecretEntry` directly in a custom backend, or if you override the defaulted `put_msg_secret` method. If you construct entries directly, build the JID/ID fields with `Arc::from(...)` or `.into()`, and pass a `[u8; 32]` for `secret`. If you override `put_msg_secret` (most backends don't — see the Note below), update its signature to take `secret: &[u8; 32]` instead of `secret: &[u8]`. The SQLite table itself is unchanged: ```sql theme={null} CREATE TABLE msg_secrets ( chat TEXT NOT NULL, sender TEXT NOT NULL, msg_id TEXT NOT NULL, secret BLOB NOT NULL, device_id INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), expires_at INTEGER NOT NULL DEFAULT 0, -- absolute unix-seconds deadline (0 = never) message_ts INTEGER NOT NULL DEFAULT 0, -- parent message event time (edit-window check) PRIMARY KEY (chat, sender, msg_id, device_id) ); CREATE INDEX idx_msg_secrets_expires ON msg_secrets (device_id, expires_at); ``` A custom backend only needs to implement three methods: `put_msg_secrets`, `get_msg_secret`, and `delete_expired_msg_secrets`. The other two have defaults — `put_msg_secret` delegates to `put_msg_secrets` with `expires_at = 0`, and `get_msg_secret_with_ts` pairs `get_msg_secret` with a `0` timestamp. Override `get_msg_secret_with_ts` only if your store persists `message_ts` and you want the edit-window enforced. ## DeviceStore Trait Handles device data persistence: ```rust theme={null} /// Save device data async fn save(&self, device: &Device) -> Result<()>; /// Load device data async fn load(&self) -> Result>; /// Check if a device exists async fn exists(&self) -> Result; /// Create a new device row and return its generated device_id async fn create(&self) -> Result; /// Create a snapshot of the database state /// Optional: label with name, save extra_content (e.g. failing message) async fn snapshot_db(&self, name: &str, extra_content: Option<&[u8]>) -> Result<()>; ``` #### resource\_report ```rust theme={null} async fn resource_report(&self) -> wacore::stats::StorageResourceReport { wacore::stats::StorageResourceReport::default() } ``` Best-effort process-local memory this backend attributes to the session. Defaulted — most custom backends don't need to implement it. It exists so a backend that can introspect its own memory (like `SqliteStore`'s SQLite page cache, often the single largest per-session chunk since it lives entirely outside the `Client`) can report it for [`Client::resource_report()`](/api/client#resource_report). Remote/store-backed backends (Redis, etc.) should override it to return `memory_bytes: Some(0)` — their data isn't process memory — rather than leaving the default all-`None` ("not reported"), if they want to make that positive claim explicit. As of [whatsapp-rust#1235](https://github.com/oxidezap/whatsapp-rust/pull/1235), `InMemoryBackend` overrides this default rather than inheriting it. Every byte it holds *is* this process's heap, so unlike a file- or network-backed store it reports an exact figure rather than a cap: `memory_bytes` sums each internal map's table allocation plus the heap its keys and values own (deduplicating the `chat`/`sender` strings shared across a conversation's `messageSecret` rows), and `pages` carries the total row count across all of it. Measured against a counting global allocator, the reported total tracks live heap to within about 1%. If you implement a custom in-process backend (not store- or network-backed), overriding `resource_report()` the same way is worth it for the same reason `SqliteStore` does — otherwise `Client::resource_report()` silently under-counts that session. As of [whatsapp-rust#1411](https://github.com/oxidezap/whatsapp-rust/pull/1411), `StorageResourceReport` also carries `free_pages: Option` (pages on the store's free list — SQLite: `freelist_count`) and `wal_bytes: Option` (the write-ahead log's on-disk size). See [`Client::resource_report()`](/api/client#resource_report) for the full field list. #### maintenance ```rust theme={null} async fn maintenance(&self) -> Result<()> { Ok(()) } ``` Periodic engine upkeep the client calls on a coarse timer (roughly hourly) while connected — statistics refresh, log truncation, whatever a backend needs to stay in shape across a session measured in weeks rather than minutes. Defaulted to a no-op, for the same reason as `resource_report`: most custom backends don't need it. It must be cheap enough to run on a live connection and safe to call when nothing has changed; anything that takes an exclusive lock on the whole database (SQLite's `VACUUM`) belongs in an explicit embedder call instead, not here. Added in [whatsapp-rust#1411](https://github.com/oxidezap/whatsapp-rust/pull/1411). `SqliteStore`'s implementation runs `PRAGMA analysis_limit = 400` followed by `PRAGMA optimize` (a no-op unless a table changed materially since the last `ANALYZE`), then an opportunistic `PRAGMA wal_checkpoint(TRUNCATE)` — the only checkpoint mode that returns the `-wal` file's blocks to the filesystem. A `TRUNCATE` checkpoint declines rather than blocks when a reader still holds a snapshot, so a skipped truncate is a normal outcome, not a failure. #### Retention and periodic maintenance As of [whatsapp-rust#1411](https://github.com/oxidezap/whatsapp-rust/pull/1411), the client drives three cadences off the keepalive tick while a connection stays up, rather than relying on reconnects to reach them: * **Retention sweeps** (`delete_expired_sent_messages`, `delete_expired_pending_inbound`, `delete_expired_base_keys`, `delete_expired_msg_secrets`) run in one task, sequentially, on every keepalive tick that already drives the cache sweep. * **Session maintenance** (\~6 hours) — signed pre-key rotation (see [Signed pre-key rotation (RotateKeyJob)](/advanced/signal-protocol#signed-pre-key-rotation-rotatekeyjob)) and tcToken pruning (see [Startup pruning](/api/tctoken#startup-pruning)) — previously ran only from the connect-time background init, so a session held open past their own cadences without a reconnect never reached them. * **Engine maintenance** (\~1 hour) calls `DeviceStore::maintenance()` above. All three are best-effort: a failing sweep or pass logs a warning and is retried on the next tick rather than affecting the connection. ## SqliteStore implementation The default storage implementation using SQLite with Diesel ORM. SQLite is bundled by default — you don't need it installed on your system. ### Bundled SQLite The `whatsapp-rust-sqlite-storage` crate enables the `bundled-sqlite` feature by default, which compiles SQLite from source and statically links it. To use a system-installed SQLite instead: ```toml Cargo.toml theme={null} [dependencies] whatsapp-rust-sqlite-storage = { version = "0.7", default-features = false } ``` ### Creating a store ```rust theme={null} use whatsapp_rust::store::SqliteStore; // Basic usage - creates/opens database at path let store = SqliteStore::new("whatsapp.db").await?; // With device_id for multi-device support let store = SqliteStore::new_for_device("whatsapp.db", 1).await?; // Using sqlite:// URL format let store = SqliteStore::new("sqlite://path/to/db.sqlite").await?; ``` ### Sharing the pool with sibling crates ```rust theme={null} pub fn shared(&self) -> SharedSqlite; ``` `SqliteStore::shared()` returns a clonable `SharedSqlite` handle onto the store's existing r2d2 connection pool and write-serialization semaphore. A sibling crate uses it to run its own queries and migrations against the *same* database file — without opening a second connection pool, which would mean two WAL writers contending for the file lock. An application-level store — a chat/message history store, for example — can use this to attach its own tables to the same `whatsapp.db` file as the device store. ```rust theme={null} pub struct SharedSqlite { /* ... */ } impl SharedSqlite { /// Acquires a semaphore permit and runs `f` on a pooled connection via `spawn_blocking`. pub async fn run(&self, f: F) -> Result where F: FnOnce(&mut SqliteConnection) -> Result + Send + 'static, T: Send + 'static; /// Runs `f` as a read-only, consistent snapshot — a diesel deferred /// transaction that never asks for the write lock. pub async fn read(&self, f: F) -> Result where F: FnOnce(&mut SqliteConnection) -> Result + Send + 'static, T: Send + 'static; } ``` `read` takes a permit from a separate reader pool, sized by [`SqliteStoreConfig::read_pool_size`](/concepts/storage#memory-and-thread-tuning-sqlitestoreconfig), which defaults to `1` connection as of [whatsapp-rust#1401](https://github.com/oxidezap/whatsapp-rust/pull/1401) (it was `0` before). A burst of reads can then run alongside a pending write instead of queueing behind `run`'s write-path permits. If `read_pool_size` is `0` or the underlying connection isn't WAL, `read` falls back to queueing on the same permits as `run` — it's always safe to call. Wrapping the closure in a deferred transaction also means a `read` that issues more than one statement (e.g. resolve a chat's identity keys, then query by them) sees one consistent snapshot across all of them, rather than possibly straddling a write that commits in between. Prefer `read` over `run` for anything that only queries. Most of `SqliteStore`'s own `SignalStore`/`AppSyncStore`/`ProtocolStore`/`DeviceStore` surface does, as of [whatsapp-rust#1222](https://github.com/oxidezap/whatsapp-rust/pull/1222) — session, identity, sender-key, and pre-key lookups among them. A handful of reads are deliberately kept on `run` instead: a stale answer for these would go out on the wire, fail an operation outright, or overwrite a cache unconditionally (app-state sync key lookups, `messageSecret` reads, `get_devices`). ### Sharing the pool with sibling devices ```rust theme={null} pub fn share_for_device(&self, device_id: i32) -> Self; ``` Call `SqliteStore::share_for_device()` to get a new `SqliteStore` for a *sibling device* in the same database file. It clones this store's pool, write-serialization semaphore, and reader pool instead of opening its own. Without it, every constructor builds its own r2d2 pool, so a process holding N sessions against one database file opens N pools. At the default `pool_size` of `1` and `read_pool_size` of `1` (as of [whatsapp-rust#1401](https://github.com/oxidezap/whatsapp-rust/pull/1401); `read_pool_size` defaulted to `0` before), that means 2N connections; a store configured with a larger `pool_size` or `read_pool_size` opens more per session, and sharing removes that entire pool, not just one connection. A connection costs memory before it reads a single row. On the bundled SQLite build, that includes a fixed \~46.9 KiB lookaside slab (`SQLITE_DEFAULT_LOOKASIDE`) — a compile-time setting, so a system-linked or SQLCipher-enabled SQLite (see [Connection init hook](#connection-init-hook)) can size it differently, or not allocate it at all. It also includes a page cache that grows to [`cache_size_kib`](#database-configuration). You can't shrink the lookaside slab with a pragma — `SQLITE_DBCONFIG_LOOKASIDE` is C-API only and diesel doesn't expose it. Since every query already carries a `device_id`, sibling sessions on one database only ever needed that field to differ. ```rust theme={null} use whatsapp_rust_sqlite_storage::SqliteStore; let device_1 = SqliteStore::new_for_device("whatsapp.db", 1).await?; // One pool, two connections (write + read at the defaults), two sessions. let device_2 = device_1.share_for_device(2); ``` The returned store owns clones of the pool handles, so you can keep using it for as long as it lives — dropping the store it came from closes nothing. What it does **not** do: * **Create the device row.** It only stamps queries with `device_id`. Wrap the returned store in [`PersistenceManager::new`](/concepts/storage#initialization) and it provisions the row for you automatically, the same as it would for a store built from `new_for_device` — you only need to provision the row yourself if you use the store directly, outside `PersistenceManager`. * **Isolate writes.** Siblings share the write permits set by [`SqliteStoreConfig::pool_size`](#database-configuration). At the default of `1`, their writes serialize against each other. On a burst where every sibling writes continuously, sharing costs roughly 2.5x the aggregate write throughput of a pool per session. In exchange you get FIFO-fair scheduling across siblings. A private connection per session instead leaves ordering to SQLite's busy-timeout handler, whose retries on each connection aren't coordinated with any other connection's — producing about 2x the spread between the fastest and slowest session. * **Split `resource_report()`.** Siblings share one pool, so every handle reports the same whole-pool estimate. When you sum across a fleet of siblings, count it once per pool, not once per handle. Because of the write-serialization trade, reach for this with **mostly-idle fleets** — sessions that are connected but not writing continuously, which is the common shape — rather than as a default replacement for a store per session. See [Memory and Thread Tuning](/concepts/storage#memory-and-thread-tuning-sqlitestoreconfig) for measured numbers. ### Features * **Connection pooling** - Uses Diesel r2d2 with pool size of 2 * **WAL mode** - Write-Ahead Logging for better concurrency * **Automatic migrations** - Runs embedded migrations on startup * **Semaphore-based locking** - Prevents concurrent writes * **Retry logic** - Automatic retry with exponential backoff for locked database * **Multi-device support** - Single database can store multiple device sessions ### Database Configuration SqliteStore automatically configures connections with: ```sql theme={null} PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 30000; PRAGMA synchronous = NORMAL; PRAGMA cache_size = 512; PRAGMA temp_store = memory; PRAGMA foreign_keys = ON; PRAGMA journal_size_limit = 33554432; -- 32 MiB, as of whatsapp-rust#1411 ``` A WAL grows to the largest single transaction ever committed and, with no limit set, stays that size for the life of the file — an auto-checkpoint only resets the WAL, it never shortens it. The history-sync `msg_secrets` seed is exactly that kind of transaction, so a month-long process would otherwise pay its peak size forever. `journal_size_limit` caps it at 32 MiB, well above any ordinary commit here, so it only ever trims the outlier; combined with the opportunistic `wal_checkpoint(TRUNCATE)` in [`DeviceStore::maintenance()`](/api/store#maintenance), the WAL is brought back under the cap on the \~1-hour engine maintenance cadence. Added in [whatsapp-rust#1411](https://github.com/oxidezap/whatsapp-rust/pull/1411). `SqliteStoreConfig` also exposes an opt-in `mmap_size: Option` field (default `None`, current behavior — no `PRAGMA mmap_size` emitted). Set it with the builder-style `with_mmap_size(bytes)`: ```rust theme={null} use whatsapp_rust_sqlite_storage::{SqliteStore, SqliteStoreConfig}; let config = SqliteStoreConfig::default().with_mmap_size(64 * 1024 * 1024); // 64 MiB let store = SqliteStore::with_config("whatsapp.db", config).await?; ``` `with_mmap_size` only sets the field on the config value — the store applies it when the config is passed to [`SqliteStore::with_config`](#creating-a-store) (or `with_config_for_device`). Building a config and never passing it to one of those constructors leaves `mmap_size` unset, since `SqliteStore::new` / `new_for_device` always use `SqliteStoreConfig::default()`. When set to a non-zero value, this emits `PRAGMA mmap_size = ;`, moving reads of the main database file through a reclaimable, OS-backed memory map instead of the heap page cache — useful for a process holding many small per-session databases, since mapped pages can be reclaimed under memory pressure while heap-cached pages cannot. `0` disables mmap, same as `None`. mmap I/O covers *reads* of the main database file only. In WAL mode (this store's default), writes still go through the WAL, and a checkpoint briefly falls back to non-mmap I/O. `SqliteStore::resource_report()` (see [`DeviceStore::resource_report`](#resource_report)) does not account for `mmap_size` — with mmap enabled, some reads bypass the heap page cache it measures, so the reported estimate can overstate actual process-heap residency for that session. ### Connection init hook `SqliteStoreConfig` also exposes an optional `connection_init: Option` field, set via the builder-style `with_connection_init(hook)`. The hook runs first in r2d2's `on_acquire` customizer on every pooled connection — before the store's own pragmas, and (because WAL setup and migrations also run on a pooled connection) before those too: ```rust theme={null} pub type ConnectionInitHook = Arc< dyn Fn(&mut SqliteConnection) -> std::result::Result<(), Box> + Send + Sync, >; ``` The canonical use is SQLCipher-style keying, where `PRAGMA key` must be the first statement executed on a fresh connection, ideally followed by a verification query: ```rust theme={null} use whatsapp_rust_sqlite_storage::{SqliteStore, SqliteStoreConfig}; use diesel::prelude::*; let config = SqliteStoreConfig::default().with_connection_init(move |conn| { diesel::sql_query("PRAGMA key = 'my-passphrase';").execute(conn)?; // Verify the key: this fails on a wrongly-keyed database. diesel::sql_query("SELECT count(*) FROM sqlite_master;").execute(conn)?; Ok(()) }); let store = SqliteStore::with_config("whatsapp.db", config).await?; ``` Linking a SQLCipher-enabled SQLite is the caller's responsibility: disable this crate's default `bundled-sqlite` feature and depend on `libsqlite3-sys` with a SQLCipher build (e.g. its `bundled-sqlcipher` feature) instead. The crate itself gains no SQLCipher, key-type, or zeroization coupling — the hook is a generic per-connection seam that equally serves loading extensions or custom per-connection pragmas. If the hook returns `Err`, the connection is rejected — this surfaces as a pool/build error at store construction (e.g. `SqliteStore::with_config` fails outright) rather than on a later query, which matters for a wrong-key error: it should fail fast, not after migrations already ran against an unreadable database. The hook must be idempotent per connection and cheap: r2d2 calls it once for every connection it opens, including replacements after errors. `VACUUM INTO` snapshots run on a pooled (already-keyed) connection, so backups of an encrypted database stay encrypted with no extra work. ### Usage Example ```rust theme={null} use whatsapp_rust_sqlite_storage::SqliteStore; use whatsapp_rust::Bot; use std::sync::Arc; #[tokio::main] async fn main() -> Result<(), Box> { // Create store let backend = Arc::new(SqliteStore::new("whatsapp.db").await?); // Use with Bot builder let mut bot = Bot::builder() .with_backend(backend.clone()) .with_transport_factory(transport_factory) .with_http_client(http_client) .with_runtime(TokioRuntime) .on_event(|event, client| async move { /* handle events */ }) .build() .await?; // Store implements all traits // SignalStore backend.put_identity("address@s.whatsapp.net", [0u8; 32]).await?; let identity = backend.load_identity("address@s.whatsapp.net").await?; // AppSyncStore let version = backend.get_version("regular").await?; // ProtocolStore let devices = backend.get_sender_key_devices("group@g.us").await?; // DeviceStore if backend.exists().await? { let device = backend.load().await?; } Ok(()) } ``` ## CacheStore Trait The `CacheStore` trait enables pluggable cache backends for the client's data caches. By default, caches use the in-process `PortableCache`; implementing this trait lets you use Redis, Memcached, or any other external cache. **Location:** `wacore/src/store/cache.rs` ```rust theme={null} #[async_trait] pub trait CacheStore: Send + Sync + 'static { /// Retrieve a cached value by namespace and key. async fn get(&self, namespace: &str, key: &str) -> anyhow::Result>>; /// Store a value with an optional TTL. /// When `ttl` is `None`, the entry persists until explicitly deleted. async fn set( &self, namespace: &str, key: &str, value: &[u8], ttl: Option, ) -> anyhow::Result<()>; /// Delete a single key from the given namespace. async fn delete(&self, namespace: &str, key: &str) -> anyhow::Result<()>; /// Delete all keys in a namespace. async fn clear(&self, namespace: &str) -> anyhow::Result<()>; /// Approximate entry count (diagnostics only). Default returns 0. async fn entry_count(&self, _namespace: &str) -> anyhow::Result { Ok(0) } } ``` ### Namespaces Each logical cache uses a unique namespace string. Implementations should partition keys by namespace (e.g., prefix as `{namespace}:{key}` in Redis). | Namespace | Cache | Description | | ------------------- | ----------------------- | ----------------------------------- | | `"group"` | `group_cache` | Group metadata | | `"device_registry"` | `device_registry_cache` | Device registry entries | | `"lid_pn_by_lid"` | `lid_pn_cache` | LID-to-phone bidirectional mappings | ### Error handling Cache operations are best-effort. The client treats read failures as cache misses and logs warnings on write failures. Implementations should still return errors for observability. ### CacheStores configuration ```rust theme={null} pub struct CacheStores { pub group_cache: Option>, pub device_registry_cache: Option>, pub lid_pn_cache: Option>, } ``` Set individual caches or use `CacheStores::all(store)` to route all pluggable caches to the same backend: ```rust theme={null} use whatsapp_rust::{CacheConfig, CacheStores}; let redis = Arc::new(MyRedisCacheStore::new("redis://localhost:6379")); let config = CacheConfig { cache_stores: CacheStores::all(redis), ..Default::default() }; ``` See [Custom backends — cache store](/guides/custom-backends#custom-cache-store) for a full implementation example. ## TypedCache `TypedCache` is a generic wrapper that dispatches to either the in-process `PortableCache` or a custom `CacheStore` backend. **Location:** `src/cache_store.rs` | Method | Signature | Description | | ------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `from_local` | `fn from_local(cache: Cache) -> Self` | Wrap an in-process `PortableCache` (zero overhead) | | `from_store` | `fn from_store(store: Arc, namespace: &'static str, ttl: Option) -> Self` | Create a cache backed by a custom store | | `get` | `async fn get(&self, key: &Q) -> Option` | Look up a value. Misses and deserialization failures return `None` | | `insert` | `async fn insert(&self, key: K, value: V)` | Insert or update a value | | `invalidate` | `async fn invalidate(&self, key: &Q)` | Remove a single key | | `invalidate_all` | `fn invalidate_all(&self)` | Remove all entries (sync). Best-effort for in-process backend; requires `tokio-runtime` for custom backends | | `clear` | `async fn clear(&self)` | Remove all entries (async). Awaits the write lock for in-process backend; awaits completion for custom backends | | `run_pending_tasks` | `async fn run_pending_tasks(&self)` | Evict expired entries (in-process backend only; no-op for custom backends) | | `entry_count` | `fn entry_count(&self) -> u64` | Approximate entry count (sync). Returns `0` for custom backends | | `entry_count_async` | `async fn entry_count_async(&self) -> u64` | Approximate entry count, delegating to custom backend if available | `invalidate_all()` on custom `CacheStore` backends requires the `tokio-runtime` feature. Without it, the clear is silently skipped. Use the async `clear()` method as an alternative. ## Implementing custom storage To implement a custom storage backend: 1. Implement all four domain traits 2. The `Backend` trait is automatically implemented 3. All methods must be `async` and thread-safe (`Send + Sync`) ### Example: Redis store ```rust theme={null} use async_trait::async_trait; use redis::aio::ConnectionManager; use wacore::store::traits::*; use wacore::store::error::Result; pub struct RedisStore { client: ConnectionManager, device_id: i32, } impl RedisStore { pub async fn new(redis_url: &str) -> Result { let client = redis::Client::open(redis_url) .map_err(|e| StoreError::Connection(Box::new(e)))?; let conn = client.get_connection_manager().await .map_err(|e| StoreError::Connection(Box::new(e)))?; Ok(Self { client: conn, device_id: 1, }) } } #[async_trait] impl SignalStore for RedisStore { async fn put_identity(&self, address: &str, key: [u8; 32]) -> Result<()> { let mut conn = self.client.clone(); let key_name = format!("identity:{}:{}", self.device_id, address); redis::cmd("SET") .arg(key_name) .arg(&key[..]) .query_async(&mut conn) .await .map_err(|e| StoreError::Database(Box::new(e)))?; Ok(()) } async fn load_identity(&self, address: &str) -> Result>> { let mut conn = self.client.clone(); let key_name = format!("identity:{}:{}", self.device_id, address); let result: Option> = redis::cmd("GET") .arg(key_name) .query_async(&mut conn) .await .map_err(|e| StoreError::Database(Box::new(e)))?; Ok(result) } // Implement remaining SignalStore methods... } #[async_trait] impl AppSyncStore for RedisStore { // Implement all AppSyncStore methods... } #[async_trait] impl ProtocolStore for RedisStore { // Implement all ProtocolStore methods... } #[async_trait] impl DeviceStore for RedisStore { // Implement all DeviceStore methods... } // Backend is automatically implemented! ``` ### Best Practices 1. **Thread Safety** - Use `Arc` for shared state, `Mutex` for mutable state 2. **Error Handling** - Convert backend errors to `StoreError` variants 3. **Transactions** - Use database transactions for atomic operations 4. **Retries** - Implement retry logic for transient failures 5. **Connection Pooling** - Reuse connections when possible 6. **Blocking Operations** - Wrap blocking I/O in `tokio::task::spawn_blocking` ## Data Structures ### AppStateSyncKey ```rust theme={null} pub struct AppStateSyncKey { pub key_data: Vec, pub fingerprint: Vec, pub timestamp: i64, } ``` ### LidPnMappingEntry ```rust theme={null} pub struct LidPnMappingEntry { pub lid: String, // LID user part pub phone_number: String, // Phone number user part pub created_at: i64, // Unix timestamp pub updated_at: i64, // Unix timestamp pub learning_source: String, // e.g. "usync", "peer_pn_message" } ``` ### TcTokenEntry ```rust theme={null} pub struct TcTokenEntry { pub token: Vec, // Raw token bytes pub token_timestamp: i64, // When token was received pub sender_timestamp: Option, // When we sent our token } ``` An entry with an empty `token` and only `sender_timestamp` set is a byte-less placeholder written by `touch_tc_token_sender_timestamp` — it records that a post-send issuance IQ succeeded before any real token had been received from the contact. ### DeviceListRecord ```rust theme={null} pub struct DeviceListRecord { pub user: String, // User part of JID pub devices: Vec, // Known devices pub timestamp: i64, // Last update timestamp pub phash: Option, // Participant hash from usync pub raw_id: Option, // ADV raw_id for identity change detection } pub struct DeviceInfo { pub device_id: u32, // 0 = primary, 1+ = companions pub key_index: Option, // Key index if known pub is_hosted: bool, // Hosted PN/LID address space (see below) } impl DeviceInfo { pub const fn new(device_id: u32, key_index: Option) -> Self pub const fn with_hosting(mut self, is_hosted: bool) -> Self } ``` The `raw_id` field stores the ADV (Account Device Verification) key index list `raw_id` from device notifications. When this value changes for a user, it indicates an identity change (e.g., the user reinstalled WhatsApp). The client uses this to detect identity changes and clear Signal sessions for that user's non-primary devices. Per-device sender key tracking is **not** wiped globally on identity change — that would empty the tracker too aggressively and feed the no-distribution path on the next group send. SKDM redistribution is instead driven per-group/per-device by retry receipts (matching WhatsApp Web's `WAWebUpdateLocalSignalSession`/`markForgetSenderKey` behavior). **Breaking change:** `DeviceInfo` gained the `is_hosted` field (marks whether the device belongs to WhatsApp's hosted PN/LID address space, populated from usync device-list results). This breaks both construction and exhaustive pattern matching. Struct-literal construction (`DeviceInfo { device_id, key_index }`) no longer compiles — use `DeviceInfo::new(device_id, key_index).with_hosting(is_hosted)` instead. An exhaustive destructuring pattern (`let DeviceInfo { device_id, key_index } = info;`) also no longer compiles — add a `..` to the pattern or match on `is_hosted` as well. Persisted JSON without `is_hosted` still deserializes correctly (it defaults to `false`); only Rust construction and pattern-matching call sites are affected. See [USync](/api/usync#hosted-addressing) for how `is_hosted` is used with `Jid::with_device_hosting`. ## Error Handling All storage operations return `Result` from `wacore::store::error`. Each variant preserves the underlying typed error as its `source()` so callers can downcast to the original backend error when needed: ```rust theme={null} pub enum StoreError { Io(#[from] std::io::Error), Serialization(#[source] Box), Validation(String), Connection(#[source] Box), Database(#[source] Box), RetriesExhausted { op: String }, Migration(#[source] Box), InvalidConfig(String), DeviceNotFound(i32), } pub type Result = std::result::Result; ``` `StoreError` exposes a helper `is_database_busy_or_locked()` that walks the source chain looking for SQLite `BUSY`/`LOCKED` markers. Retry layers use it to decide whether a database error is transient without depending on a specific backend crate. ## See also * [Transport Trait](/api/transport) - Network transport abstraction * [HTTP Client Trait](/api/http-client) - HTTP client abstraction * [Client API](/api/client) - Main client interface # TC Token Source: https://whatsapp-rust.jlucaso.com/api/tctoken Manage trusted contact privacy tokens and cstoken fallback The `TcToken` feature provides APIs for issuing and managing trusted contact privacy tokens (TC tokens). These tokens are used for privacy-gated operations like sending messages and fetching profile pictures. The library also supports **cstoken** (client-side token / NCT) as a fallback when no TC token exists for a recipient. This matches WhatsApp Web's `MsgCreateFanoutStanza.js` behavior. Token timing (bucket duration and count) is configurable via server-side AB props, with sensible defaults matching WhatsApp Web. ## Access Access TC token operations through the client: ```rust theme={null} let tc_token = client.tc_token(); ``` ## Methods ### issue\_tokens Issue privacy tokens for specified contacts. ```rust theme={null} pub async fn issue_tokens(&self, jids: &[Jid]) -> Result, TcTokenError> ``` **Parameters:** * `jids` - Array of JIDs (should be LID JIDs) to issue tokens for **Returns:** * `Vec` - List of received tokens **Example:** ```rust theme={null} let jids = vec![ "100000000000001@lid".parse()?, "100000000000002@lid".parse()?, ]; let tokens = client.tc_token().issue_tokens(&jids).await?; for token in &tokens { println!("Token for {}: {} bytes", token.jid, token.token.len()); println!("Timestamp: {}", token.timestamp); } ``` Issued tokens are automatically stored in the backend and used for subsequent operations like message sending and profile picture fetching. Tokens are also automatically issued after sending a message when the current bucket has expired (see [automatic usage](#automatic-usage)). ### prune\_expired Remove expired tokens from storage. ```rust theme={null} pub async fn prune_expired(&self) -> Result ``` **Returns:** * Number of tokens deleted **Example:** ```rust theme={null} let deleted = client.tc_token().prune_expired().await?; println!("Pruned {} expired tokens", deleted); ``` By default, tokens expire after 28 days (4 buckets × 7 days). The server may override this via AB props. Expired tokens are also automatically pruned on connect and, as of [whatsapp-rust#1411](https://github.com/oxidezap/whatsapp-rust/pull/1411), on a \~6-hour keepalive tick for the rest of the connection's lifetime. Call this method if you need to trigger pruning manually. The received token and the sender-side issuance bucket expire on independent cutoffs — a row is only pruned once **both** are stale, so recent sender-side rate-limit state survives an expired received token (and vice versa). ### get Get a stored token for a specific JID. ```rust theme={null} pub async fn get(&self, jid: &str) -> Result, TcTokenError> ``` **Parameters:** * `jid` - User portion of the JID (without domain) **Returns:** * `Option` - The stored token entry, if found **Example:** ```rust theme={null} if let Some(entry) = client.tc_token().get("100000000000001").await? { println!("Token timestamp: {}", entry.token_timestamp); println!("Token size: {} bytes", entry.token.len()); if let Some(sender_ts) = entry.sender_timestamp { println!("Issued at: {}", sender_ts); } } ``` ### get\_all\_jids Get all JIDs that have stored tokens. ```rust theme={null} pub async fn get_all_jids(&self) -> Result, TcTokenError> ``` **Returns:** * List of JID user portions with stored tokens **Example:** ```rust theme={null} let jids = client.tc_token().get_all_jids().await?; println!("Tokens stored for {} contacts", jids.len()); for jid in jids { println!(" - {}", jid); } ``` ## Error handling Each method returns `Result`: ```rust theme={null} #[non_exhaustive] pub enum TcTokenError { #[error("{0}")] Iq(#[from] IqError), #[error("{0}")] Store(#[from] StoreError), } ``` **Variants:** * `Iq` — the server token request failed (timeout, server error, etc.) * `Store` — token persistence failed **Example:** ```rust theme={null} use whatsapp_rust::TcTokenError; let jids = vec!["100000000000001@lid".parse()?]; match client.tc_token().issue_tokens(&jids).await { Ok(tokens) => println!("Got {} tokens", tokens.len()), Err(TcTokenError::Iq(e)) => eprintln!("Server request failed: {e}"), Err(TcTokenError::Store(e)) => eprintln!("Storage error: {e}"), Err(e) => eprintln!("Error: {e}"), } ``` ## Types ### ReceivedTcToken Token received from the server. ```rust theme={null} pub struct ReceivedTcToken { /// JID the token is for pub jid: Jid, /// Binary token data pub token: Vec, /// Server timestamp pub timestamp: i64, } ``` ### TcTokenEntry Stored token entry. ```rust theme={null} pub struct TcTokenEntry { /// Binary token data pub token: Vec, /// Token timestamp from server pub token_timestamp: i64, /// Timestamp when we issued/received this token pub sender_timestamp: Option, } ``` An entry with an empty `token` is a **byte-less placeholder** — it records that a post-send issuance IQ succeeded before any real token was received from the contact. For a fresh placeholder, `token_timestamp` is set equal to `sender_timestamp` (there is no received epoch yet); since `token` is empty, the received-token side of the prune cutoff always treats the row as expired-or-absent regardless of that value, so pruning a placeholder depends only on whether `sender_timestamp` is still live. See [Post-send issuance](#post-send-issuance). ### TcTokenConfig Runtime-configurable timing for token expiration, sourced from server AB props. ```rust theme={null} pub struct TcTokenConfig { /// Receiver-side bucket duration in seconds (default: 604800 = 7 days) pub bucket_duration: i64, /// Number of receiver-side buckets (default: 4) pub num_buckets: i64, /// Sender-side bucket duration in seconds (default: 604800 = 7 days) pub sender_bucket_duration: i64, /// Number of sender-side buckets (default: 4) pub sender_num_buckets: i64, } ``` The client builds this config from AB props at runtime, falling back to defaults. All values are clamped to safe ranges (durations between 1 and 180 days, counts ≥ 1). ## Automatic usage The library automatically includes privacy tokens in outgoing 1:1 message stanzas using a fallback chain that matches WhatsApp Web: 1. **tctoken** — used when `PRIVACY_TOKEN_ON_ALL_1_ON_1_MESSAGES` is on and a stored, unexpired token exists for the recipient 2. **cstoken** — an independent HMAC-SHA256 fallback using the NCT salt and recipient LID, used when `NCT_TOKEN_SEND_ENABLED` is on and the tctoken above didn't win (its prop is off, or no valid token is stored) — see [AB prop gating](#ab-prop-gating) for the full priority logic 3. **No token** — send without a token if neither is available (the server may return a 463 error) ```rust theme={null} // TC tokens are automatically included when sending messages client.send_message(jid, message).await?; // And when fetching profile pictures let picture = client.contacts().get_profile_picture(&jid, true).await?; ``` The tctoken (no cstoken fallback) is also attached automatically at three other call sites that use privacy tokens, matching WA Web's `USyncStatusProtocol`/`OutSpamTCTokenMixin`/`StartCall.js`: * **`get_user_info`** — a per-recipient tctoken is attached to each queried `` node in the usync IQ, so status/about for a privacy-restricted contact resolves instead of coming back hidden. Gated behind `profile_scraping_privacy_token_in_about_usync`. * **`send_spam_report`** — when `SpamReportRequest::from_jid` is set, that contact's tctoken is attached to the spam report IQ so the report is accepted for a privacy-restricted account. Gated behind `enable_spam_report_iq_with_privacy_token`. Group reports that only set `group_jid`/`participant_jid` (no `from_jid`) don't get a token attached. * **Outgoing 1:1 call offers** (`voip` feature) — placing a call attaches the callee's stored, unexpired tctoken as the offer's leading `` node, and issues a fresh token to the callee in the background after the offer sends. This mirrors WA Web's `sendTcToken` in `StartCall.js` and prevents 463 nacks on later offers to a privacy-restricted contact. Unlike the other paths, it isn't gated by any AB prop — issuance is rate-limited only by the same sender bucket that governs message reissuance (see [Post-send issuance](#post-send-issuance)). Group-call initiation isn't implemented yet. ```rust theme={null} // Attaches the queried contact's tctoken automatically when the AB prop is on let info = client.contacts().get_user_info(&[jid]).await?; // Attaches the reported contact's tctoken automatically when the AB prop is on let result = client.send_spam_report(request).await?; // Attaches the callee's tctoken automatically, then issues a fresh one after the offer sends let handle = client.voip().call(&peer).audio(mic_source, speaker_sink).start().await?; ``` ### AB prop gating Token inclusion on message stanzas is gated by two **independent** server-side AB props, matching WhatsApp Web's `MsgCreateFanoutStanza.js` (`Re = R(te) ?? D(te, s)`): * **`PRIVACY_TOKEN_ON_ALL_1_ON_1_MESSAGES`** — gates the tctoken only (`R`). When off, a stored, valid tctoken is never attached. * **`NCT_TOKEN_SEND_ENABLED`** — gates the cstoken fallback independently (`D`). The cstoken is attached whenever this flag is on and the NCT salt/recipient LID are available — **even when the tctoken prop above is off, or a valid tctoken exists but its prop is disabled**. The cstoken is not nested behind the tctoken prop. Token *issuance scheduling* (requesting new tokens from the server after sending) runs regardless of these flags. The usync and spam-report attachment points each have their own independent gating prop (`profile_scraping_privacy_token_in_about_usync`, `enable_spam_report_iq_with_privacy_token`) — unrelated to the message-stanza props above, and with no cstoken fallback. Outgoing call offers (`voip` feature) attach and issue tokens **unconditionally** — there is no AB prop gate for this path at all, matching WA Web's `StartCall.js`. ### Post-send issuance After sending a 1:1 message, the library checks whether a new token should be issued for the recipient. If the sender-side bucket has rolled over since the last issuance, a background IQ request fires. On IQ success, the sender-side issuance timestamp is recorded **unconditionally**, matching WhatsApp Web's `sendTcToken` in `MsgJob.js`, which persists `tcTokenSenderTimestamp` on success regardless of the response body — the real `set privacy` IQ response carries no token bytes, so the timestamp can't be derived from an echoed token. If no entry exists yet for the recipient, this creates a byte-less placeholder that carries only the sender timestamp. The placeholder is superseded by the contact's first real token (see [Incoming token notifications](#incoming-token-notifications)), regardless of the real token's own timestamp. ### Identity change reissuance When a contact reinstalls WhatsApp and their identity key changes, the library automatically re-issues TC tokens so the contact retains a valid privacy token. This matches WhatsApp Web's `sendTcTokenWhenDeviceIdentityChange` behavior. The reissuance is triggered when an `UntrustedIdentity` error is encountered during message decryption **from the sender's primary device (device 0)** — an identity change on a companion device does not trigger reissuance, matching WA Web. After handling the identity change (clearing the old identity and retrying decryption), the client spawns a background task that: 1. Checks if a token was previously issued to the sender (via `sender_timestamp`) 2. Verifies the token hasn't expired on the sender side 3. Re-issues the token using the original issuance timestamp to preserve the bucket window This ensures that privacy-gated operations (like profile picture fetching) continue to work after a contact reinstalls, without requiring manual token management. Token reissuance is skipped for bot JIDs, status broadcast senders, and identity changes reported for non-primary (companion) devices. The operation is deduplicated via session locks to prevent concurrent reissuance for the same sender. ### Incoming token notifications When a contact sends you a privacy token, the library handles it automatically: 1. Parses the `` stanza 2. Resolves the sender to a LID for storage (using `sender_lid` attribute or LID-PN cache) 3. Applies a timestamp monotonicity pre-filter — if the incoming token is older than a stored *real* token, storage and the presence re-subscribe are both skipped 4. Stores the token in the backend, preserving any `sender_timestamp` already recorded by the post-send issuance path 5. Re-subscribes presence for the sender to pick up the updated token The same newer-wins rule (older writes rejected, a byte-less placeholder always accepts the first real token) is also enforced **atomically inside the store itself** — see [`store_received_tc_token`](/api/store#tctoken-storage). This is what closes the cross-source race between this notification path and history-sync's tc-token candidates: both call the same store method, so whichever call lands last can never clobber a fresher token, without needing a lock shared across the two paths. A byte-less placeholder (written by post-send issuance before any real token has been received) is always replaced by the contact's first real token, even if the placeholder's own timestamp is newer — the newer-wins rule only blocks a stale write once a real token is on record. ### LID discovery migration Tokens can arrive keyed by phone number before a contact's LID is known — for example, a token received while the peer was still PN-only. `resolve_tc_token_key` keys token lookups: it returns the LID as soon as a PN↔LID mapping exists for that contact, and the PN before that. So the moment the client learns a mapping, a token still filed under the PN becomes unreachable through this lookup — the send path then behaves as if no token had ever arrived, even though one is stored. To prevent that, the client migrates a PN-keyed token to the LID key as part of LID discovery, alongside the device-registry and Signal-session re-keying that already happens there (see [LID-PN mappings](/concepts/storage#lid-pn-mappings)): 1. Read any token stored under the phone number 2. If found, write it under the LID — **newer-wins**, so a token already stored under the LID (e.g. from a message that arrived after the mapping was learned) is never regressed by the older PN-keyed one 3. Only after that write succeeds, delete the PN-keyed row The newer-wins guarantee in step 2 relies on [`store_received_tc_token`](/api/store#tctoken-storage) being atomic. The built-in backends guarantee this: `SqliteStore` performs the check-and-write inside an `IMMEDIATE` transaction, and `InMemoryBackend` performs it under its state lock. A custom [`Store`](/api/store) backend that keeps the default read-modify-write implementation of that method is not protected: a concurrently-arriving LID-keyed token can land between the migration's read and write, and the migration's write would then clobber it. The sender-side issuance timestamp isn't carried over in the migration. Normally this just means one extra post-send issuance later — but the [identity-change reissuance](#identity-change-reissuance) flow also reads `sender_timestamp` to decide whether a token was ever issued to the sender. If a contact's identity changes before the next send re-populates the timestamp, that flow sees no prior issuance and skips reissuing, rather than the token being lost outright ([#1345](https://github.com/oxidezap/whatsapp-rust/pull/1345)). ### Startup pruning Expired tokens are automatically pruned when the client connects, matching WhatsApp Web's `PrivacyTokenJob`. As of [whatsapp-rust#1411](https://github.com/oxidezap/whatsapp-rust/pull/1411), pruning also runs on a \~6-hour keepalive maintenance tick for the rest of a connection's lifetime — before this, the connect-time call was the only trigger, so a session that paired once and stayed connected for weeks without reconnecting only ever pruned once, at hour zero. ### Skipped recipients Tokens are not sent to: * Your own JID * Bot JIDs * Status broadcast You typically don't need to manage tokens manually unless you are: * Pre-issuing tokens for a batch of contacts * Debugging token-related issues ## cstoken (NCT fallback) When no valid TC token exists for a recipient, the library falls back to a **cstoken** (client-side token). This is computed using an NCT salt that is provisioned by the server via app state sync. ### How it works 1. The server provides an NCT salt through the `nct_salt_sync` app state mutation (or via history sync during initial pairing) 2. The salt is stored in the `Device` struct as `nct_salt` 3. When sending a message where the tctoken doesn't win — either `PRIVACY_TOKEN_ON_ALL_1_ON_1_MESSAGES` is off, or no valid TC token is stored — and `NCT_TOKEN_SEND_ENABLED` is on, the library computes `HMAC-SHA256(nct_salt, recipient_lid)` and includes it as a `` stanza child (see [AB prop gating](#ab-prop-gating) for the full priority logic) ### Wire format ```xml theme={null} ``` ### NCT salt provisioning The NCT salt is delivered through two channels: * **App state sync** -- The `nct_salt_sync` mutation in the `RegularHigh` syncd collection sets or removes the salt. This is the authoritative source. * **History sync** -- During initial pairing, the salt may be included in the history sync payload. This is a backfill-only source and won't overwrite a salt already set via app state sync. The cstoken fallback is fully automatic. You don't need to manage the NCT salt or compute tokens manually. The library handles salt storage, LID resolution, and token computation transparently during message sending. ## Token lifecycle ```mermaid theme={null} sequenceDiagram participant App participant Client participant Backend participant Server Note over Client,Server: On connect: prune expired tokens Client->>Backend: delete_expired_tc_tokens(token_cutoff, sender_cutoff) Note over App,Server: Manual issuance App->>Client: issue_tokens([jid1, jid2]) Client->>Server: IQ set (privacy namespace) Server-->>Client: Tokens with timestamps Client->>Backend: Store tokens Client-->>App: Vec Note over Client,Server: Sending a message (automatic) App->>Client: send_message(jid1, msg) Client->>Backend: Get tc_token for jid1 alt tctoken prop on AND valid TC token stored Backend-->>Client: TcTokenEntry Client->>Server: Message with else NCT_TOKEN_SEND_ENABLED on, salt/LID available Backend-->>Client: None (or gated/expired) Client->>Client: HMAC-SHA256(nct_salt, recipient_lid) Client->>Server: Message with else Neither condition met Backend-->>Client: None Client->>Server: Message without token end Server-->>Client: Ack Client-->>App: message_id Note over Client,Server: Post-send issuance (if bucket rolled over) Client->>Server: IQ set (issue new token) Server-->>Client: IQ success (no token bytes in the response) Client->>Backend: touch_tc_token_sender_timestamp (advance sender bucket) Note over Client,Server: Incoming token notification Server->>Client: notification type="privacy_token" Client->>Client: Resolve sender LID Client->>Backend: store_received_tc_token (atomic newer-wins, preserves sender_timestamp) Client->>Server: Re-subscribe presence Note over App,Server: Outgoing 1:1 call offer (voip feature, unconditional — no AB prop gate) App->>Client: voip().call(&peer).audio(...).start() Client->>Backend: Get tc_token for peer Backend-->>Client: TcTokenEntry or None Client->>Server: Call offer, tctoken as leading child if stored Server-->>Client: Offer ack Client-->>App: CallHandle Client->>Server: IQ set (issue fresh token to callee, if sender bucket rolled over) Server-->>Client: IQ success Client->>Backend: touch_tc_token_sender_timestamp ``` ## Expiration TC token expiration uses a bucket-aligned system. By default, tokens expire after 28 days (4 buckets × 7-day duration). The server can override these defaults via AB props: | AB prop | Default | Description | | ---------------------------- | --------------- | ---------------------------------------- | | `tctoken_duration` | 604800 (7 days) | Receiver-side bucket duration in seconds | | `tctoken_num_buckets` | 4 | Number of receiver-side buckets | | `tctoken_duration_sender` | 604800 (7 days) | Sender-side bucket duration in seconds | | `tctoken_num_buckets_sender` | 4 | Number of sender-side buckets | Bucket durations are capped at 180 days. The expiration cutoff is bucket-aligned — it always falls on a bucket boundary, matching WhatsApp Web's `tokenExpirationCutoff` logic. Pruning evaluates the received-token cutoff and the sender-bucket cutoff independently: a row is only removed once its received token is expired-or-absent **and** its sender bucket is expired-or-absent, so recent sender-side rate-limit state is never dropped just because the received token expired (and vice versa). ```rust theme={null} use wacore::iq::tctoken::{tc_token_expiration_cutoff, is_tc_token_expired}; // Get the cutoff timestamp for expired received tokens (using defaults) let cutoff = tc_token_expiration_cutoff(); println!("Tokens before {} are expired", cutoff); // Check a specific token let expired = is_tc_token_expired(some_timestamp); // Prune expired tokens — checks both the received-token and sender-bucket // cutoffs (AB-prop-aware config); a row survives if either is still live let deleted = client.tc_token().prune_expired().await?; ``` ## Best practices 1. **Pre-issue tokens** for contacts you frequently interact with 2. **Don't over-issue** — tokens are automatically issued after each message send when the bucket rolls over 3. **Use LID JIDs** when issuing tokens manually 4. **Let automatic pruning handle cleanup** — expired tokens are pruned on connect and on a periodic keepalive tick while connected, so manual pruning is rarely necessary ```rust theme={null} // Pre-issue tokens for frequent contacts async fn initialize_tokens(client: &Client, contacts: &[Jid]) -> anyhow::Result<()> { // Filter to LID JIDs only let lid_jids: Vec<_> = contacts.iter() .filter(|j| j.is_lid()) .cloned() .collect(); if !lid_jids.is_empty() { client.tc_token().issue_tokens(&lid_jids).await?; } Ok(()) } ``` # Transport Trait Source: https://whatsapp-rust.jlucaso.com/api/transport Network transport abstraction and WebSocket implementation ## Overview The transport layer provides a runtime-agnostic abstraction for network connections. It handles raw byte transmission without knowledge of WhatsApp's protocol framing. The transport system consists of two main traits: * **Transport** - Represents an active connection for sending/receiving raw bytes * **TransportFactory** - Creates new transport instances and event streams ## Transport Trait The `Transport` trait represents an active network connection as a simple byte pipe. ```rust theme={null} use async_trait::async_trait; use wacore::sync_marker::MaybeSendSync; #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait Transport: MaybeSendSync { /// Sends raw data to the server async fn send(&self, data: Vec) -> Result<(), anyhow::Error>; /// Closes the connection async fn disconnect(&self); } ``` `MaybeSendSync` is `Send + Sync` on native targets and carries no bounds on `wasm32`. This means `Arc` remains thread-safe on native, while implementations backed by `!Send` JS handles (e.g. a browser `WebSocket`) compile on the wasm port without wrapping. ### Methods #### send Sends raw bytes through the transport. The caller is responsible for any protocol framing. ```rust theme={null} async fn send(&self, data: Vec) -> Result<(), anyhow::Error>; ``` **Parameters:** * `data` - Raw bytes to send **Returns:** * `Ok(())` on success * `Err(anyhow::Error)` on failure **Example:** ```rust theme={null} let data = vec![1, 2, 3, 4]; transport.send(data).await?; ``` #### disconnect Gracefully closes the connection. ```rust theme={null} async fn disconnect(&self); ``` **Example:** ```rust theme={null} transport.disconnect().await; ``` #### resource\_report Best-effort per-session footprint of this transport: read/write framing buffers plus a TLS/noise session-state estimate. Defaulted to `None` ("not reported") — a transport that can introspect its buffers overrides it. ```rust theme={null} fn resource_report(&self) -> Option { None } ``` **Returns:** * `Some(TransportResourceReport)` with any subset of `read_buffer_bytes`, `write_buffer_bytes`, `tls_state_bytes` filled in (each `Option`) * `None` (default) if the transport doesn't report Feeds into [`Client::resource_report()`](/api/client#resource_report). The bundled Tokio WebSocket transport (below) overrides this with static estimates, since `tokio-websockets` and `rustls` don't expose their live buffer sizes. ## TransportFactory Trait Creates new transport instances and associated event streams. ```rust theme={null} #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait TransportFactory: MaybeSendSync { /// Creates a new transport and returns it along with a stream of events async fn create_transport( &self, ) -> Result<(Arc, async_channel::Receiver), anyhow::Error>; } ``` Like `Transport`, `TransportFactory` uses `MaybeSendSync` so that factory implementations holding `!Send` JS state compile on `wasm32`. On native the bound is `Send + Sync` as before. ### Methods #### create\_transport Establishes a new connection and returns both the transport handle and an event receiver. ```rust theme={null} async fn create_transport( &self, ) -> Result<(Arc, async_channel::Receiver), anyhow::Error>; ``` **Returns:** * `Arc` - The transport instance for sending data * `async_channel::Receiver` - Stream of transport events **Example:** ```rust theme={null} let factory = TokioWebSocketTransportFactory::new(); let (transport, events) = factory.create_transport().await?; // Use transport to send data transport.send(data).await?; // Listen for events while let Ok(event) = events.recv().await { match event { TransportEvent::Connected => println!("Connected"), TransportEvent::DataReceived(bytes) => println!("Received {} bytes", bytes.len()), TransportEvent::Disconnected(reason) => { println!("Disconnected: {reason}"); break; } } } ``` ## TransportEvent Events produced by the transport layer: ```rust theme={null} pub enum TransportEvent { /// The transport has successfully connected Connected, /// Raw data has been received from the server DataReceived(Bytes), /// The connection was lost, carrying why it closed Disconnected(DisconnectReason), } ``` Since v0.6 `Disconnected` carries a `DisconnectReason` instead of being a unit variant (a **breaking change** for custom transports — update your `match` arms and the value you emit). It surfaces *why* the socket closed in logs, so server-initiated closes can be told apart from local shutdowns. ### Event Types #### Connected Emitted immediately after successful connection establishment. ```rust theme={null} TransportEvent::Connected ``` #### DataReceived Emitted when raw data is received from the server. ```rust theme={null} TransportEvent::DataReceived(bytes) ``` **Fields:** * `bytes: Bytes` - Raw data received (from the `bytes` crate) #### Disconnected Emitted when the connection is closed (gracefully or due to error). The attached `DisconnectReason` says why: ```rust theme={null} pub enum DisconnectReason { /// Server sent a WebSocket close frame. `code` is the RFC 6455 close /// code when present; `reason` is the close payload text. ServerClose { code: Option, reason: String }, /// The stream ended (EOF) without a close frame. StreamEnded, /// A transport-level read error occurred. ReadError(String), /// Local shutdown — no reason available from the wire. Unknown, } ``` `DisconnectReason` implements `Display` for human-readable logging. Custom transports should emit the most specific variant they can determine. ```rust theme={null} impl DisconnectReason { pub fn is_clean_shutdown(&self) -> bool { // ... } } ``` `is_clean_shutdown()` tells a benign, server-initiated stream recycle (the normal WhatsApp reconnect path) apart from a genuine transport failure, so consumers of [`events::Disconnected`](/concepts/events#disconnected) don't have to parse logs to classify a disconnect. It's deliberately conservative — anything ambiguous returns `false` (treated as a real failure): | Variant | `is_clean_shutdown()` | | ------------------------------------------------------------- | --------------------------------------------------------------------------- | | `StreamEnded` | `true` — EOF with no close frame is how the WA server recycles a connection | | `ServerClose` with code `None`, `Some(1000)`, or `Some(1001)` | `true` — no code, normal closure, or going-away are graceful | | `ServerClose` with any other code | `false` — protocol/server error, restart, etc. | | `ReadError(_)` | `false` — a transport read/IO error is always a real failure | | `Unknown` | `false` — an unreported reason is never assumed benign | ```rust theme={null} TransportEvent::Disconnected(reason) => { tracing::info!(%reason, "transport closed"); break; } ``` ## Tokio WebSocket transport The default transport implementation using `tokio-websockets` for async WebSocket connections. ### Features * **Async I/O** - Built on Tokio runtime * **TLS support** - Uses rustls with webpki-roots for certificate validation * **Split architecture** - Separate read/write paths for efficiency * **Generic over streams** - Works with any `AsyncRead + AsyncWrite` stream type * **Automatic reconnection** - Handled by higher-level Client code * **Development mode** - Optional `danger-skip-tls-verify` feature ### TokioWebSocketTransportFactory The default factory handles DNS resolution, TCP connection, and TLS. For custom connection logic, use [`from_websocket`](#from_websocket) directly. When no custom connector is supplied via [`with_connector`](#with_connector), the factory builds [`default_tls_connector()`](#default_tls_connector) once, on the first call to `create_transport`, and retains it in a `OnceLock` for every subsequent dial on that factory — a reconnect no longer pays for a fresh TLS config. Retaining the connector also lets a later dial reuse resumption tickets an earlier handshake received: previously (the pre-#1245 behavior), rebuilding the config per dial discarded the resumption store — tickets and all — before the next dial could ever use it. A connector supplied explicitly via `with_connector` is used as-is and never touches this cache. ```rust theme={null} use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory; // Default - connects to WhatsApp Web let factory = TokioWebSocketTransportFactory::new(); // Custom URL let factory = TokioWebSocketTransportFactory::new() .with_url("wss://custom-endpoint.example.com/ws"); // Custom TLS connector (e.g. custom CA certificates, client certs) let factory = TokioWebSocketTransportFactory::new() .with_connector(my_custom_connector); // Custom Origin header (e.g. connecting through a relay that requires its own) let factory = TokioWebSocketTransportFactory::new() .with_origin("https://relay.example"); ``` #### with\_connector ```rust theme={null} pub fn with_connector(self, connector: Connector) -> Self ``` Uses a custom TLS `Connector` instead of the built-in default. This is the primary extension point for custom TLS configuration — for example, adding custom CA certificates or client certificates. For full proxy support, implement `TransportFactory` directly and use [`from_websocket`](#from_websocket) instead. **Parameters:** * `connector` - A `tokio_websockets::Connector` (re-exported as `whatsapp_rust::transport::Connector`) **Example — custom CA certificate:** ```rust theme={null} use whatsapp_rust_tokio_transport::{TokioWebSocketTransportFactory, Connector, default_tls_connector}; use tokio_rustls::TlsConnector; use rustls::RootCertStore; use std::sync::Arc; // Build a custom TLS config with your own CA let mut root_store = RootCertStore::empty(); root_store.add_parsable_certificates(my_ca_certs); let config = rustls::ClientConfig::builder() .with_root_certificates(root_store) .with_no_client_auth(); let connector = Connector::Rustls(TlsConnector::from(Arc::new(config))); let factory = TokioWebSocketTransportFactory::new() .with_connector(connector); ``` Use `default_tls_connector()` to inspect or replicate the default TLS configuration as a starting point before customizing. A connector returned by that function already carries the single-host resumption sizing described [below](#default_tls_connector), even when supplied via `with_connector`. A connector built independently with `Connector::Rustls` — as in the example above — does not get that sizing unless you apply it yourself. #### with\_origin ```rust theme={null} pub fn with_origin(self, origin: impl Into) -> Self ``` Sends a different `Origin` header on the WebSocket upgrade request. The default is [`WHATSAPP_WEB_ORIGIN`](#connection-settings) (`"https://web.whatsapp.com"`), and it stays correct even when [`with_url`](#tokiowebsockettransportfactory) points at a relay or a mock — a relay that simply forwards traffic to WhatsApp should still see the same `Origin` a real WA Web browser would send, regardless of which host you dial. Override it only when the peer you're connecting to — the relay itself, not WhatsApp — validates `Origin` against its own value. **Parameters:** * `origin` - The value to send as the `Origin` header #### without\_origin ```rust theme={null} pub fn without_origin(self) -> Self ``` Opens the socket with no `Origin` header at all — this crate's behavior before the header was added. Use it only for a peer that rejects the upgrade because of the header; no known WhatsApp endpoint does. ### default\_tls\_connector ```rust theme={null} pub fn default_tls_connector() -> Connector ``` Returns the default TLS connector used by `TokioWebSocketTransportFactory`. Uses rustls with `webpki_roots` for certificate validation and `ring` as the crypto provider. This is useful as a starting point when you need to inspect or replicate the default TLS configuration before customizing it via `with_connector`. Its session-resumption store is sized for the one host a factory dials: 8 tickets, rustls's per-server maximum, rather than the \~32-server table (`⌈256/8⌉` slots) rustls's `Resumption::default()` provisions for. A connector reused across several hosts (for example, shared deliberately across factories) still works, but only the most recently dialled hosts keep their tickets; build a larger `rustls::client::Resumption` store yourself if that's the shape you need. `TokioWebSocketTransportFactory` itself only ever dials the one URL it was built with, so the default sizing is a straight win there — see [`TokioWebSocketTransportFactory`](#tokiowebsockettransportfactory) above for how the resulting connector is cached and reused across reconnects. ### Usage with Bot builder ```rust theme={null} use whatsapp_rust::Bot; use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory; #[tokio::main] async fn main() -> Result<(), Box> { let factory = TokioWebSocketTransportFactory::new(); let mut bot = Bot::builder() .with_backend(backend) .with_transport_factory(factory) .with_http_client(http_client) .on_event(|event, client| async move { /* handle events */ }) .build() .await?; Ok(()) } ``` ### from\_websocket Wraps an already-upgraded `WebSocketStream` into a `Transport` + event channel. This is useful when you need custom connection strategies such as IPv4 preference, TCP keepalive tuning, or connecting through a proxy. ```rust theme={null} pub fn from_websocket( ws: WebSocketStream, ) -> (Arc, async_channel::Receiver) where S: AsyncRead + AsyncWrite + Send + Unpin + 'static, ``` **Parameters:** * `ws` - An already-connected `WebSocketStream` over any async stream type **Returns:** * `Arc` - The transport instance for sending data * `async_channel::Receiver` - Stream of transport events The function splits the WebSocket into read/write halves, spawns a background read pump task, and synchronously enqueues a `Connected` event before the read pump starts — ensuring it always precedes any `DataReceived` events. **Example — IPv4-only connection with TCP keepalive:** ```rust theme={null} use whatsapp_rust_tokio_transport::from_websocket; use tokio::net::TcpStream; use tokio_websockets::ClientBuilder; // Custom TCP connection with keepalive let tcp = TcpStream::connect("web.whatsapp.com:443").await?; tcp.set_nodelay(true)?; let sock_ref = socket2::SockRef::from(&tcp); let keepalive = socket2::TcpKeepalive::new() .with_time(Duration::from_secs(30)) .with_interval(Duration::from_secs(10)); sock_ref.set_tcp_keepalive(&keepalive)?; // Upgrade to WebSocket (TLS handled externally) let (ws, _response) = ClientBuilder::from_uri("wss://web.whatsapp.com/ws/chat".parse()?) .connect_on(tcp) .await?; // Wrap into a Transport let (transport, events) = from_websocket(ws); ``` **Example — using with a custom TransportFactory:** ```rust theme={null} use whatsapp_rust_tokio_transport::from_websocket; use wacore::net::{Transport, TransportEvent, TransportFactory}; struct MyTransportFactory; #[async_trait] impl TransportFactory for MyTransportFactory { async fn create_transport( &self, ) -> Result<(Arc, async_channel::Receiver), anyhow::Error> { // Your custom connection logic here let ws = establish_custom_websocket().await?; Ok(from_websocket(ws)) } } ``` `TokioWebSocketTransportFactory` itself delegates to `from_websocket` internally. Use the factory when the default DNS/TCP/TLS behavior is sufficient, and `from_websocket` when you need full control over the connection. ### Proxy support The transport layer provides two approaches for proxy support, depending on your needs: **Option 1: Implement `TransportFactory` directly** — for full control over the connection (SOCKS5, HTTP CONNECT, etc.). Establish a WebSocket through the proxy yourself, then wrap it with `from_websocket`: ```rust theme={null} use whatsapp_rust_tokio_transport::from_websocket; use wacore::net::{Transport, TransportEvent, TransportFactory}; struct ProxyTransportFactory { proxy_url: String, } #[async_trait] impl TransportFactory for ProxyTransportFactory { async fn create_transport( &self, ) -> Result<(Arc, async_channel::Receiver), anyhow::Error> { // 1. Connect to proxy let proxy_stream = connect_to_proxy(&self.proxy_url).await?; // 2. Upgrade to WebSocket through the proxy let (ws, _) = tokio_websockets::ClientBuilder::from_uri( "wss://web.whatsapp.com/ws/chat".parse()? ) .connect_on(proxy_stream) .await?; // 3. Wrap into Transport Ok(from_websocket(ws)) } } ``` **Option 2: Use `with_connector`** — for custom TLS only (no proxy routing). The factory still handles DNS and TCP, but you control the TLS layer: ```rust theme={null} let factory = TokioWebSocketTransportFactory::new() .with_connector(my_custom_tls_connector); ``` See [custom backends — proxy and custom TLS](/guides/custom-backends#proxy-and-custom-tls) for a complete guide with examples. ### TLS configuration By default, the transport validates TLS certificates using webpki-roots: ```rust theme={null} let mut root_store = rustls::RootCertStore::empty(); root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); let config = rustls::ClientConfig::builder() .with_root_certificates(root_store) .with_no_client_auth(); ``` The config's session-resumption store is then resized down from rustls's default (provisioned for \~32 distinct server names) to 8 tickets — a single server's worth — since a `TokioWebSocketTransportFactory` only ever dials the one host it was built with. See [`default_tls_connector`](#default_tls_connector) for the sizing rationale. ### Development mode (skip TLS verification) Only use for development and testing. Enable the `danger-skip-tls-verify` feature to disable certificate verification: ```toml theme={null} [dependencies] whatsapp-rust-tokio-transport = { version = "0.7", features = ["danger-skip-tls-verify"] } ``` This allows connecting through MITM proxies or self-signed certificates. ### Connection settings The default WebSocket URL is: ```rust theme={null} pub const WHATSAPP_WEB_WS_URL: &str = "wss://web.whatsapp.com/ws/chat"; ``` Every upgrade created by `TokioWebSocketTransportFactory` also carries an `Origin` header by default: ```rust theme={null} pub const WHATSAPP_WEB_ORIGIN: &str = "https://web.whatsapp.com"; ``` RFC 6455 requires a browser client to send `Origin` on the WebSocket handshake, and both whatsmeow and Baileys send this exact value unconditionally, regardless of the platform their `ClientPayload` claims. Override it with [`with_origin`](#with_origin), or drop it entirely with [`without_origin`](#without_origin) for a peer that rejects the header. The `Client` wraps `create_transport()` in a **20-second timeout** matching WhatsApp Web's connect timeout. The transport itself does not enforce this timeout — it is applied at the client layer. If you use a custom transport, be aware that the client will abort the connection attempt after 20 seconds regardless of the transport's own timeout behavior. ### WsTransport resource report `WsTransport` overrides [`Transport::resource_report`](#resource_report) with static, documented estimates rather than a live measurement, since `tokio-websockets` and `rustls` don't surface their buffer sizes: `read_buffer_bytes: 16 KiB`, `write_buffer_bytes: 16 KiB`, `tls_state_bytes: 32 KiB` (record buffers + key schedule for one TLS session). These give a realistic order-of-magnitude for the transport's per-session contribution — tens of KiB — rather than an exact figure. ### Internal architecture The transport is generic over any `AsyncRead + AsyncWrite` stream, split into separate read/write paths: ```rust theme={null} let (sink, stream) = ws.split(); // Sink - wrapped in Arc for sending let sink: Arc>>> = Arc::new(Mutex::new(Some(sink))); // Stream - moved to read_pump task with shutdown signal tokio::task::spawn(read_pump(stream, event_tx, shutdown_rx)); ``` The read pump uses `tokio::select!` with a shutdown watch channel to ensure clean termination: ```rust theme={null} async fn read_pump( mut stream: SplitStream>, tx: async_channel::Sender, mut shutdown: tokio::sync::watch::Receiver, ) { loop { tokio::select! { biased; _ = shutdown.changed() => break, next = stream.next() => match next { Some(Ok(msg)) if msg.is_binary() => { let payload = msg.into_payload(); // Shutdown-aware send to prevent blocking on full channel tokio::select! { biased; _ = shutdown.changed() => break, r = tx.send(TransportEvent::DataReceived(Bytes::from(payload))) => { if r.is_err() { break; } } } } Some(Ok(msg)) if msg.is_close() => break, Some(Err(e)) => break, None => break, }, } } let _ = tx.send(TransportEvent::Disconnected(DisconnectReason::StreamEnded)).await; } ``` ## Implementing custom transports You can implement custom transports for different runtimes or protocols. ### Example: mock transport for testing ```rust theme={null} use async_trait::async_trait; use std::sync::Arc; use wacore::net::{Transport, TransportEvent, TransportFactory}; /// A mock transport that does nothing pub struct MockTransport; #[async_trait] impl Transport for MockTransport { async fn send(&self, _data: Vec) -> Result<(), anyhow::Error> { // Silently succeed Ok(()) } async fn disconnect(&self) { // Nothing to do } } /// Factory for creating mock transports pub struct MockTransportFactory; impl MockTransportFactory { pub fn new() -> Self { Self } } #[async_trait] impl TransportFactory for MockTransportFactory { async fn create_transport( &self, ) -> Result<(Arc, async_channel::Receiver), anyhow::Error> { let (_tx, rx) = async_channel::bounded(1); Ok((Arc::new(MockTransport), rx)) } } ``` ### Example: TCP transport (no TLS) ```rust theme={null} use async_trait::async_trait; use tokio::net::TcpStream; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use std::sync::Arc; use tokio::sync::Mutex; pub struct TcpTransport { writer: Arc>>, } #[async_trait] impl Transport for TcpTransport { async fn send(&self, data: Vec) -> Result<(), anyhow::Error> { let mut writer = self.writer.lock().await; writer.write_all(&data).await?; Ok(()) } async fn disconnect(&self) { // TCP disconnect handled by drop } } pub struct TcpTransportFactory { address: String, } impl TcpTransportFactory { pub fn new(address: impl Into) -> Self { Self { address: address.into(), } } } #[async_trait] impl TransportFactory for TcpTransportFactory { async fn create_transport( &self, ) -> Result<(Arc, async_channel::Receiver), anyhow::Error> { let stream = TcpStream::connect(&self.address).await?; let (reader, writer) = tokio::io::split(stream); let (event_tx, event_rx) = async_channel::bounded(100); let transport = Arc::new(TcpTransport { writer: Arc::new(Mutex::new(writer)), }); // Spawn read task tokio::task::spawn(async move { let mut reader = reader; let mut buf = vec![0u8; 4096]; event_tx.send(TransportEvent::Connected).await.ok(); loop { match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { let data = bytes::Bytes::copy_from_slice(&buf[..n]); if event_tx.send(TransportEvent::DataReceived(data)).await.is_err() { break; } } Err(_) => break, } } event_tx.send(TransportEvent::Disconnected(DisconnectReason::StreamEnded)).await.ok(); }); Ok((transport, event_rx)) } } ``` ### Best Practices 1. **Thread Safety** - On native, implementations must be `Send + Sync` (enforced via `MaybeSendSync`). On `wasm32`, `MaybeSendSync` carries no bounds, so `!Send` implementations backed by JS handles compile. 2. **Error Handling** - Return descriptive errors from `send()` 3. **Graceful Shutdown** - Implement proper cleanup in `disconnect()` 4. **Event Channel Size** - Use bounded channels with reasonable capacity 5. **Read Task** - Spawn a separate task for receiving data 6. **Resource Cleanup** - Ensure sockets/resources are closed on drop ## Testing Transports ### Unit test example ```rust theme={null} #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_mock_transport() { let factory = MockTransportFactory::new(); let (transport, events) = factory.create_transport().await.unwrap(); // Should succeed without error transport.send(vec![1, 2, 3]).await.unwrap(); // Disconnect should be no-op transport.disconnect().await; } #[tokio::test] async fn test_websocket_transport() { let factory = TokioWebSocketTransportFactory::new(); let (transport, mut events) = factory.create_transport().await.unwrap(); // Should receive Connected event match events.recv().await { Ok(TransportEvent::Connected) => {}, other => panic!("Expected Connected, got {:?}", other), } transport.disconnect().await; } } ``` ## See Also * [Storage Traits](/api/store) - Storage backend abstraction * [HTTP Client Trait](/api/http-client) - HTTP client abstraction * [Client API](/api/client) - Main client interface # upload Source: https://whatsapp-rust.jlucaso.com/api/upload Upload and encrypt media for sending in messages ## upload Upload media to WhatsApp's CDN with automatic encryption. Only use `upload` for new or modified media. To forward existing media unchanged, reuse the original message's CDN fields directly — no upload required. See [media forwarding via CDN reuse](/guides/media-handling#forwarding-media-via-cdn-reuse). ```rust theme={null} pub async fn upload( &self, data: Vec, media_type: MediaType, options: UploadOptions, ) -> Result ``` Raw media bytes to upload. The data is automatically encrypted using AES-256-CBC before uploading. Type of media being uploaded. Determines the CDN endpoint and encryption keys: * `MediaType::Image` - Images and stickers * `MediaType::Video` - Video files * `MediaType::Audio` - Audio files and voice notes * `MediaType::Document` - Documents and other files * `MediaType::Sticker` - Sticker images * `MediaType::StickerPack` - Sticker pack ZIP files * `MediaType::StickerPackThumbnail` - Sticker pack thumbnail images * `MediaType::LinkThumbnail` - Link preview thumbnails * `MediaType::ProductCatalogImage` - Product catalog images (unencrypted) Upload options. Use `Default::default()` or `UploadOptions::new()` for default behavior. See [UploadOptions](#uploadoptions) below. Contains all metadata needed to include the media in a message: ```rust theme={null} #[non_exhaustive] pub struct UploadResponse { pub url: String, pub direct_path: String, pub media_key: [u8; 32], pub file_enc_sha256: [u8; 32], pub file_sha256: [u8; 32], pub file_length: u64, pub media_key_timestamp: i64, /// Per-64-KiB HMAC sidecar for progressive playback/seek (audio/video /// only). Pass to `AudioMessage`/`VideoMessage.streaming_sidecar`. pub streaming_sidecar: Option>, } ``` Helper methods are provided for protobuf message construction, which requires `Vec`: ```rust theme={null} impl UploadResponse { pub fn media_key_vec(&self) -> Vec { ... } pub fn file_sha256_vec(&self) -> Vec { ... } pub fn file_enc_sha256_vec(&self) -> Vec { ... } } ``` `UploadResponse` is `#[non_exhaustive]`. Field reads are unaffected; only exhaustive struct destructuring from outside the crate requires adding `..`. Full CDN URL where the encrypted file was uploaded CDN path component (e.g., `/v/t62.7118-24/12345_67890`). Used for downloads. 32-byte encryption key. Required for the recipient to decrypt the media. Use `media_key_vec()` when building protobuf messages. SHA-256 hash of the encrypted file. Used for integrity verification during download. Use `file_enc_sha256_vec()` when building protobuf messages. SHA-256 hash of the original (decrypted) file. Used for final validation after decryption. Use `file_sha256_vec()` when building protobuf messages. Original file size in bytes (before encryption) Unix timestamp (seconds) when the media key was generated. Set automatically by `upload()` to the current time. Per-64-KiB HMAC-SHA256 table over the ciphertext that lets recipients seek and stream audio/video progressively without downloading the whole file. Generated automatically for audio and video uploads (`None` otherwise). Assign it to `AudioMessage.streaming_sidecar` / `VideoMessage.streaming_sidecar` when building the message. Opt in/out explicitly with `UploadOptions::with_streaming_sidecar(bool)`. ### Example: upload and send image ```rust theme={null} use wacore::download::MediaType; use waproto::whatsapp as wa; use std::fs; // Read image file let image_bytes = fs::read("photo.jpg")?; // Upload image to WhatsApp CDN let upload = client.upload(image_bytes, MediaType::Image, Default::default()).await?; // Create image message with upload metadata let message = wa::Message { image_message: buffa::MessageField::some(wa::message::ImageMessage { url: Some(upload.url), direct_path: Some(upload.direct_path), media_key: Some(upload.media_key_vec()), file_enc_sha256: Some(upload.file_enc_sha256_vec()), file_sha256: Some(upload.file_sha256_vec()), file_length: Some(upload.file_length), media_key_timestamp: Some(upload.media_key_timestamp), caption: Some("Check out this photo!".to_string()), mimetype: Some("image/jpeg".to_string()), ..Default::default() }), ..Default::default() }; // Send the message let result = client.send_message(chat_jid, message).await?; println!("Image sent: {}", result.message_id); ``` ### Example: upload video with progress ```rust theme={null} use wacore::download::MediaType; use std::fs; let video_bytes = fs::read("video.mp4")?; println!("Uploading {} bytes...", video_bytes.len()); let upload = client.upload(video_bytes, MediaType::Video, Default::default()).await?; println!("Upload complete!"); println!(" URL: {}", upload.url); println!(" Path: {}", upload.direct_path); // Use upload metadata in VideoMessage... ``` ### Example: upload document ```rust theme={null} use wacore::download::MediaType; use waproto::whatsapp as wa; use std::fs; use std::path::Path; let doc_path = Path::new("report.pdf"); let doc_bytes = fs::read(doc_path)?; let filename = doc_path.file_name() .and_then(|n| n.to_str()) .unwrap_or("document.pdf"); let upload = client.upload(doc_bytes, MediaType::Document, Default::default()).await?; let message = wa::Message { document_message: buffa::MessageField::some(wa::message::DocumentMessage { url: Some(upload.url), direct_path: Some(upload.direct_path), media_key: Some(upload.media_key_vec()), file_enc_sha256: Some(upload.file_enc_sha256_vec()), file_sha256: Some(upload.file_sha256_vec()), file_length: Some(upload.file_length), media_key_timestamp: Some(upload.media_key_timestamp), file_name: Some(filename.to_string()), mimetype: Some("application/pdf".to_string()), ..Default::default() }), ..Default::default() }; let result = client.send_message(chat_jid, message).await?; ``` ### Example: upload audio (voice note) ```rust theme={null} use wacore::download::MediaType; use waproto::whatsapp as wa; let audio_bytes = fs::read("voice.ogg")?; let upload = client.upload(audio_bytes, MediaType::Audio, Default::default()).await?; let message = wa::Message { audio_message: buffa::MessageField::some(wa::message::AudioMessage { url: Some(upload.url), direct_path: Some(upload.direct_path), media_key: Some(upload.media_key_vec()), file_enc_sha256: Some(upload.file_enc_sha256_vec()), file_sha256: Some(upload.file_sha256_vec()), file_length: Some(upload.file_length), media_key_timestamp: Some(upload.media_key_timestamp), mimetype: Some("audio/ogg; codecs=opus".to_string()), ptt: Some(true), // Mark as Push-To-Talk (voice note) ..Default::default() }), ..Default::default() }; let result = client.send_message(chat_jid, message).await?; ``` *** ## High-level message builders The `whatsapp_rust::media` module turns an [`UploadResponse`](#upload) into a ready-to-send `wa::Message`, so you don't hand-assemble the CDN/crypto fields (url, direct\_path, media\_key, file\_sha256, file\_enc\_sha256, file\_length, media\_key\_timestamp, streaming\_sidecar) on every send. Each builder takes the upload result plus a typed options struct with sensible MIME defaults. ```rust theme={null} use wacore::download::MediaType; use whatsapp_rust::media::{self, ImageOptions, VideoOptions, AudioOptions, DocumentOptions}; use std::fs; let chat_jid: Jid = "15551234567@s.whatsapp.net".parse()?; let image_bytes = fs::read("photo.jpg")?; let upload = client.upload(image_bytes, MediaType::Image, Default::default()).await?; let msg = media::image_message(upload, ImageOptions { caption: Some("Check out this photo!".to_string()), ..Default::default() }); client.send_message(chat_jid, msg).await?; ``` | Builder | Options struct | Notable fields (all optional) | MIME default | | --------------------------------------- | ----------------- | --------------------------------------------------------------------------- | -------------------------- | | `media::image_message(upload, opts)` | `ImageOptions` | `caption`, `mimetype`, `jpeg_thumbnail` | `image/jpeg` | | `media::video_message(upload, opts)` | `VideoOptions` | `caption`, `mimetype`, `jpeg_thumbnail`, `duration_seconds`, `gif_playback` | `video/mp4` | | `media::document_message(upload, opts)` | `DocumentOptions` | `mimetype`, `file_name`, `title`, `caption`, `page_count`, `jpeg_thumbnail` | `application/octet-stream` | | `media::audio_message(upload, opts)` | `AudioOptions` | `mimetype`, `duration_seconds`, `ptt`, `waveform` | `audio/ogg; codecs=opus` | The `video_message` and `audio_message` builders carry the upload's `streaming_sidecar` (progressive-playback HMAC table) when present. For fields the options structs don't expose (e.g. explicit image `width`/`height`), assemble the proto by hand as shown above. ```rust theme={null} use wacore::download::MediaType; use whatsapp_rust::media::{self, AudioOptions}; use std::fs; // Voice note (push-to-talk) with a waveform preview let opus_bytes = fs::read("voice.ogg")?; let waveform_bytes: Vec = vec![]; // optional PCM waveform preview let upload = client.upload(opus_bytes, MediaType::Audio, Default::default()).await?; let msg = media::audio_message(upload, AudioOptions { ptt: Some(true), duration_seconds: Some(8), waveform: Some(waveform_bytes), ..Default::default() }); client.send_message(chat_jid, msg).await?; ``` *** ## UploadOptions Options for customizing upload behavior. ```rust theme={null} #[non_exhaustive] pub struct UploadOptions { /// Reuse an existing media key instead of generating a fresh one. pub media_key: Option<[u8; 32]>, /// Override streaming-sidecar generation; `None` selects by media type. pub streaming_sidecar: Option, } ``` `UploadOptions` is `#[non_exhaustive]`, so it cannot be constructed using struct literal syntax from outside the crate. Use `UploadOptions::default()` or `UploadOptions::new()` with builder methods instead. When set, reuses the provided 32-byte media key instead of generating a new one. This is required when uploading a sticker pack thumbnail, which must share the same `media_key` as the sticker pack ZIP. Override streaming-sidecar generation. `None` (default) generates the per-64-KiB HMAC-SHA256 `streaming_sidecar` only for audio and video; `Some(true)` forces it on; `Some(false)` forces it off. When produced, assign the resulting `UploadResponse.streaming_sidecar` to `AudioMessage.streaming_sidecar` / `VideoMessage.streaming_sidecar` so recipients can seek and stream progressively. ### Creating options ```rust theme={null} use whatsapp_rust::upload::UploadOptions; // Default options (generates a new media key) let options = UploadOptions::default(); // Reuse an existing media key let options = UploadOptions::new().with_media_key(existing_key.clone()); // Force the streaming sidecar on (e.g. for a media type it skips by default) let options = UploadOptions::new().with_streaming_sidecar(true); ``` ### Example: Upload sticker pack thumbnail with shared key ```rust theme={null} use whatsapp_rust::upload::UploadOptions; use wacore::download::MediaType; // Upload the sticker pack ZIP first let zip_upload = client.upload( zip_bytes, MediaType::StickerPack, UploadOptions::default(), ).await?; // Upload the thumbnail with the same media_key let thumb_upload = client.upload( thumbnail_jpeg, MediaType::StickerPackThumbnail, UploadOptions::new().with_media_key(zip_upload.media_key), ).await?; ``` *** ## MediaEncryptor Chunk-based AES-256-CBC media encryptor. Processes plaintext incrementally without requiring a sync `Read`, enabling use with async streams, network sources, or any chunk-at-a-time producer. Two output modes with zero duplicated crypto logic: * `update()` / `finalize()` — append encrypted blocks to a `Vec` * `update_to_writer()` / `finalize_to_writer()` — write to any `Write` implementor you provide, batching output into \~8 KiB runs internally rather than staging the whole file ```rust theme={null} pub struct MediaEncryptor { /* ... */ } ``` ### Creating an encryptor ```rust theme={null} use wacore::upload::MediaEncryptor; use wacore::download::MediaType; // Initialize with a random media key let enc = MediaEncryptor::new(MediaType::Image)?; // Or initialize with a caller-supplied key (must be 32 cryptographically random bytes) let enc = MediaEncryptor::with_key(media_key, MediaType::Image)?; ``` Type of media being encrypted. Determines the HKDF info string for key derivation. Caller-supplied 32-byte key for `with_key()`. Must be cryptographically random — reusing keys breaks confidentiality. ### Feeding plaintext Feed plaintext in arbitrarily sized chunks. All complete blocks in a call are encrypted immediately. Only a trailing partial block (at most 15 bytes) is buffered across your calls. `update_to_writer()` also batches its ciphertext into \~8 KiB runs before writing, but only within a single call — it flushes everything to your writer before returning, so if you feed it small inputs you still get small, per-call writes rather than full 8 KiB runs. See the memory note below for details. ```rust theme={null} // Append encrypted blocks to a Vec let mut encrypted = Vec::new(); enc.update(plaintext_chunk, &mut encrypted); // Or write encrypted blocks directly to a writer enc.update_to_writer(plaintext_chunk, &mut writer)?; ``` Plaintext bytes to encrypt. Can be any size — from a single byte to the entire file. On `update_to_writer` I/O error, the encryptor state is unspecified — discard it. ### Finalizing Finalize applies PKCS7 padding to the remaining plaintext, encrypts the final block(s), and appends a 10-byte truncated HMAC-SHA256 MAC. ```rust theme={null} // Finalize to Vec let info = enc.finalize(&mut encrypted)?; // Or finalize to writer let info = enc.finalize_to_writer(&mut writer)?; ``` Encryption metadata (keys and hashes). The encrypted data was already written via `update`/`finalize` calls. ```rust theme={null} pub struct EncryptedMediaInfo { pub media_key: [u8; 32], pub file_sha256: [u8; 32], pub file_enc_sha256: [u8; 32], pub file_length: u64, } ``` 32-byte encryption key for the recipient to decrypt the media SHA-256 hash of the original plaintext, computed on the fly during encryption SHA-256 hash of the encrypted output (ciphertext + MAC) Original file size in bytes (before encryption) ### Example: Chunk-based encryption to Vec ```rust theme={null} use wacore::upload::MediaEncryptor; use wacore::download::MediaType; let mut enc = MediaEncryptor::new(MediaType::Image)?; let mut encrypted = Vec::new(); // Feed plaintext in arbitrary chunks for chunk in plaintext_data.chunks(4096) { enc.update(chunk, &mut encrypted); } let info = enc.finalize(&mut encrypted)?; println!("Media key: {:?}", info.media_key); println!("Original size: {} bytes", info.file_length); ``` ### Example: Streaming encryption to a writer ```rust theme={null} use wacore::upload::MediaEncryptor; use wacore::download::MediaType; use std::fs::File; let mut enc = MediaEncryptor::new(MediaType::Video)?; let mut writer = File::create("large_video.enc")?; // Feed chunks from any source (async stream, network, etc.) for chunk in reader_chunks { enc.update_to_writer(&chunk, &mut writer)?; } let info = enc.finalize_to_writer(&mut writer)?; ``` The encryptor's own internal state uses constant memory, regardless of file size, with one exception: the streaming sidecar. When enabled (the default for audio and video), it accumulates a per-64-KiB HMAC table — about 10 bytes per 64 KiB of ciphertext — until `finalize()`/`finalize_to_writer()` returns it, so it grows slowly with file size even when your destination is a writer. This also doesn't cover whatever destination you provide. With `update()`/`finalize()`, you're only buffering the crypto state and at most 15 bytes of remainder plaintext. Your destination `Vec` itself still grows with the encrypted output. With `update_to_writer()`/`finalize_to_writer()`, ciphertext is also staged internally, bounding memory at crypto state + remainder + \~8 KiB. It's flushed to your writer in \~8 KiB runs rather than once per 16-byte block, amortizing the `Write` call — but only within a single call, since nothing is held across calls. If you feed it small chunks (e.g. one 16-byte block per call), you still get small, per-call writes rather than 8 KiB runs. Your writer itself (e.g. a `File`) need not retain anything. Unlike `encrypt_media_streaming`, `MediaEncryptor` does not require a sync `Read` source, making it suitable for async contexts. *** ## Streaming encryption For sync `Read` sources, `encrypt_media_streaming` provides a convenience wrapper around `MediaEncryptor` that reads in 8KB chunks: ```rust theme={null} pub fn encrypt_media_streaming( reader: R, writer: W, media_type: MediaType, ) -> Result ``` Source of plaintext media bytes. Can be a `File`, `Cursor>`, or any `Read` implementor. Destination for encrypted output. Receives ciphertext + 10-byte HMAC-SHA256 MAC — the exact bytes to upload to WhatsApp CDN. Type of media being encrypted. Determines the HKDF info string for key derivation. ### Example: Encrypt from file to file ```rust theme={null} use wacore::upload::encrypt_media_streaming; use wacore::download::MediaType; use std::fs::File; let reader = File::open("large_video.mp4")?; let writer = File::create("large_video.enc")?; let info = encrypt_media_streaming(reader, writer, MediaType::Video)?; println!("Media key: {:?}", info.media_key); println!("Original size: {} bytes", info.file_length); ``` `encrypt_media_streaming` uses \~40KB of memory regardless of file size (8KB read buffer + crypto state). Internally it creates a `MediaEncryptor` and feeds 8KB chunks from the reader. ### encrypt\_media\_streaming\_with\_key ```rust theme={null} pub fn encrypt_media_streaming_with_key( reader: R, writer: W, media_type: MediaType, media_key: Option<&[u8; 32]>, sidecar: Option, ) -> Result ``` Lower-level variant of `encrypt_media_streaming`. It returns the same `EncryptedMediaInfo` (including the optional `streaming_sidecar`) but lets you control two things the convenience wrapper decides for you: * **`media_key`** — pass `Some(&key)` to encrypt with a pre-existing 32-byte key (e.g. when re-encrypting for a shared-key sticker pack), or `None` to generate a fresh random key like `encrypt_media_streaming` does. * **`sidecar`** — pass `Some(true)`/`Some(false)` to force the streaming sidecar on or off, or `None` to use the per-media-type default (generated for audio/video). Reach for this when you need a deterministic key or want to override sidecar generation; otherwise use `encrypt_media_streaming`, which calls this with `None, None`. ### Relationship to `encrypt_media` When you call `encrypt_media`, it forwards through `encrypt_media_with_key` to `encrypt_media_with_key_and_sidecar`, which drives `MediaEncryptor` directly and sizes the output `Vec` exactly up front via `encrypted_len`: ```rust theme={null} pub fn encrypt_media_with_key_and_sidecar( plaintext: &[u8], media_type: MediaType, media_key: Option<&[u8; 32]>, sidecar: Option, ) -> Result { let want_sidecar = sidecar.unwrap_or_else(|| media_type_uses_sidecar(media_type)); let mut enc = match media_key { Some(key) => MediaEncryptor::with_key_and_sidecar(*key, media_type, want_sidecar)?, None => MediaEncryptor::new_with_sidecar(media_type, want_sidecar)?, }; let mut data_to_upload = Vec::with_capacity(encrypted_len(plaintext.len())); enc.update(plaintext, &mut data_to_upload); let info = enc.finalize(&mut data_to_upload)?; Ok(EncryptedMedia { data_to_upload, media_key: info.media_key, file_sha256: info.file_sha256, file_enc_sha256: info.file_enc_sha256, streaming_sidecar: info.streaming_sidecar, }) } ``` `data_to_upload` used to start from `Vec::new()` and grow by doubling, so a 16 MiB video reallocated close to a dozen times on its way to 32 MiB of capacity — copying everything encrypted so far on each grow, and peaking (plaintext + old buffer + new buffer) near 64 MiB, about 4x the \~16 MiB ciphertext. `encrypted_len` (below) gives the exact output size up front, so this path now allocates once, peaking at plaintext + ciphertext ≈ 32 MiB instead. ### When to use each API | API | Use when | | ------------------------- | ------------------------------------------------------------------------------- | | `MediaEncryptor` | You have an async stream, network source, or need full control over chunk sizes | | `encrypt_media_streaming` | You have a sync `Read` source (file, cursor) | | `encrypt_media` | The entire plaintext fits in memory | *** ## upload\_stream (constant-memory) `upload()` encrypts the whole plaintext into a `Vec` in memory before sending. For large files, `upload_stream` keeps memory constant by uploading **already-encrypted** ciphertext from any backing store (temp file, memory-mapped blob, …) without re-buffering it. ```rust theme={null} pub async fn upload_stream( &self, source: S, info: wacore::upload::EncryptedMediaInfo, media_type: MediaType, ) -> Result where S: wacore::upload::UploadSource + 'static, ``` The flow is two steps you own: 1. Encrypt the plaintext into storage of your choice with [`encrypt_media_streaming`](#streaming-encryption) (or `encrypt_media_streaming_with_key`). It returns an `EncryptedMediaInfo` carrying the crypto metadata and the optional `streaming_sidecar`. 2. Pass that storage (as an `UploadSource`) plus the `EncryptedMediaInfo` to `upload_stream`. The method never touches disk itself. ```rust theme={null} use wacore::upload::{encrypt_media_streaming, UploadSource}; use wacore::download::MediaType; use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use tempfile::NamedTempFile; // A file-backed UploadSource. It owns the temp file (so the data lives as // long as the source) and reopens it by path on every attempt, giving each // reader its own independent OS file offset — safe for retry / resume / // host-failover, which re-invoke reader_from. struct FileSource { file: NamedTempFile, len: u64, } impl UploadSource for FileSource { fn len(&self) -> u64 { self.len } fn reader_from(&self, offset: u64) -> std::io::Result> { let mut f = File::open(self.file.path())?; // fresh fd, independent cursor f.seek(SeekFrom::Start(offset))?; Ok(Box::new(f)) } } // 1. Encrypt big_video.mp4 into a named temp file (constant memory). `&File` // implements Write, so the ciphertext is written straight to disk. let cipher_file = NamedTempFile::new()?; let info = encrypt_media_streaming( File::open("big_video.mp4")?, cipher_file.as_file(), MediaType::Video, )?; let len = cipher_file.as_file().metadata()?.len(); // ciphertext size // 2. Stream the ciphertext straight from the temp file to the CDN — the // blob is never buffered in memory. let source = FileSource { file: cipher_file, len }; let upload = client.upload_stream(source, info, MediaType::Video).await?; // upload.streaming_sidecar is propagated from the EncryptedMediaInfo. ``` Don't back a multi-attempt `UploadSource` with `File::try_clone()` — on Unix the cloned descriptor shares the original's file offset, so a retry or resume that calls `reader_from` again would move the cursor out from under any concurrent read. Reopen by path (as above) or use positional reads so each reader has an independent offset. If the ciphertext already fits comfortably in memory, skip the custom source and pass it directly — `bytes::Bytes` implements `UploadSource`, so `client.upload_stream(bytes::Bytes::from(ciphertext_vec), info, media_type)` works for the small-file case. The file-backed source above is what keeps memory constant for large media. ### UploadSource trait `upload_stream` accepts anything implementing `UploadSource`: ```rust theme={null} pub trait UploadSource: Send + Sync { /// Total length of the encrypted blob in bytes. fn len(&self) -> u64; fn is_empty(&self) -> bool { self.len() == 0 } /// A reader positioned at `offset`. Called once per upload attempt /// (re-invoked on retry / resume). fn reader_from(&self, offset: u64) -> std::io::Result>; } ``` Built-in implementations: | Type | Notes | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `std::sync::Arc<[u8]>` | In-memory shared buffer | | `bytes::Bytes` | **O(1) to construct** from an owned `Vec` (adopts the buffer instead of copying). Prefer this when the ciphertext was produced into a `Vec`. | Implement the trait yourself for a file-backed or network-backed source — `reader_from(offset)` is what makes resumable uploads work without re-encrypting. ### encrypted\_len ```rust theme={null} pub const fn encrypted_len(plaintext_len: usize) -> usize ``` Computes the exact ciphertext size for a given plaintext length (plaintext rounded up to the next 16-byte AES block, plus the 10-byte media MAC). Use it to pre-allocate an exact-fit `Vec` before streaming encryption, avoiding reallocation as the ciphertext grows. `upload()` is unchanged and still the right choice when the plaintext fits in memory — both paths share the same retry/resumable-upload machinery. Reach for `upload_stream` only when you want to bound memory for large media. *** ## Resumable uploads For files 5 MiB (5,242,880 bytes) or larger, the client automatically probes the CDN to check for a previous partial or complete upload of the same file before sending the full payload. ### Resume check flow 1. A `POST` request is sent to the upload URL with `?resume=1` appended 2. The server responds with one of three states: * **Complete** — the file already exists on the CDN. The existing `url` and `direct_path` are returned immediately with no upload * **Resume** — a partial upload exists. The client resumes from the given `byte_offset`, appending `&file_offset={offset}` to the URL and sending only the remaining bytes * **Not found** — no previous upload exists. A full upload proceeds normally The resume check is non-fatal. If the probe request itself fails (network error, unexpected response), the client silently falls back to a full upload. No special handling is needed in your code. ### Resume check endpoint ``` POST https://{media_host}/mms/{type}/{token}?auth={auth}&token={token}&resume=1 Origin: https://web.whatsapp.com ``` ### Resumed upload endpoint When a partial upload is detected at byte offset `N`: ``` POST https://{media_host}/mms/{type}/{token}?auth={auth}&token={token}&file_offset=N Content-Type: application/octet-stream Body: [encrypted bytes from offset N onward] ``` *** ## Media Encryption All media uploaded to WhatsApp is end-to-end encrypted before transmission: ### Encryption Process 1. **Generate keys**: Create random 32-byte `media_key` 2. **Derive keys**: Use HKDF-SHA256 with media type info string to derive: * 16-byte IV (initialization vector) * 32-byte cipher key * 32-byte MAC key 3. **Encrypt**: Apply AES-256-CBC with PKCS7 padding 4. **Compute MAC**: HMAC-SHA256 over IV + ciphertext, append first 10 bytes 5. **Upload**: POST encrypted bytes to WhatsApp CDN 6. **Return metadata**: `media_key`, hashes, and CDN path for message ### Key Derivation Each media type uses a specific HKDF info string: ```rust theme={null} MediaType::Image → "WhatsApp Image Keys" MediaType::Video → "WhatsApp Video Keys" MediaType::Audio → "WhatsApp Audio Keys" MediaType::Document → "WhatsApp Document Keys" MediaType::Sticker → "WhatsApp Image Keys" MediaType::StickerPack → "WhatsApp Sticker Pack Keys" MediaType::StickerPackThumbnail → "WhatsApp Sticker Pack Thumbnail Keys" MediaType::LinkThumbnail → "WhatsApp Link Thumbnail Keys" MediaType::History → "WhatsApp History Keys" MediaType::AppState → "WhatsApp App State Keys" MediaType::ProductCatalogImage → N/A (unencrypted) ``` The `media_key` is shared with recipients through the encrypted message, allowing them to decrypt the media. `ProductCatalogImage` is the exception — it is uploaded without encryption and has no media key. ### Encryption vs Decryption The encryption process is the inverse of download decryption: | Upload (Encryption) | Download (Decryption) | | ------------------------------ | --------------------------------- | | Generate `media_key` | Receive `media_key` in message | | Derive IV, cipher key, MAC key | Derive same keys from `media_key` | | Encrypt with AES-256-CBC | Decrypt with AES-256-CBC | | Append HMAC-SHA256 (10 bytes) | Verify HMAC-SHA256 | | Upload to CDN | Download from CDN | *** ## MediaType Specifies the type of media for encryption and CDN routing. ```rust theme={null} pub enum MediaType { Image, Video, Audio, Document, History, AppState, Sticker, StickerPack, StickerPackThumbnail, LinkThumbnail, ProductCatalogImage, } ``` JPEG, PNG, or other image formats. Uses `/mms/image` endpoint and `"WhatsApp Image Keys"` for HKDF. MP4 or other video formats. Uses `/mms/video` endpoint and `"WhatsApp Video Keys"` for HKDF. Audio files and voice notes. Uses `/mms/audio` endpoint and `"WhatsApp Audio Keys"` for HKDF. PDF, DOCX, ZIP, and other document formats. Uses `/mms/document` endpoint and `"WhatsApp Document Keys"` for HKDF. Sticker images (WebP format). Uses `/mms/image` endpoint and `"WhatsApp Image Keys"` for HKDF (same as images). History sync data. Uses `/mms/md-msg-hist` endpoint and `"WhatsApp History Keys"` for HKDF. App state sync data. Uses `/mms/md-app-state` endpoint and `"WhatsApp App State Keys"` for HKDF. Sticker pack ZIP files. Uses `/mms/sticker-pack` endpoint and `"WhatsApp Sticker Pack Keys"` for HKDF. Sticker pack thumbnail images (JPEG). Uses `/mms/thumbnail-sticker-pack` endpoint and `"WhatsApp Sticker Pack Thumbnail Keys"` for HKDF. Must be uploaded with the same `media_key` as the corresponding sticker pack ZIP — use `UploadOptions::new().with_media_key(...)`. Link preview thumbnails. Uses `/mms/thumbnail-link` endpoint and `"WhatsApp Link Thumbnail Keys"` for HKDF. Product catalog images for WhatsApp Business. **Unencrypted** — `is_encrypted()` returns `false`. Uses `/product/image` endpoint (not under the `/mms/` prefix). Matches WhatsApp Web's `CreateMediaKeys.js` behavior which skips encryption for this type. *** ## Upload Endpoint The upload endpoint is constructed as: ``` https://{media_host}{upload_path}/{token}?auth={auth}&token={token} ``` Where: * `{media_host}` — CDN hostname from media connection (primary hosts tried first) * `{upload_path}` — Media type path from `MediaType::upload_path()` (e.g., `/mms/image`, `/mms/video`, `/product/image`) * `{token}` — Base64url-encoded `file_enc_sha256` * `{auth}` — Media connection auth token Most media types use the `/mms/{type}` prefix, but `ProductCatalogImage` uses `/product/image` directly. The `upload_path()` method returns the correct path for each media type. Optional query parameters (added automatically for resumable uploads): * `resume=1` — probe for existing upload (resume check request) * `file_offset={N}` — resume upload from byte offset `N` The request uses: * **Method**: `POST` * **Content-Type**: `application/octet-stream` * **Origin**: `https://web.whatsapp.com` * **Body**: Encrypted media bytes (or remaining bytes when resuming) The upload automatically handles media connection refresh. If the connection is expired, it's renewed before uploading. If the CDN returns HTTP 401 or 403, the client invalidates the cached credentials, fetches a fresh auth token, and retries the upload once. *** ## Error Handling ### Automatic retry on auth errors The upload method automatically retries when WhatsApp's CDN returns HTTP 401 or 403. On an auth error, the client invalidates the cached media connection, fetches fresh credentials from WhatsApp's servers, and retries the upload once. If the retry also fails with an auth error, the error is returned to the caller. For non-auth HTTP errors (such as 500), the client tries the next available CDN host without refreshing credentials. Hosts are tried in priority order — primary hosts first, then fallback hosts. ### Recovering the host's status by type As of PR #1195, `upload_error_from_response` attaches the refusing host's status as a typed [`HttpStatusError`](/api/errors#httpstatuserror) node, not only into the error message. If every host in the failover list refuses, the status of the last refusal survives into the returned error and is recoverable with [`ErrorChainExt::http_status()`](/api/errors#error-chain-recovery) — no string matching required: ```rust theme={null} use whatsapp_rust::ErrorChainExt; match client.upload(data, media_type, Default::default()).await { Ok(upload) => { /* Success */ } Err(e) => { let cause: &(dyn std::error::Error + 'static) = e.as_ref(); match cause.http_status() { Some(401) | Some(403) => eprintln!("auth retry already exhausted: {e}"), // 500/502/503/504 are the statuses normally worth a retry; // 501/505 mean the request itself is unsupported and won't // succeed on a resend. Apply your own policy per status. Some(429) | Some(500) | Some(502) | Some(503) | Some(504) => { /* worth backing off and retrying later */ } Some(status) => eprintln!("upload host refused with {status}: {e}"), None => eprintln!("no HTTP refusal status recoverable, inspect the cause: {e}"), } } } ``` `None` doesn't mean no HTTP exchange took place — only that no *refused* status was attached to this error. See [download's note on `None`](/api/download#recovering-the-cdn-status-by-type) for the same distinction on the mirrored path. ### Common upload errors CDN returned error status. Auth errors (401/403) are retried automatically with fresh credentials. Other errors are tried against alternate CDN hosts. ```rust theme={null} use whatsapp_rust::ErrorChainExt; match client.upload(data, media_type, Default::default()).await { Err(e) => { let cause: &(dyn std::error::Error + 'static) = e.as_ref(); if let Some(status) = cause.http_status() { eprintln!("CDN error {status}: {e}"); // Retry or use fallback } else { eprintln!("Other error: {}", e); } } Ok(upload) => { /* Success */ } } ``` Media connection has no available CDN hosts. ```rust theme={null} Err(anyhow!("No media hosts")) ``` Failed to encrypt media (rare, usually indicates invalid input). ```rust theme={null} match client.upload(data, media_type, Default::default()).await { Err(e) if e.to_string().contains("encrypt") => { eprintln!("Encryption error: {}", e); } _ => {} } ``` ### Example: Upload with additional retry Auth errors (401/403) are retried automatically. For other transient failures, you can add your own retry logic: ```rust theme={null} use std::time::Duration; use tokio::time::sleep; let mut attempts = 0; let max_attempts = 3; let upload = loop { attempts += 1; match client.upload(data.clone(), MediaType::Image, Default::default()).await { Ok(upload) => break upload, Err(e) if attempts < max_attempts => { eprintln!("Upload attempt {} failed: {}", attempts, e); sleep(Duration::from_secs(2)).await; continue; } Err(e) => return Err(e), } }; println!("Upload succeeded after {} attempt(s)", attempts); ``` # USync Source: https://whatsapp-rust.jlucaso.com/api/usync Typed USync query engine for user sync, device lists, profiles, and bot metadata USync ("user sync") is the WhatsApp protocol used to batch-query per-user data: registration status, device lists, profile picture/status, business verification, bot profiles, and more. Higher-level helpers like [`is_on_whatsapp`](/api/contacts#is_on_whatsapp), [`get_user_info`](/api/contacts#get_user_info), and [`get_user_devices`](/api/signal#get_user_devices) already build on USync internally. `Client::query_usync` exposes the same typed query engine directly, for protocol combinations the specialized helpers don't cover — for example fetching a bot's profile, resolving a username, or reading `disappearing_mode`/`text_status` in the same request as a device-list lookup. Prefer the specialized helpers ([`Contacts`](/api/contacts), [`Signal::get_user_devices`](/api/signal#get_user_devices) via `client.signal()`) for common lookups — they also handle cache population and persistence. `query_usync` is a neutral operation: it only returns decoded wire data. ## Access `query_usync` is a direct method on `Client` (not behind a sub-accessor): ```rust theme={null} let response = client.query_usync(query).await?; ``` ## Building a query ```rust theme={null} pub fn new( mode: UsyncMode, context: UsyncContext, protocols: Vec, users: Vec, ) -> Result ``` `UsyncQuery::new` validates the whole query before it reaches the network. It requires at least one protocol and one user, and rejects duplicate protocol kinds. It also enforces per-user field consistency — for example, a `tc_token` requires the `Status` protocol to be selected, and `device_sync` requires `DevicesV2`. Deserializing a `UsyncQuery` from an external source runs this same validation, so a serialized input can't bypass it. **`UsyncMode`:** * `Query` (default) — contact lookups * `Full` — user info with more detail * `Delta` — incremental contact synchronization **`UsyncContext`:** * `Interactive` (default) — user-initiated operations * `Background` — background sync * `Message` — message-related operations * `Voip` — call setup refreshing device lists Neither `UsyncMode` nor `UsyncContext` is `#[non_exhaustive]`. `Delta` and `Voip` are new variants added in this release — an exhaustive `match` over either enum in existing code will fail to compile until the new arms are handled. ### `UsyncUser` Construct a query target from a JID, phone number, or username, then attach protocol-specific inputs with builder methods: ```rust theme={null} pub fn from_jid(jid: Jid) -> Self pub fn from_phone(phone: impl Into) -> Self pub fn from_username(username: impl Into) -> Self pub fn from_pn_jid(pn_jid: Jid) -> Self pub fn with_id(mut self, jid: Jid) -> Self pub fn with_pn_jid(mut self, jid: Jid) -> Self pub fn with_phone(mut self, phone: impl Into) -> Self pub fn with_known_lid(mut self, lid: Jid) -> Self pub fn with_device_sync(mut self, hint: UsyncDeviceSyncHint) -> Self pub fn with_persona_id(mut self, persona_id: impl Into) -> Self pub fn with_username(mut self, username: impl Into) -> Self pub fn with_username_pin(mut self, pin: impl Into) -> Self pub fn with_contact_type(mut self, contact_type: impl Into) -> Self pub fn with_tc_token(mut self, token: impl Into>) -> Self ``` `from_phone`/`with_phone` accept a digit-only phone string and canonicalize it to E.164 (`+`-prefixed) form automatically. A phone number is rejected by `UsyncQuery::new` (`UsyncValidationError::InvalidPhone`) if it has a leading zero, contains non-digit characters after the `+`, or exceeds 15 digits. [`Contacts::find_by_username`](/api/contacts#find_by_username) builds exactly this shape (`Contact` in LID addressing plus `BusinessVerifiedName`, one user via `from_username`/`with_username_pin`) to do a reverse username lookup, so you rarely need to hand-build it yourself. `UsyncDeviceSyncHint` carries cache hints for the `DevicesV2` subprotocol so the server can skip returning an unchanged device list: ```rust theme={null} pub const fn new() -> Self pub fn with_device_hash(mut self, device_hash: impl Into) -> Self pub const fn with_timestamp(mut self, timestamp: i64) -> Self pub const fn with_expected_timestamp(mut self, expected_timestamp: i64) -> Self ``` ### `UsyncProtocol` ```rust theme={null} pub enum UsyncProtocol { Contact { addressing_mode: UsyncAddressingMode }, DevicesV2, Status, TextStatus, DisappearingMode, BusinessVerifiedName, Picture, Lid, Username, BotProfileV1, Features(Vec), } ``` `UsyncAddressingMode` is `Pn` (default) or `Lid`. `UsyncFeature` lists the feature flags the `Features` subprotocol can query (`Document`, `Encrypt`, `EncryptBlocklist`, `EncryptContact`, `EncryptGroupGen2`, `EncryptImage`, `EncryptLocation`, `EncryptUrl`, `EncryptV2`, `Voip`, `MultiAgent`). ## Reading the response ```rust theme={null} pub struct UsyncResponse { pub protocol_states: Vec, pub users: Vec, } impl UsyncResponse { pub fn protocol_state(&self, protocol: UsyncProtocolKind) -> Option<&UsyncProtocolState> } pub struct UsyncUserResult { pub id: Option, // absent for contact-only results without a JID pub pn_jid: Option, pub protocols: Vec, } impl UsyncUserResult { pub fn protocol(&self, kind: UsyncProtocolKind) -> Option<&UsyncProtocolResult> } ``` Each per-user protocol result is wrapped in `UsyncOutcome`, which is either the decoded value or the server's per-subprotocol error — matching the same "errors don't fail the whole batch" behavior as `is_on_whatsapp`/`get_user_info`: ```rust theme={null} pub enum UsyncOutcome { Value(T), Error(Box), } impl UsyncOutcome { pub fn value(&self) -> Option<&T> pub fn error(&self) -> Option<&UsyncSubprotocolError> } ``` `UsyncProtocolResult` carries the typed payload per protocol: ```rust theme={null} pub enum UsyncProtocolResult { Contact(UsyncOutcome), Devices(UsyncOutcome), Status(UsyncOutcome), TextStatus(UsyncOutcome), DisappearingMode(UsyncOutcome), Business(UsyncOutcome>), Picture(UsyncOutcome), Lid(UsyncOutcome>), Username(UsyncOutcome>), Bot(UsyncOutcome>), Features(UsyncOutcome>), } ``` Payload structs, all `#[non_exhaustive]`: * **`UsyncContactResult`** — `contact_type: CompactString`, `username: Option`, `content: Option` * **`UsyncDevicesResult`** — `device_list: Option`, `key_index: Option` * `UsyncDeviceListResult` — `hash: Option`, `devices: Vec` * `UsyncDeviceResult` — `id: u16`, `key_index: Option`, `is_hosted: bool` * `UsyncKeyIndexResult` — `timestamp: i64`, `signed_key_index_bytes: Option>`, `expected_timestamp: Option` * **`UsyncStatusResult`** — `status: Option`, `timestamp: Option` (WhatsApp Web itself only consumes `status`; the wire timestamp is kept for callers that need it) * **`UsyncTextStatusResult`** — `text`, `emoji`, `ephemeral_duration_seconds`, `last_update_time` (all `Option`) * **`UsyncDisappearingModeResult`** — `duration_seconds: u32`, `setting_timestamp: i64`, `ephemerality_disabled: bool` * **`UsyncBusinessResult`** — `verified_name: Option` (see [`is_on_whatsapp`](/api/contacts#is_on_whatsapp) for `VerifiedName` fields); `pn_jid: Option`, the phone-number JID the server attaches to `` when the queried user was addressed by LID. It's the only place a username lookup can learn the PN, since that query never carries one. * **`UsyncFeatureResult`** — `feature: UsyncFeature`, `value: CompactString` * **`UsyncBotProfileResult`** — `name`, `attributes`, `description`, `category`, `is_default`, `prompts: Vec`, `persona_id`, `commands: Vec`, `commands_description`, `is_meta_created: Option`, `creator_name: Option`, `creator_profile_url: Option`, `posing_as_professional: Option` * `UsyncBotPrompt` — `emoji: CompactString`, `text: CompactString` * `UsyncBotCommand` — `name: CompactString`, `description: CompactString` * `UsyncBotProfessionalType` — `Unknown`, `Yes`, `No`, or an `Other(String)` catch-all for unrecognized wire values ## Example ```rust theme={null} use whatsapp_rust::usync::{ UsyncContext, UsyncMode, UsyncProtocol, UsyncProtocolKind, UsyncProtocolResult, UsyncQuery, UsyncUser, }; let query = UsyncQuery::new( UsyncMode::Query, UsyncContext::Interactive, vec![UsyncProtocol::BotProfileV1, UsyncProtocol::Username], vec![UsyncUser::from_jid(jid)], )?; let response = client.query_usync(query).await?; for user in &response.users { if let Some(bot) = user.protocol(UsyncProtocolKind::Bot) && let UsyncProtocolResult::Bot(outcome) = bot && let Some(profile) = outcome.value() { println!("Bot: {} ({})", profile.name, profile.category); } } ``` ## Validation errors ```rust theme={null} #[non_exhaustive] pub enum UsyncValidationError { EmptyProtocols, EmptyUsers, DuplicateProtocol(UsyncProtocolKind), EmptyFeatureSet, MissingUserIdentity { index: usize }, InvalidUserJid { index: usize, jid: String }, InvalidPnJid { index: usize }, EmptyPhone { index: usize }, InvalidPhone { index: usize }, EmptyUsername { index: usize }, UsernamePinWithoutUsername { index: usize }, InvalidKnownLid { index: usize }, EmptyDeviceHash { index: usize }, ConflictingContactInputs { index: usize }, ContactInputWithoutProtocol { index: usize }, DeviceSyncWithoutProtocol { index: usize }, TcTokenWithoutProtocol { index: usize }, PersonaIdWithoutProtocol { index: usize }, KnownLidWithoutProtocol { index: usize }, EmptySid, } ``` `Client::query_usync` surfaces a validation failure as `IqError::EncodeError` (the query never reaches the network). ## Hosted addressing `UsyncDeviceResult.is_hosted` (and the corresponding `is_hosted` field on the persisted `DeviceInfo`/`UsyncDevice` types — see [Store: DeviceListRecord](/api/store#devicelistrecord)) marks a device as belonging to WhatsApp's *hosted* PN/LID address space rather than the regular one. Use `Jid::with_device_hosting(device_id, is_hosted)` to build a correctly-addressed device JID from a device-list entry: ```rust theme={null} let device_jid = user_jid.with_device_hosting(device.id, device.is_hosted); ``` ## Breaking changes * **`DeviceInfo`** (`wacore::store::traits::DeviceInfo`) and **`UsyncDevice`** (`wacore::usync::UsyncDevice`) both gained an `is_hosted: bool` field. This breaks both construction and exhaustive pattern matching. Struct-literal construction (`DeviceInfo { device_id, key_index }`) no longer compiles — use the new constructors instead: ```rust theme={null} DeviceInfo::new(device_id, key_index).with_hosting(is_hosted) UsyncDevice::new(device, key_index).with_hosting(is_hosted) ``` An exhaustive destructuring pattern (`let DeviceInfo { device_id, key_index } = info;`) also no longer compiles — add a `..` to the pattern (`let DeviceInfo { device_id, key_index, .. } = info;`) or match on `is_hosted` as well. Persisted `DeviceInfo` JSON without `is_hosted` still deserializes correctly (`is_hosted` defaults to `false`) — this only affects Rust construction and pattern-matching call sites, not on-disk data. * **`UsyncMode::Delta`** and **`UsyncContext::Voip`** are new enum variants (see the warning above). * **`UsyncBusinessResult`** gained a `pn_jid: Option` field. `UsyncBusinessResult` is `#[non_exhaustive]`, so this is additive — no struct-literal construction or exhaustive match breaks. # wacore Source: https://whatsapp-rust.jlucaso.com/api/wacore Platform-agnostic WhatsApp protocol implementation ## Overview `wacore` is the core WhatsApp protocol implementation for whatsapp-rust. It's designed to be **platform-agnostic** with no runtime dependencies on Tokio or specific databases, making it portable across different async runtimes and storage backends. ```toml theme={null} [dependencies] wacore = "0.7" ``` ## Philosophy wacore contains all the core logic for: * Binary protocol encoding/decoding * Cryptographic primitives (AES-GCM, Signal Protocol) * IQ protocol types and specifications * Runtime abstraction (`Runtime` trait for pluggable async executors) * Network abstractions (`Transport`, `TransportFactory`, `HttpClient` traits) * State management traits (`Backend`, `SignalStore`, `AppSyncStore`, etc.) * Message builders and parsers It has **zero runtime dependencies** — no Tokio, no async-std, only `futures`, `async-trait`, `async-lock`, and `async-channel` for async primitives. This makes `wacore` portable to any async runtime, including WASM targets. The main `whatsapp-rust` crate provides concrete implementations (Tokio runtime, SQLite storage, ureq HTTP client, Tokio WebSocket transport). ## Key Exports ### Re-exported Crates ```rust theme={null} pub use aes_gcm; // AES-GCM encryption pub use wacore_appstate as appstate; // App state sync pub use wacore_noise as noise; // Noise Protocol pub use wacore_libsignal as libsignal; // Signal Protocol ``` ### Derive Macros ```rust theme={null} pub use wacore_derive::{EmptyNode, ProtocolNode, StringEnum}; ``` * **`EmptyNode`** - For protocol nodes with only a tag (no attributes) * **`ProtocolNode`** - For protocol nodes with string attributes * **`StringEnum`** - For enums with string representations See [Binary Protocol](/advanced/binary-protocol) for usage examples. ### Framing ```rust theme={null} pub use wacore_noise::framing; // WebSocket frame encoding/decoding ``` ## Core Modules ### Protocol & Binary Type-safe protocol node builders and parsers XML utilities for protocol nodes ### Cryptography Signal Protocol implementation for E2E encryption Noise Protocol XX for handshake encryption ### IQ Protocol ```rust theme={null} pub mod iq; ``` Type-safe IQ request/response specifications: * **`blocklist`** - Block/unblock contacts * **`chatstate`** - Typing indicators, presence * **`contacts`** - Contact synchronization * **`dirty`** - Dirty bit checking * **`groups`** - Group management operations * **`keepalive`** - Connection keepalive * **`mediaconn`** - Media server connections * **`mex`** - Message Extension queries * **`passive`** - Passive IQ handling * **`prekeys`** - Prekey distribution * **`privacy`** - Privacy settings * **`props`** - Server properties and A/B experiment configs. The companion [`abprops`](#a-b-props-registry) module ships the typed flag registry, and `props::WATCHED` lists the flags the library itself reads * **`spam_report`** - Spam reporting * **`tctoken`** - Temporary client tokens * **`usync`** - User synchronization. Ships a typed query/response model (`UsyncQuery`, `UsyncProtocol`, `UsyncResponse`, ...) covering every USync subprotocol observed in WhatsApp Web (device lists, contact/LID/username lookup, status, bot profiles, features). See [USync](/api/usync) See [Architecture](/concepts/architecture) for the IQ protocol pattern. #### A/B props registry `wacore::iq::abprops` is an auto-generated, vendored snapshot of WhatsApp Web's A/B-props registry. Each WA Web registry becomes a `pub mod` (currently `web`), and each flag becomes a typed `pub const AbProp` named after its key in screaming snake case (for example `web::PRIVACY_TOKEN_SENDING_ON_GROUP_CREATE`). Every entry carries: * `name` — the wire name the server uses in `` * `code` — the numeric `config_code` * `value_type` — `AbPropType::Bool`, `Int`, `Float`, or `Str` * `default` — the registry default applied when the server omits the flag Pass these constants to [`AbPropsCache`](/api/client#ab-props-cache) (`is_enabled`, `get`, `get_int`, `watch`, `watch_many`) instead of raw `u32` codes. The library only materializes the consts you reference, so the rest of the \~2,000-flag registry adds no binary weight. ```rust theme={null} use wacore::iq::abprops::web; let flag = web::ADMIN_REVOKE_RECEIVER; println!("{} (code {}) defaults to {:?}", flag.name, flag.code, flag.default); ``` `wacore::iq::props::WATCHED` is the slice of flags the library itself reads — useful as a starting point if you want to extend the cache's interest set. A small `props::stale` module preserves flags the client still references but that the current WA Web bundle no longer ships. ### Message Handling Message encryption and decryption Message sending logic Media download and decryption Media encryption and upload preparation ### State Management ```rust theme={null} pub mod store; ``` * **`Device`** - Core device state structure * **`DeviceCommand`** - State mutation commands * **`traits`** - Backend trait definitions (`Backend`, `SessionStore`, etc.) * **`ab_props`** - In-memory A/B experiment property cache (`AbPropsCache`), populated from `fetch_props()` on each connection. Features query this cache using typed flag constants from the [`abprops` registry](#a-b-props-registry) (e.g., privacy token attachment on group operations). See [State Management](/advanced/state-management) for the command pattern. ### Runtime & Networking ```rust theme={null} pub mod runtime; // Runtime trait, AbortHandle, timeout(), blocking() pub mod net; // Transport, TransportFactory, HttpClient, TransportEvent ``` The `runtime` module defines the `Runtime` trait that all async operations go through: ```rust theme={null} pub trait Runtime: Send + Sync + 'static { fn spawn(&self, future: Pin + Send + 'static>>) -> AbortHandle; /// Spawn a task nobody will cancel. Defaults to `self.spawn(future).detach()`. fn spawn_detached(&self, future: Pin + Send + 'static>>) { self.spawn(future).detach(); } fn sleep(&self, duration: Duration) -> Pin + Send>>; fn spawn_blocking(&self, f: Box) -> Pin + Send>>; fn yield_now(&self) -> Option + Send>>>; /// How often to yield in tight loops (every N items). Defaults to 10. fn yield_frequency(&self) -> u32 { 10 } } ``` | Method | Purpose | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `spawn` | Spawn a background task, returning an `AbortHandle` for cancellation. The handle is `#[must_use]` — dropping it aborts the task. Call `.detach()` for fire-and-forget tasks | | `spawn_detached` | Spawn a fire-and-forget task. The default is `spawn(future).detach()` — same cost as calling both yourself. Override it to skip building the `AbortHandle` entirely; the bundled `TokioRuntime` does, via a direct `tokio::spawn` (see [#1124](https://github.com/oxidezap/whatsapp-rust/pull/1124)) | | `sleep` | Return a future that completes after a duration | | `spawn_blocking` | Offload a blocking closure to a thread pool | | `yield_now` | Cooperatively yield; return `None` if unnecessary (e.g., multi-threaded runtimes) | | `yield_frequency` | How many items to process before yielding in tight loops (default: `10`) | #### Helper functions The `runtime` module also provides two runtime-agnostic helper functions that work with any `Runtime` implementation: **`timeout`** — Race a future against a deadline using the runtime's `sleep` implementation: ```rust theme={null} pub async fn timeout( rt: &dyn Runtime, duration: Duration, future: F, ) -> Result where F: Future, ``` Returns `Ok(value)` if the future completes before the duration, or `Err(Elapsed)` if it times out. This is the runtime-agnostic replacement for `tokio::time::timeout` — the library uses it internally for phash validation, media retry timeouts, session establishment, and IQ response waiting. **`blocking`** — Offload a blocking closure and return its result: ```rust theme={null} pub async fn blocking( rt: &dyn Runtime, f: impl FnOnce() -> T + Send + 'static, ) -> T ``` Wraps `Runtime::spawn_blocking` with a oneshot channel to ferry the closure's return value back to the caller. On WASM, the closure runs inline since there is only one thread. The `net` module defines the networking abstractions: * **`Transport`** — active connection for sending/receiving raw bytes * **`TransportFactory`** — creates new transport instances and event streams * **`HttpClient`** — HTTP request execution (buffered and streaming) * **`TransportEvent`** — `Connected`, `DataReceived(Bytes)`, `Disconnected` On WASM targets (`target_arch = "wasm32"`), all `Send` bounds are automatically removed. ### Connection & Pairing Noise Protocol handshake QR code pairing Phone number pairing SHORTCAKE\_PASSKEY companion-linking crypto (ephemeral identity commit/reveal, verification code, HKDF/AES-GCM pairing envelope, handoff proof) Transport and HTTP client traits `shortcake` is pure and platform-agnostic (no Tokio, wasm-buildable) — it only builds/parses the deterministic protocol payloads. The one non-reproducible step, obtaining a WebAuthn assertion, lives in `whatsapp_rust::passkey` (the `PasskeyAuthenticator` seam). See [Authentication — Passkey linking](/concepts/authentication#passkey-linking-shortcake_passkey). ### Specialized Features App state synchronization (contacts, settings) Message history synchronization User device list synchronization Signal Protocol prekey generation #### history\_sync types `HistoryMsgSecretRecord` carries the per-message secret extracted by the history-sync pipeline for E2E key expansion. ```rust theme={null} pub struct HistoryMsgSecretRecord { pub chat_id: Arc, // shared per conversation pub from_me: bool, pub key_participant: Option, pub web_msg_participant: Option, pub msg_id: CompactString, // inline for typical 20–22 char WA IDs pub secret: SecretBytes, // inline ≤32 bytes, heap for larger pub timestamp: Option, pub is_poll_or_event: bool, pub is_bot_invocation: bool, } ``` `SecretBytes` avoids heap allocation for secrets ≤32 bytes (covering the typical 32-byte Signal message secret) and falls back to `Vec` for larger values. ```rust theme={null} pub enum SecretBytes { Inline { len: u8, buf: [u8; 32] }, Heap(Vec), } impl SecretBytes { pub const INLINE_CAP: usize = 32; pub fn as_slice(&self) -> &[u8]; pub fn into_vec(self) -> Vec; } ``` Implements `Deref`, `From<&[u8]>`, `From>`, `PartialEq`, `Eq`, and `Debug`. Use `as_slice()` or deref for reads; `into_vec()` to convert to `Vec`. For `Bytes`-owned input, `wacore::history_sync` also exports: ```rust theme={null} pub fn process_history_sync_bytes( compressed_data: Bytes, own_user: Option<&str>, retain_blob: bool, ) -> Result pub fn process_history_sync_bytes_filtered( compressed_data: Bytes, own_user: Option<&str>, retain_blob: bool, record_filter: F, ) -> Result where F: for<'a> FnMut(HistoryMsgSecretRecordRef<'a>) -> bool ``` `own_user` scopes `from_me` classification to the local account's JID (pass `None` if that check should be skipped). `retain_blob` controls whether the returned `HistorySyncResult` keeps a `Bytes` handle on the original compressed input (`compressed_bytes`, consumed by `LazyHistorySync`) — pass `false` to drop it immediately after parsing. `process_history_sync_bytes` is a `Bytes`-based sibling of `process_history_sync` (unchanged) — both accept every record. `process_history_sync_bytes_filtered` additionally takes a `record_filter` predicate that runs against a borrowed `HistoryMsgSecretRecordRef<'a>` before its owned `HistoryMsgSecretRecord` counterpart is allocated, so a caller that owns a retention policy (for example, dropping records outside a retention window) can reject them without paying for materialization: ```rust theme={null} pub struct HistoryMsgSecretRecordRef<'a> { pub conversation_index: usize, pub chat_id: &'a str, pub from_me: bool, pub key_participant: Option<&'a str>, pub web_msg_participant: Option<&'a str>, pub msg_id: &'a str, pub secret: &'a [u8], pub timestamp: Option, pub is_poll_or_event: bool, pub is_bot_invocation: bool, } ``` `conversation_index` is a zero-based counter shared across records from the same conversation, letting a filter cache per-conversation classification instead of recomputing it per record. See [Architecture — RAM optimization layers](/concepts/architecture#ram-optimization-layers) for the measured allocation win. If you want to skip the intermediate owned `HistoryMsgSecretRecord` entirely, `wacore::history_sync` also exports a streaming visitor path: ```rust theme={null} pub trait HistoryMsgSecretRecordVisitor { fn visit(&mut self, record: HistoryMsgSecretRecordRef<'_>) -> usize; fn reserve(&mut self, _additional: usize) {} fn retained_item_size(&self) -> Option { None } } pub fn process_history_sync_bytes_with_record_visitor( compressed_data: Bytes, own_user: Option<&str>, retain_blob: bool, visitor: F, ) -> Result where F: for<'a> FnMut(HistoryMsgSecretRecordRef<'a>) pub fn process_history_sync_bytes_with_record_sink( compressed_data: Bytes, own_user: Option<&str>, retain_blob: bool, visitor: V, ) -> Result where V: HistoryMsgSecretRecordVisitor ``` `process_history_sync_bytes_with_record_visitor` takes a plain `FnMut` closure for the common case. The closure builds your own row directly from the borrowed `HistoryMsgSecretRecordRef`. This ensures the owned `HistoryMsgSecretRecord` is never allocated. `process_history_sync_bytes_with_record_sink` takes a full `HistoryMsgSecretRecordVisitor` implementation instead of a closure. The `visit` return value reports the byte size you retained for that record for accounting. The optional `reserve` and `retained_item_size` hooks let you size your own collection (e.g., a batched SQL insert buffer) up front rather than growing it one record at a time. Reach for `process_history_sync_bytes_filtered` (above) when a simple accept/reject predicate is enough. Reach for the visitor/sink pair when you also want to avoid materializing the owned record. ### Time ```rust theme={null} pub mod time; ``` The `time` module centralizes all timestamp handling. It exposes two independent clocks that should not be confused: * **Wall clock** (`TimeProvider`, `now_millis`, `now_utc`) — answers "what time is it?". May jump backwards across NTP syncs, manual adjustments, or leap-second smearing. Backed by `chrono::Utc::now()` on native targets. * **Monotonic clock** (`MonotonicProvider`, `Instant`) — answers "how much time passed?". Never moves backwards and is immune to NTP adjustments. Backed by `std::time::Instant` on native targets. On `wasm32-unknown-unknown` there is no built-in wall-clock source. If you do not register a provider before the first timestamp, `now_millis()` returns `0` (Unix epoch) and logs a single warning. Always call `set_time_provider` with a `Date.now()`-backed (or equivalent) provider during WASM startup — see [Custom wall-clock provider](#custom-wall-clock-provider) below. Use the wall clock for stanza timestamps, app-state mutations, and log lines. Use the monotonic clock for timeouts, retry backoff, and latency measurements. Conflating the two silently corrupts elapsed-time logic whenever the system clock is adjusted mid-measurement. Each clock can be overridden globally — useful in environments where the standard implementations are unavailable (e.g., WASM) or for deterministic testing. #### Wall-clock functions | Function | Return type | Description | | ------------------------ | ----------------------- | ----------------------------------------------------------------------- | | `now_millis()` | `i64` | Current time in milliseconds since Unix epoch | | `now_secs()` | `i64` | Current time in seconds since Unix epoch | | `now_utc()` | `DateTime` | Current time as a `chrono::DateTime` | | `from_secs(ts)` | `Option>` | Convert Unix timestamp (seconds) to `DateTime` | | `from_secs_or_now(ts)` | `DateTime` | Like `from_secs`, falling back to `now_utc()` for out-of-range values | | `from_millis(ts)` | `Option>` | Convert Unix timestamp (milliseconds) to `DateTime` | | `from_millis_or_now(ts)` | `DateTime` | Like `from_millis`, falling back to `now_utc()` for out-of-range values | #### Custom wall-clock provider Implement the `TimeProvider` trait and call `set_time_provider` before any time functions are used: ```rust theme={null} use wacore::time::{TimeProvider, set_time_provider}; struct FixedTime; impl TimeProvider for FixedTime { fn now_millis(&self) -> i64 { 1700000000000 // Fixed timestamp for testing } } // Must be called before any time functions are used set_time_provider(FixedTime).expect("provider already set"); ``` `set_time_provider` returns `Err` if a provider has already been set. The provider uses `OnceLock` internally, so it can only be configured once per process. On `wasm32` targets the epoch fallback is not cached, so a later `set_time_provider` call still takes effect even if a timestamp was read during startup. A typical browser embedder wires `Date.now()` through `wasm-bindgen`: ```rust theme={null} use wacore::time::{TimeProvider, set_time_provider}; struct JsDateProvider; impl TimeProvider for JsDateProvider { fn now_millis(&self) -> i64 { js_sys::Date::now() as i64 } } // Register before constructing the client or sending stanzas. set_time_provider(JsDateProvider).expect("provider already set"); ``` #### Instant `Instant` is a portable monotonic instant that replaces `std::time::Instant`, which is unavailable on `wasm32-unknown-unknown`. On native targets it wraps `std::time::Instant` via the default `MonotonicProvider` and exposes nanosecond resolution. The `Instant` type is `Copy` and supports `Add` and `Sub` (returning `Duration`), with saturating arithmetic to prevent overflow. ```rust theme={null} use wacore::time::Instant; let start = Instant::now(); // ... do work ... let elapsed = start.elapsed(); // std::time::Duration ``` #### Custom monotonic provider Implement the `MonotonicProvider` trait and call `set_monotonic_provider` before any `Instant` is captured. The provider must return nanoseconds since an arbitrary fixed reference and never return a smaller value than a previous call. ```rust theme={null} use wacore::time::{MonotonicProvider, set_monotonic_provider}; struct PerfNowProvider; impl MonotonicProvider for PerfNowProvider { fn now_nanos(&self) -> u64 { // e.g., wrap performance.now() in browsers, or hrtime in Node (js_sys::performance().now() * 1_000_000.0) as u64 } } set_monotonic_provider(PerfNowProvider).expect("provider already set"); ``` On `wasm32` targets without a registered provider, the fallback derives nanos from the wall clock (clamped to non-decreasing) and quantizes to milliseconds. Embedders targeting browsers, Node, or WASI should register a sub-millisecond provider for accurate latency measurements. `set_monotonic_provider` returns `Err` if a provider has already been set. Like the wall-clock provider, it uses `OnceLock` internally and can only be configured once per process. ### Utilities * **`client`** - Client context traits * **`ib`** - Identity byte utilities * **`proto_helpers`** - Protobuf conversion helpers * **`reporting_token`** - Reporting token generation * **`request`** - Request building utilities * **`stanza`** - Common stanza builders * **`sticker_pack`** - Sticker pack creation helpers (see [sticker packs](/guides/sending-messages#sticker-packs)) * **`time`** - Pluggable wall-clock and monotonic-clock providers, plus portable `Instant` (see [time](#time) above) * **`types`** - Common type definitions (JID, events, messages). Includes `types::jid` utilities for zero-allocation JID comparison (`cmp_for_lock_order`), buffer-reusing address formatting (`write_protocol_address_to`), and in-place sorted deduplication (`sort_dedup_by_user`, `sort_dedup_by_device`) * **`version`** - WhatsApp version constants * **`webp`** - WebP format utilities (animated sticker detection) ### webp module The `webp` module provides utilities for working with WebP image files. It is re-exported as `whatsapp_rust::webp`. #### is\_animated Detects whether a WebP file contains animation frames by parsing RIFF/VP8X headers and scanning for ANIM/ANMF chunks. ```rust theme={null} pub fn is_animated(data: &[u8]) -> bool ``` Raw WebP file bytes. Returns `true` if the WebP file is animated, `false` otherwise (including for invalid or too-short input). **Example:** ```rust theme={null} use whatsapp_rust::webp; let webp_bytes = std::fs::read("sticker.webp")?; if webp::is_animated(&webp_bytes) { println!("Animated sticker"); } else { println!("Static sticker"); } ``` This function is used internally by `create_sticker_pack_zip` to set the `is_animated` field on each sticker proto entry. You can also use it directly when you need to classify WebP files before processing. ## Submodule Packages wacore is split into several workspace crates: ### wacore-binary **Location:** `wacore/binary` Binary protocol encoding/decoding using WhatsApp's custom format. ```rust theme={null} use wacore_binary::{ CompactString, jid::Jid, node::Node, builder::NodeBuilder, marshal::{marshal, unmarshal_ref}, }; let node = NodeBuilder::new("message") .attr("type", "text") .attr("to", "15551234567@s.whatsapp.net") .build(); let bytes = marshal(&node)?; let decoded = unmarshal_ref(&bytes)?; ``` **Key exports:** * `CompactString` - Re-export of `compact_str::CompactString`, used by `Jid.user`, `NodeValue::String`, and `NodeContent::String` * `jid::{Jid, JidRef, Server, JidExt}` - WhatsApp JID types. `Server` is an enum (`#[repr(u8)]`) with variants for all known WhatsApp server domains (`Pn`, `Lid`, `Group`, `Broadcast`, `Newsletter`, `Hosted`, `HostedLid`, `Messenger`, `Interop`, `Bot`, `Legacy`). `JidExt` provides helper methods (`is_group()`, `is_newsletter()`, etc.) for both owned and borrowed JID types. `jid::parse_jid_ref(s: &str) -> Option>` parses the common user/group/LID/bot shapes directly into a borrowed `JidRef` with no allocation; it returns `None` for edge cases the compatibility fallback handles, so callers that need those cases parse via `s.parse::()` instead. `Jid`'s own `FromStr` impl is built on top of `parse_jid_ref` * `node::{Node, NodeRef, NodeStr, NodeValue, ValueRef, OwnedNodeRef, AttrsVec}` - Protocol node types. `Node` (owned) for building outgoing stanzas, `NodeRef` (borrowed) for reading received stanzas, `NodeStr` for borrowed-or-inline decoded strings, `OwnedNodeRef` for yoke-based zero-copy self-referential nodes shared as `Arc`. `AttrsVec` is `SmallVec<[(Cow<'static, str>, NodeValue); 2]>`, the inline-capable backing store for `Attrs` (≤2 attrs stay on the stack; see [Binary Protocol](/advanced/binary-protocol#inline-attribute-storage)). The entire `NodeRef` type family (`NodeRef`, `NodeStr`, `ValueRef`, `JidRef`, `NodeContentRef`, `OwnedNodeRef`) implements `serde::Serialize`, producing output identical to their owned counterparts — enabling zero-copy serialization without converting to `Node` first * `builder::NodeBuilder` - Fluent node builder with `new(&'static str)` / `new_dynamic(String)`, `attr()`, `jid_attr()`, `children()`, `bytes()`, `string_content()`, and `apply_content()` chaining methods * `marshal::*` - Binary marshaling functions * `attrs::{AttrParser, AttrParserRef}` - Attribute parsing utilities for owned `Node` and borrowed `NodeRef` respectively * `token` - Token dictionary ### wacore-libsignal **Location:** `wacore/libsignal` Signal Protocol implementation for end-to-end encryption. ```rust theme={null} use wacore::libsignal::{ core::SessionCipher, protocol::{PreKeyBundle, PublicKey}, store::SessionStore, }; ``` **Key modules:** * `core` - Core session cipher logic * `crypto` - Cryptographic primitives (HKDF, HMAC, AES) * `protocol` - Protocol message types * `store` - Store trait definitions See [Signal Protocol](/advanced/signal-protocol) for encryption details. ### wacore-noise **Location:** `wacore/noise` Noise Protocol implementation for handshake encryption. Supports the XX, IK, and XXfallback patterns to match WhatsApp Web's behavior on cold start, resumed reconnects, and server-driven recovery. ```rust theme={null} use wacore::noise::{ NoiseHandshake, HandshakeUtils, XxHandshakeState, IkHandshakeState, XxFallbackHandshakeState, IkServerHelloOutcome, VerifiedServerCertChain, build_handshake_header, }; ``` **Key exports:** * `NoiseState` - Generic Noise state machine * `NoiseHandshake` - WhatsApp-specific handshake wrapper * `XxHandshakeState` - Three-message XX handshake (cold start / fallback) * `IkHandshakeState` - Resumed IK handshake using a cached server static * `XxFallbackHandshakeState` - Server-driven recovery from a stale IK static, continuing the existing transcript * `IkServerHelloOutcome` - Either `Continue` (IK succeeds) or `Fallback` (pivot into XXfallback) * `VerifiedServerCertChain` - Output of XX/XXfallback; persisted by the client to enable IK on the next connect * `HandshakeUtils` - Protocol message building/parsing * `framing` - WebSocket frame encoding * `build_edge_routing_preintro` - Edge routing helper See [WebSocket & Noise Protocol — Noise Protocol Handshake](/advanced/websocket-handling#noise-protocol-handshake) for the full pattern selection and failure-handling logic. ### wacore-appstate **Location:** `wacore/appstate` App state synchronization for contacts, settings, and metadata. ```rust theme={null} use wacore::appstate::{ process_snapshot, process_patch, expand_app_state_keys, Mutation, }; ``` **Key exports:** * `process_snapshot` - Process full state snapshots * `process_patch` - Apply incremental patches * `Mutation` - State mutation records * `LTHash` - LTHash implementation for integrity * `expand_app_state_keys` - Key derivation ### wacore-derive **Location:** `wacore/derive` Procedural macros for protocol node generation. ```rust theme={null} use wacore::{EmptyNode, ProtocolNode, StringEnum}; #[derive(EmptyNode)] #[protocol(tag = "participants")] pub struct ParticipantsRequest; #[derive(ProtocolNode)] #[protocol(tag = "query")] pub struct QueryRequest { #[attr(name = "request", default = "interactive")] pub request_type: String, } #[derive(StringEnum)] pub enum Action { #[str = "block"] Block, #[str = "unblock"] Unblock, } ``` ## Usage in main library The main `whatsapp-rust` crate uses wacore modules throughout: ```rust theme={null} // Binary protocol use wacore_binary::jid::Jid; use wacore_binary::builder::NodeBuilder; // Signal Protocol use wacore::libsignal::store::SessionStore; use wacore::libsignal::protocol::PreKeyBundle; // App state use wacore::appstate::{ process_snapshot, Mutation, }; // IQ specs use wacore::iq::privacy as privacy_settings; // Store traits use wacore::store::traits::Backend; // Protobuf helpers use wacore::proto_helpers; ``` ## Design Principles ### Platform-Agnostic No dependencies on: * Tokio or any async runtime — uses only `futures`, `async-trait`, `async-lock`, `async-channel` * Specific database implementations * File system operations This allows users to provide their own: * **Runtime:** Tokio (default), async-std, smol, WASM, etc. — implement `Runtime` (4 methods) * **Storage:** SQLite (default), PostgreSQL, in-memory, etc. — implement `Backend` (4 sub-traits) * **Transport:** Tokio WebSocket (default), custom protocols — implement `TransportFactory` + `Transport` * **HTTP client:** ureq (default), reqwest, surf, etc. — implement `HttpClient` ### Type Safety Strong typing throughout: * `Jid` with `Server` enum for WhatsApp identifiers — server type is an enum variant, not a string * `Node` / `NodeRef` for protocol messages (owned / borrowed) * Validated newtypes (e.g., `GroupSubject` with length limits) * Enum variants with `StringEnum` for protocol values ### Zero-copy where possible * `Cow<'static, str>` for owned `Node.tag` and `Attrs` keys — known protocol strings (from the token dictionary) are borrowed as static references with zero heap allocation, while unknown strings fall back to owned `String` * `NodeStr<'a>` for borrowed `NodeRef.tag`, `AttrsRef` keys, `ValueRef::String`, and `JidRef.user` — a borrowed-or-inline string type where the `Owned` variant uses `CompactString` (inline up to 24 bytes) instead of heap-allocated `String`, reducing allocation pressure during decoding * `Server` enum (`#[repr(u8)]`) for `Jid.server` — a `Copy` type that requires zero allocation, replacing the previous `Cow<'static, str>` string-based server field * `OwnedNodeRef` — yoke-based self-referential node that owns the decompressed network buffer while `NodeRef` borrows string/byte payloads directly from it. Received stanzas flow through the system as `Arc` for cheap shared zero-copy access * **Zero-copy `Serialize`** — the entire `NodeRef` type family (`NodeRef`, `NodeStr`, `ValueRef`, `JidRef`, `NodeContentRef`, `OwnedNodeRef`) implements `serde::Serialize`, producing output identical to their owned counterparts. This allows serializing received stanzas directly from the network buffer without converting to owned `Node` types first. See [Binary Protocol — Zero-copy serialization](/advanced/binary-protocol#zero-copy-serialization) for details * `NodeRef` for borrowed node parsing * `AttrParserRef` for attribute iteration * `marshal_ref` for encoding without cloning ## Benchmarks The project includes two categories of benchmarks, both divan suites tracked on CodSpeed: **protocol-level**, measured under the instruction-count and memory instruments, and **integration-level**, real client operations measured under the instruction-count instrument only. ### Protocol benchmarks (divan / CodSpeed) wacore includes a suite of [divan](https://github.com/nvzqz/divan) benchmarks — through the `codspeed-divan-compat` harness — that measure core protocol operations. Locally they run as ordinary divan wall-time benchmarks; in CI the `codspeed.yml` workflow runs them under [CodSpeed](https://codspeed.io)'s deterministic instrumentation for low-noise regression tracking, sharded across two jobs (`wacore`/`wacore-noise` and `wacore-binary`/`wacore-libsignal`/`wacore-appstate`). #### Prerequisites * **Nightly Rust** (the project pins `nightly-2026-06-16`) * Nothing else for a local `cargo bench` — the CodSpeed runner supplies the Valgrind-based instrumentation in CI Bench auto-discovery is off (`autobenches = false`): only the explicit `[[bench]]` targets in each crate's `Cargo.toml` are built, so a stray file under `benches/` is never compiled as a phantom bench. #### Available suites | Suite | Location | What it measures | | --------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `send_receive_benchmark` | `wacore/benches/send_receive_benchmark.rs` | Full send/receive pipeline — DM send, DM receive, group send (steady-state skmsg), group send with SKDM distribution (10/50/256 participants), and group receive. Uses real `prepare_peer_stanza` and `prepare_group_stanza` functions with in-memory Signal stores | | `reporting_token_benchmark` | `wacore/benches/reporting_token_benchmark.rs` | Reporting token generation — key derivation, token calculation, and full generation pipeline for simple and extended messages | | `token_lookup_benchmark` | `wacore/benches/token_lookup_benchmark.rs` | Protocol token resolution through the compile-time `tiny_map` dispatch | | `history_sync_benchmark` | `wacore/benches/history_sync_benchmark.rs` | History-sync payload decompression and parsing | | `appstate_sync_benchmark` | `wacore/benches/appstate_sync_benchmark.rs` | App-state mutation decode and patch application | | `message_utils_benchmark` | `wacore/benches/message_utils_benchmark.rs` | Message helper paths (padding, secret derivation, edit/revoke plumbing) | | `sender_key_derivation_benchmark` | `wacore/benches/sender_key_derivation_benchmark.rs` | Sender-key chain derivation, including the prewarm path | | `signal_address_probe_benchmark` | `wacore/benches/signal_address_probe_benchmark.rs` | Server-aware Signal address lookup probes | | `sframe_varint_benchmark` | `wacore/benches/sframe_varint_benchmark.rs` | SFrame varint header codec (requires the `voip` feature) | | `voip_benchmark` | `wacore/benches/voip_benchmark.rs` | VoIP media-path hot loops (requires the `voip-mlow` feature). Under the `bench-internals` feature, adds a `codec_stages` group — nine rows attributing MLow encoder CPU to a stage (`analyze_frame`, `entropy_encode`, `lpc_front_end`, `fft512_forward`, `fft576_roundtrip`, `perc_model_frame`, `pitch_search`, `lsf_quantize`, `celp_subframes_frame`) via the `wacore::voip::mlow::stage_bench` harness, so a codec-internal change shows *which* stage moved instead of only moving the single `mlow_encode` row. `fft512_forward`/`fft576_roundtrip` measure the half-length real-FFT analysis path described in [VoIP Calls](/guides/voip-calls#mlow-codec) — the encoder's only FFT path, not an opt-in one | | `binary_benchmark` | `wacore/binary/benches/binary_benchmark.rs` | Binary protocol encoding/decoding — marshal and unmarshal operations for various node sizes and structures | | `jid_benchmark` | `wacore/binary/benches/jid_benchmark.rs` | JID parsing, formatting, and equality | | `appstate_benchmark` | `wacore/appstate/benches/appstate_benchmark.rs` | App-state hash/MAC primitives | | `libsignal_benchmark` | `wacore/libsignal/benches/libsignal_benchmark.rs` | Signal Protocol operations — session establishment, message encrypt/decrypt, sender key distribution, and group cipher operations | #### Running protocol benchmarks ```bash theme={null} # Every suite in the default workspace members. Not `--workspace`: that also # picks up bench-integration, which needs the mock server described below cargo bench # Run a specific benchmark suite cargo bench -p wacore --bench send_receive_benchmark cargo bench -p wacore --bench reporting_token_benchmark cargo bench -p wacore-binary --bench binary_benchmark cargo bench -p wacore-libsignal --bench libsignal_benchmark # Feature-gated suites cargo bench -p wacore --features voip --bench sframe_varint_benchmark # Per-stage MLow codec attribution — adds the codec_stages rows to voip_benchmark. # `bench-internals` alone is intentionally not enough to select the target: the # bench's `required-features` is `["voip-mlow"]` only, so `codec_stages` is # `#[cfg]`-gated inside the bench body instead of pulled in through Cargo's # required-features (see CI integration below for why that distinction matters). cargo bench -p wacore --features voip-mlow,bench-internals --bench voip_benchmark ``` ### Integration benchmarks The `bench-integration` suite (`tests/bench-integration/benches/integration.rs`) drives real client operations end-to-end against a mock server. It is a divan bench target as well, so it reports through CodSpeed rather than the custom timing/allocation binary it replaced — under the instruction-count instrument only, so it yields no allocation figures. It installs a `DeterministicAlloc` global allocator whose `realloc` always allocates-and-copies — the system allocator's in-place growth depends on live heap layout and would otherwise be charged to the benchmark as run-to-run memory noise. #### Scenarios | Scenario | What it measures | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `connect_to_ready` | Client creation through `Connected` event — includes transport connect, Noise handshake, and initial sync | | `send_message` | DM send (sender side) — protobuf encoding, Signal encrypt, node marshal, and WebSocket write. Runs once per batch size, so per-operation cost is visible against the fixed setup | | `send_and_receive` | Full round-trip — send a DM and wait for delivery on the receiver, also across batch sizes | | `reconnect` | Disconnect and reconnect cycle — the cost of re-establishing a session | #### Running integration benchmarks These benchmarks cannot run without the mock server ([Bartender](https://github.com/whiskeysockets-devtools/bartender)) reachable at `MOCK_SERVER_URL`, so in practice they execute in CI under `cargo codspeed run`: ```bash theme={null} # Start the mock server (Docker) docker run -d -p 8080:8080 ghcr.io/whiskeysockets-devtools/bartender:latest # Run the benchmarks MOCK_SERVER_URL="wss://127.0.0.1:8080/ws/chat" \ cargo bench -p bench-integration ``` Integration benchmarks require the `danger-skip-tls-verify` feature, which is enabled automatically via the `bench-integration` crate's `Cargo.toml`. `tests/bench-integration` is not a workspace default member, so `-p bench-integration` is required — a bare invocation discovers no benchmarks for it. ### Allocation optimizations The library includes several allocation-reduction strategies that the integration benchmarks track: * **Shared thread-local zlib pool** — The one-shot binary protocol decompressor (`decompress_zlib_pooled`) and the streaming `InflateReader` (used by `NodeStream`, see [Binary Protocol — Streaming Decode](/advanced/binary-protocol#streaming-decode-nodestream)) share one pool of `zlib_rs::Inflate` states per thread instead of keeping separate thread-local states, so a thread that runs both doesn't retain two blocks. Each state is \~47.5 KB. `warm_pool()` builds this thread's state ahead of its first compressed payload, while the heap is still fresh, rather than leaving the first large frame to find one. `set_pool_retention(n)` sets how many states a thread parks between uses (default `1`, `0` = never park; takes effect on the next park). `drain_pool()` releases this thread's parked states, returning their memory. `parked_states()` reports how many this thread currently holds * **`CompactString` for JIDs** — JID user fields use `compact_str::CompactString` which stores strings up to 24 bytes inline (no heap allocation), covering the vast majority of phone numbers and LID identifiers * **`Server` enum** — JID server fields use a `#[repr(u8)]` enum instead of heap-allocated strings, making JID construction and comparison zero-allocation * **Zero-copy node decoding** — Received stanzas are decoded as `NodeRef` borrowing directly from the network buffer via `OwnedNodeRef` (yoke-based self-referential type), avoiding cloning string/byte payloads during decode * **`Arc` sharing in `LidPnEntry`** — `LidPnEntry.lid` and `.phone_number` are `Arc` instead of `String`. `LidPnCache` reuses the entry's own `Arc`s as the map keys for both lookup directions, so each identifier is allocated once per mapping rather than once as key and again inside the entry. Because this cache is unbounded by design, the saving compounds over the contact base (\~40–80 B per mapping, \~0.5–1 MB for 10k contacts). Constructors accept `impl Into>`, so `String` and `&str` call sites are unaffected; direct field reads return `Arc` — use `&*entry.lid` for `&str` comparisons. Wire/persistence format is unchanged. * **Pre-allocated buffers** — History sync decompression uses a `compressed_size_hint` with a 4x multiplier for buffer pre-allocation, reducing `Vec` reallocation during decompression * **Inline attribute storage** — `Attrs` uses `AttrsVec` (`SmallVec<[...; 2]>`) instead of `Vec`. Nodes with ≤2 attributes (the common per-recipient fanout shapes `to` and `enc`) keep their attributes on the stack alongside the node, eliminating the per-node allocation for attribute storage. Allocation count: −27% per DM stanza (15→11 allocs); −40% for group fanout with 800 participants (4012→2412 allocs) * **Secret-presence pre-scan in history sync** — before buffa decodes a `HistorySyncMsg` (30+ fields, String allocations), a shallow varint walk checks whether the message carries `message_secret` at any level. Messages without a secret are skipped entirely. Bench (20k messages, secret-dense fixture): −29.5% allocations (56,020 → 39,520), −4.3% allocated bytes. Production blobs are secret-sparse so the saving is larger in practice * **Inline storage in `HistoryMsgSecretRecord`** — `chat_id` is `Arc` (allocated once per conversation, shared across all records in that conversation), `msg_id` is `CompactString` (inline for typical 20–22 char WA IDs on 64-bit targets; smaller inline limit on 32-bit/wasm32), and `secret` is `SecretBytes` (inline for secrets ≤32 bytes, heap for larger) * **Filter-before-materialize in history sync** — `process_history_sync_bytes_filtered` runs a caller-supplied retention predicate against a borrowed [`HistoryMsgSecretRecordRef`](#history_sync-types) before the owned `HistoryMsgSecretRecord` is built; a rejected record is never materialized. Bench (500-conversation blob, upstream PR's rejection-heavy fixture): allocation churn 21.61 MiB → 14.00 MiB, allocation count \~84k → \~26k. The reduction scales with how much of the record set the predicate rejects — the default `process_history_sync_bytes` (accept-all) sees none of it * **Streaming visitor/sink for history-sync records** — `process_history_sync_bytes_with_record_visitor` and `process_history_sync_bytes_with_record_sink` (via the [`HistoryMsgSecretRecordVisitor`](#history_sync-types) trait) go a step further than the filter predicate above. You can build your own storage row directly from the borrowed `HistoryMsgSecretRecordRef`. This ensures the intermediate owned `HistoryMsgSecretRecord` is never allocated at all. Bench (allocator-instrumented synthetic history extraction): 20.20 MiB → 14.43 MiB allocated (-28.6%); CodSpeed history stream-drain memory: 243.8 KB → 115.2 KB (2.1× less) * **Borrowed JID parsing** — [`jid::parse_jid_ref`](#wacore-binary) parses common protocol JIDs into a `JidRef` with zero allocation, falling back to `Jid`'s owned parser only for edge cases. Cut allocations 4,093 → 97 in the history-sync task's JID classification path * **In-place buffered media decrypt** — non-streaming `HttpClient` downloads now authenticate the already-buffered response body and decrypt AES-256-CBC in place (`DownloadUtils::verify_and_decrypt_in_place`, see [download](/api/download#downloadutils)), truncating the MAC/padding tail instead of allocating a second file-sized output buffer. Streaming clients are unaffected. Buffered download/decrypt span: 4.355 MiB → 2.146 MiB * **Stack arrays in the MLow codec's analysis internals** — `smpl_nlsf2a` sizes its scratch buffers by the fixed LPC order (16); it now stores three of its four `Vec`s as stack arrays instead. `smpl_lsf_quant::get_maxi_k` bounds its `used` mask by `n ≤ 17` and now stores it on the stack too, the same fix `smpl_celp::smpl_get_maxi_k` already carries. `CelpEncoder::encode_subframe` used to allocate a `vec![0.0f32; 160]` scratch buffer fresh inside its rate loop; it now pools that buffer and hoists it out of the loop. None of the three touch floating-point arithmetic — the `voip::mlow` golden-checksum tests pin that. Whole-frame `mlow_encode`: 633 → 534 allocations (−15.6%), 519.8 KB → 499.2 KB (−4.0%) ([#1320](https://github.com/oxidezap/whatsapp-rust/pull/1320)) * **Range storage and inline return for `participant_list_hash`** — `MessageUtils::participant_list_hash` renders every device into one shared arena and sorts range views over it instead of over the individual devices. On 64-bit targets the ranges are now `Vec<(u32, u32)>` instead of `usize` pairs, halving the bytes moved per sort compare; on 32-bit targets such as `wasm32`, `usize` and `u32` are the same width, so this part of the change is a no-op there. The sort and hash also read the arena as raw bytes instead of `str`, skipping the UTF-8 boundary re-check `str` indexing pays on every probe. The ten-byte result (`2:` plus eight base64 characters) is returned as a `CompactString` — every holder in the participant-list-hash pipeline (`ResolvedGroupDevices::phash`, `ResolvedDmDevices::phash`, `phash_for_stanza`, `GroupQueryIq::phash`) already carried it as one, so returning `String` cost an allocation made only to be converted at each call site. This is separate from `UserDeviceList::phash`, the server-provided usync device-list hash, which stays `Option`. 8-device group: 3 allocations / 426 B → 2 / 352 B. 1600 devices: 3 / 83.21 KB → 2 / 70.4 KB. This is a **deliberate breaking change**: `participant_list_hash` returns `CompactString` where it returned `String` before, and `GroupQueryIq::phash` / `with_phash` move to `CompactString` with it ([#1326](https://github.com/oxidezap/whatsapp-rust/pull/1326)) * **Attributes carried by value in pairing acks** — `PairUtils::build_ack_node` and `build_ack_node_ref` clone or convert the `to`/`id` `NodeValue` directly instead of rendering each through `to_string()` first. A `NodeValue` already holds either an inline `CompactString` or a structured `Jid`, so the round trip through `String` copied bytes that were already in the right shape. A pairing ack now costs 0 `String` allocations instead of 3 ([#1326](https://github.com/oxidezap/whatsapp-rust/pull/1326)) * **Inline `` node storage in message classification** — `classify_incoming_message` collects a received stanza's `` nodes into a `SmallVec<[&NodeRef; 4]>` instead of a heap-allocated `Vec`. A fan-out addressed to us carries at most one `` per copy we can read — direct children plus this device's entry under `` — so every stanza shape observed in practice stays within the four inline slots and allocates nothing. The bound is on the storage, not the input: a stanza with more than four matching `` nodes would still spill to the heap ([#1326](https://github.com/oxidezap/whatsapp-rust/pull/1326)) * **Inline PN↔LID pairs and tctoken candidates in history sync** — `HistoryLidMapping.phone_number`/`.lid` and `TcTokenCandidate.id` move from `String` to `CompactString`, and `TcTokenCandidate.tc_token` from `Vec` to `SmallVec<[u8; 32]>`. Phone and LID user parts are typically 11–16 digits and live captures put a tctoken at 16–24 bytes, so both fit inline for the common case on 64-bit targets (`CompactString`'s inline capacity is smaller on 32-bit/wasm32; a longer value in either type spills to the heap) — the PN↔LID harvest (plus the two indexes `dedupe_lid_mappings` clones them into for conflict resolution) and the tctoken candidate extraction allocate nothing for typical inputs. `extract_conversation_fields` also reuses one borrowed `parse_jid_ref` scan per conversation for both the PN/LID guess and the tctoken chat-kind guard, instead of building an owned `Jid` just to read `server`. On the `whatsapp-rust` side, `HistorySecretSeedCollector` now caches the previous group message's resolved `(raw participant, Arc sender)` pair, since group history arrives in bursts from the same sender — a repeat of the raw field reuses the cached `Arc` instead of re-parsing the JID and re-rendering it. Bench (`bench_process_history_sync`, rebuilt mixed DM/group fixture): 7507 → 3507 allocations (-53%). This is a **deliberate breaking change**: both new types `Deref` to `str`/`[u8]`, so a call site that consumes them as `&str`/`&[u8]` via deref coercion (method calls, `.parse()`, comparisons against `&str`) compiles unchanged; a call site typed explicitly as `&String`/`&Vec` does not coerce and needs to borrow as `&str`/`&[u8]` instead, and code that moves a field out into an owned `String`/`Vec` needs `.into_string()` / `.into_vec()` ([#1349](https://github.com/oxidezap/whatsapp-rust/pull/1349)) ### CI integration Both benchmark types run from a single workflow, `.github/workflows/codspeed.yml`, on every push to `main` and on pull requests. CodSpeed stores the baselines and reports per-PR deltas. **Protocol benchmarks:** * Sharded across two jobs — `wacore`/`wacore-noise` and `wacore-binary`/`wacore-libsignal`/`wacore-appstate` — so one shard failing doesn't cancel the other; CodSpeed merges the shards into one run * Run under both the simulation (instruction-count) and memory instruments * `MALLOC_*` environment variables freeze glibc malloc's adaptive thresholds, whose allocation-history-dependent decisions otherwise read as spurious deltas * The `wacore`/`wacore-noise` shard passes `--features voip-mlow,bench-internals`. Without a `features` entry, a CodSpeed shard builds its bench targets under only the crate's default features. Both `voip_benchmark` and `sframe_varint_benchmark` declare `required-features`, and cargo *silently skips* a bench target whose required features are off instead of erroring. Before this fix, the entire VoIP media plane — MLow encode/decode, E2E-SRTP, SFrame, and the `codec_stages` per-stage rows — built nowhere and uploaded nothing ([#1320](https://github.com/oxidezap/whatsapp-rust/pull/1320)) **Integration benchmarks:** * Run in their own job with a Bartender mock server as a Docker service container * Simulation instrument only, no memory instrument: `send_and_receive` drives a live async round-trip whose in-flight pipeline buffers straddle divan sample boundaries, so its memory figure tracked runner scheduling rather than code and tripped false regressions. The deterministic memory signal lives in the single-threaded unit benches instead ## Next steps Protocol Buffers message definitions Type-safe protocol node pattern IqSpec request/response pairing Device state and commands # waproto Source: https://whatsapp-rust.jlucaso.com/api/waproto Protocol Buffers definitions for WhatsApp messages ## Overview `waproto` contains the Protocol Buffers definitions for all WhatsApp message types. It's auto-generated from `whatsapp.proto` using [buffa](https://github.com/anthropics/buffa) and provides strongly-typed Rust structs for working with WhatsApp's binary protocol. ```toml theme={null} [dependencies] waproto = "0.7" ``` ## Structure ``` waproto/ ├── src/ │ ├── lib.rs # Module definition + tags re-export │ ├── whatsapp.proto # Source protobuf definitions │ ├── whatsapp.desc # Binary FileDescriptorSet (committed) │ └── whatsapp.desc.sha256 # SHA-256 of descriptor and proto └── build.rs # Always-on: generates whatsapp.rs + tags.rs → OUT_DIR ``` The build writes `whatsapp.rs` and `tags.rs` to `OUT_DIR`. Do not commit these files. Consumers of the crate never need `protoc` installed — only editors of `whatsapp.proto` do, to regenerate the committed descriptor. ## Usage All protobuf types are under the `waproto::whatsapp` module: ```rust theme={null} use waproto::whatsapp as wa; let message = wa::Message { conversation: Some("Hello, World!".to_string()), ..Default::default() }; ``` Sub-message fields (nested protobuf messages) are `buffa::MessageField`, not `Option>` or `Option`. Construct one with `buffa::MessageField::some(..)`, and read it back with `.as_option()`, `.is_set()`, or `.is_unset()`. `waproto` re-exports `buffa` (`waproto::buffa`), and `whatsapp-rust` in turn re-exports it as `whatsapp_rust::buffa` (with `MessageField` also available directly from `prelude`). Naming `buffa::MessageField` no longer requires a direct `buffa` dependency of your own — see [Installation](/installation#add-to-your-project). You can still add `buffa` directly if you want to pin your own version or use APIs beyond `MessageField`. ```rust theme={null} use waproto::buffa::MessageField; use waproto::whatsapp as wa; let img = wa::Message { image_message: MessageField::some(wa::message::ImageMessage { caption: Some("Check this out!".to_string()), ..Default::default() }), ..Default::default() }; if let Some(image) = img.image_message.as_option() { println!("caption: {:?}", image.caption); } ``` Plain scalar fields (`String`, `Vec`, `bool`, integers) are still `Option`, same as before. Enum fields are a typed `Option` rather than a raw `Option`, and enum variants are `SCREAMING_SNAKE_CASE` (e.g. `wa::message::protocol_message::Type::MESSAGE_EDIT`). ## Key message types ### Core message types #### Message The main message container used for all WhatsApp messages. ```rust theme={null} pub struct Message { // Text message pub conversation: Option, // Media messages pub image_message: MessageField, pub video_message: MessageField, pub audio_message: MessageField, pub document_message: MessageField, pub sticker_message: MessageField, // Rich messages pub extended_text_message: MessageField, pub interactive_message: MessageField, pub template_message: MessageField, pub buttons_message: MessageField, pub list_message: MessageField, // Group messages pub sender_key_distribution_message: MessageField, // System messages pub protocol_message: MessageField, pub ephemeral_message: MessageField, pub view_once_message: MessageField, pub view_once_message_v2: MessageField, pub view_once_message_v2_extension: MessageField, // Reactions and interactions pub reaction_message: MessageField, pub edited_message: MessageField, pub keep_in_chat_message: MessageField, // Metadata pub message_context_info: MessageField, // ... and many more } ``` **Usage in main library:** ```rust theme={null} use waproto::buffa::MessageField; use waproto::whatsapp as wa; // Construct a text message let msg = wa::Message { conversation: Some("Hello!".to_string()), ..Default::default() }; // Construct an image message let img = wa::Message { image_message: MessageField::some(wa::message::ImageMessage { url: Some(media_url), media_key: Some(media_key.to_vec()), file_sha256: Some(file_sha256.to_vec()), file_enc_sha256: Some(file_enc_sha256.to_vec()), caption: Some("Check this out!".to_string()), ..Default::default() }), ..Default::default() }; ``` #### MessageKey Identifies a specific message in a conversation. ```rust theme={null} pub struct MessageKey { pub remote_jid: Option, // Chat JID pub from_me: Option, // Sent by me? pub id: Option, // Message ID pub participant: Option, // Group participant JID } ``` ### Media Messages #### ImageMessage ```rust theme={null} pub struct ImageMessage { pub url: Option, pub mimetype: Option, pub caption: Option, pub file_sha256: Option>, pub file_length: Option, pub height: Option, pub width: Option, pub media_key: Option>, pub file_enc_sha256: Option>, pub jpeg_thumbnail: Option>, pub context_info: MessageField, // ... } ``` #### VideoMessage ```rust theme={null} pub struct VideoMessage { pub url: Option, pub mimetype: Option, pub caption: Option, pub file_sha256: Option>, pub file_length: Option, pub seconds: Option, pub media_key: Option>, pub file_enc_sha256: Option>, pub jpeg_thumbnail: Option>, pub gif_playback: Option, // ... } ``` #### AudioMessage ```rust theme={null} pub struct AudioMessage { pub url: Option, pub mimetype: Option, pub file_sha256: Option>, pub file_length: Option, pub seconds: Option, pub ptt: Option, // Push-to-talk (voice note) pub media_key: Option>, pub file_enc_sha256: Option>, // ... } ``` #### DocumentMessage ```rust theme={null} pub struct DocumentMessage { pub url: Option, pub mimetype: Option, pub title: Option, pub file_sha256: Option>, pub file_length: Option, pub page_count: Option, pub media_key: Option>, pub file_enc_sha256: Option>, pub file_name: Option, pub jpeg_thumbnail: Option>, // ... } ``` #### StickerMessage ```rust theme={null} pub struct StickerMessage { pub url: Option, pub file_sha256: Option>, pub file_enc_sha256: Option>, pub media_key: Option>, pub mimetype: Option, pub height: Option, pub width: Option, pub is_animated: Option, // ... } ``` ### Rich content messages #### ExtendedTextMessage Text with formatting, links, and quoted messages. ```rust theme={null} pub struct ExtendedTextMessage { pub text: Option, pub matched_text: Option, pub description: Option, pub title: Option, pub jpeg_thumbnail: Option>, pub context_info: MessageField, // ... } ``` #### InteractiveMessage Buttons, lists, and other interactive elements. ```rust theme={null} pub struct InteractiveMessage { pub header: MessageField, pub body: MessageField, pub footer: MessageField, pub context_info: MessageField, // One of: pub native_flow_message: Option, pub shop_storefront_message: Option, // ... } ``` #### ButtonsMessage ```rust theme={null} pub struct ButtonsMessage { pub content_text: Option, pub footer_text: Option, pub context_info: MessageField, pub buttons: Vec, pub header_type: Option, // ... } ``` #### ListMessage ```rust theme={null} pub struct ListMessage { pub title: Option, pub description: Option, pub button_text: Option, pub list_type: Option, pub sections: Vec, pub context_info: MessageField, // ... } ``` ### Encryption Messages #### SenderKeyDistributionMessage Used for group message encryption. ```rust theme={null} pub struct SenderKeyDistributionMessage { pub group_id: Option, pub axolotl_sender_key_distribution_message: Option>, } ``` #### PreKeySignalMessage Used for establishing 1:1 encryption. ```rust theme={null} pub struct PreKeySignalMessage { // Signal Protocol encrypted message } ``` ### System & protocol messages #### ProtocolMessage For protocol-level operations. ```rust theme={null} pub struct ProtocolMessage { pub key: MessageField, pub r#type: Option, // MESSAGE_EDIT, REVOKE, etc. pub ephemeral_expiration: Option, pub ephemeral_setting_timestamp: Option, // ... } ``` **Common types:** * `REVOKE` - Revoke sent message * `MESSAGE_EDIT` - Edit sent message * `EPHEMERAL_SETTING` - Ephemeral message setting #### ReactionMessage ```rust theme={null} pub struct ReactionMessage { pub key: MessageField, pub text: Option, // Emoji pub grouping_key: Option, pub sender_timestamp_ms: Option, } ``` #### EditMessage ```rust theme={null} pub struct EditMessage { pub key: MessageField, pub message: MessageField, // New text content pub timestamp_ms: Option, } ``` #### AlbumMessage Parent message for grouped media albums. Declares expected image/video counts so WhatsApp clients know how many items to group together. ```rust theme={null} pub struct AlbumMessage { pub expected_image_count: Option, pub expected_video_count: Option, pub context_info: MessageField, } ``` Each child media message is wrapped in `associated_child_message` (a `FutureProofMessage`) and linked to the parent via a `MessageAssociation`: ```rust theme={null} pub struct MessageAssociation { pub association_type: Option, // MEDIA_ALBUM = 1 pub parent_message_key: MessageField, pub message_index: Option, } ``` Use `whatsapp_rust::proto_helpers::wrap_as_album_child` to construct album children. See [Sending Messages - Album messages](/guides/sending-messages#album-messages) for usage examples. ### AI & bot messages #### AIRichResponseMessage ```rust theme={null} pub struct AIRichResponseMessage { pub message_type: Option, pub submessages: Vec, pub context_info: MessageField, // ... } ``` #### BotFeedbackMessage ```rust theme={null} pub struct BotFeedbackMessage { pub message_key: MessageField, pub kind: Option, // Thumbs up/down pub text: Option, // ... } ``` ### Metadata & Context #### MessageContextInfo ```rust theme={null} pub struct MessageContextInfo { pub device_list_metadata: MessageField, pub device_list_metadata_version: Option, pub message_secret: Option>, pub padding_bytes: Option>, pub message_add_on_duration_in_secs: Option, pub message_association: MessageField, // Album/child linking // ... } ``` #### ContextInfo Quoted messages, mentions, and forwarding info. ```rust theme={null} pub struct ContextInfo { pub stanza_id: Option, pub participant: Option, pub quoted_message: MessageField, pub remote_jid: Option, pub mentioned_jid: Vec, pub conversion_source: Option, pub forwarding_score: Option, pub is_forwarded: Option, // ... } ``` ## Device & identity types ### ADV Messages Account Device Verification messages. ```rust theme={null} pub struct ADVDeviceIdentity { pub raw_id: Option, pub timestamp: Option, pub key_index: Option, pub account_type: Option, pub device_type: Option, } pub struct ADVSignedDeviceIdentity { pub details: Option>, pub account_signature_key: Option>, pub account_signature: Option>, pub device_signature: Option>, } pub struct ADVKeyIndexList { pub raw_id: Option, pub timestamp: Option, pub current_index: Option, pub valid_indexes: Vec, pub account_type: Option, } ``` These ADV types are capitalized `ADV...` (not `Adv...`) to match buffa's acronym-preserving type naming. ### Signal protocol structures ```rust theme={null} pub struct PreKeyRecordStructure { pub id: Option, pub public_key: Option>, pub private_key: Option>, } pub struct SignedPreKeyRecordStructure { pub id: Option, pub public_key: Option>, pub private_key: Option>, pub signature: Option>, pub timestamp: Option, } ``` These are all-scalar structs (no nested messages), so they're unaffected by the `MessageField` change above. ## Handshake & connection types ### HandshakeMessage Used during initial connection handshake. ```rust theme={null} pub struct HandshakeMessage { pub client_hello: MessageField, pub server_hello: MessageField, pub client_finish: MessageField, } ``` ### ClientPayload Device and client information during pairing. ```rust theme={null} pub struct ClientPayload { pub username: Option, pub passive: Option, pub user_agent: MessageField, pub web_info: MessageField, pub push_name: Option, pub session_id: Option, pub short_connect: Option, pub connect_type: Option, pub connect_reason: Option, // ... } ``` ## History sync types ### HistorySyncNotification ```rust theme={null} pub struct HistorySyncNotification { pub file_sha256: Option>, pub file_length: Option, pub media_key: Option>, pub file_enc_sha256: Option>, pub direct_path: Option, pub sync_type: Option, pub chunk_order: Option, pub original_message_id: Option, } ``` ### HistorySync ```rust theme={null} pub struct HistorySync { pub sync_type: history_sync::HistorySyncType, pub conversations: Vec, pub status_v3_messages: Vec, pub chunk_order: Option, pub progress: Option, // ... } ``` ## Media reference types ### ExternalBlobReference References to uploaded media files. ```rust theme={null} pub struct ExternalBlobReference { pub media_key: Option>, pub direct_path: Option, pub handle: Option, pub file_size_bytes: Option, pub file_sha256: Option>, pub file_enc_sha256: Option>, } ``` ## App state types ### SyncActionValue App state synchronization actions. ```rust theme={null} pub struct SyncActionValue { pub timestamp: Option, // One of many action types: pub contact_action: MessageField, pub mute_action: MessageField, pub pin_action: MessageField, pub push_name_setting: MessageField, pub archive_chat_action: MessageField, pub delete_chat_action: MessageField, pub star_action: MessageField, // ... and many more } ``` ## Enums waproto generates real Rust enums (not raw `i32` constants). Variant names are `SCREAMING_SNAKE_CASE`: ```rust theme={null} // Message types pub mod protocol_message { pub enum Type { REVOKE = 0, EPHEMERAL_SETTING = 3, EPHEMERAL_SYNC_RESPONSE = 4, HISTORY_SYNC_NOTIFICATION = 5, APP_STATE_SYNC_KEY_SHARE = 6, APP_STATE_SYNC_KEY_REQUEST = 7, MESSAGE_EDIT = 14, // ... } } // Media types pub mod video_message { pub enum Attribution { NONE = 0, GIPHY = 1, TENOR = 2, } } ``` buffa generates a **closed** enum by default: an unrecognized wire value decodes to `None` rather than being preserved, unlike the old prost-generated `Option` fields which round-tripped any integer. In practice WhatsApp only ever sends in-schema values, so this only matters for forward compatibility with brand-new server-side enum variants. `SyncdMutation.operation` (`SyncdOperation`) is the one deliberate exception: `build.rs` opts it into buffa's **open** enum mode, so the field type is `Option>` instead of `Option`. An unrecognized wire value decodes to `EnumValue::Unknown(n)` rather than `None`. `wacore_appstate`'s `process_patch` rejects an unknown operation with a typed `AppStateError::UnsupportedSyncdOperation` before mutating any state, instead of silently treating it as `SET` and corrupting the app-state LTHash — which is what the previous closed-enum decode did. ## Feature flags | Feature | Description | Implies | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | | `serde-deserialize` | Adds `#[derive(serde::Deserialize)]` and `#[serde(default)]` to all types. Use when you need to parse protobuf types from JSON. | — | | `serde-snake-case` | Adds `#[serde(rename_all(deserialize = "snake_case"))]` to all types. Allows snake\_case enum variant names during deserialization (buffa generates SCREAMING\_SNAKE\_CASE). Serialize output is unchanged. | `serde-deserialize` | | `serde-enum-repr` | Enums (de)serialize as their numeric repr instead of the variant name — what the JS/WASM bridge and camelCase serializer expect. | — | The `generate` feature was removed in [#836](https://github.com/oxidezap/whatsapp-rust/pull/836). Code generation is now always-on — `buffa-build`, `buffa-descriptor`, `heck`, and `sha2` are unconditional build dependencies. Remove `--features generate` from any build scripts. ## Serde support All generated types derive `serde::Serialize` by default. Deserialization and snake\_case renaming are behind optional feature flags (`serde-deserialize` and `serde-snake-case` above). Enable them in your `Cargo.toml`: ```toml theme={null} [dependencies] waproto = { version = "0.7", features = ["serde-snake-case"] } ``` ### Default behavior (no feature flags) All types derive `Serialize` only: ```rust theme={null} #[derive(Clone, PartialEq, Default)] #[derive(serde::Serialize)] pub struct Message { // ... } ``` This allows JSON serialization for debugging: ```rust theme={null} let json = serde_json::to_string_pretty(&message)?; println!("Message: {}", json); ``` ### With `serde-deserialize` All types also derive `Deserialize` with `#[serde(default)]`, matching protobuf semantics where missing fields use default values: ```rust theme={null} #[derive(serde::Serialize)] #[cfg_attr(feature = "serde-deserialize", derive(serde::Deserialize))] #[cfg_attr(feature = "serde-deserialize", serde(default))] pub struct Message { // ... } ``` ### With `serde-snake-case` All types additionally accept snake\_case during deserialization. This primarily affects enum and oneof variants (buffa generates SCREAMING\_SNAKE\_CASE names), while struct fields are already snake\_case. Serialization output remains unchanged. ```rust theme={null} // Without serde-snake-case: must use SCREAMING_SNAKE_CASE let json = r#"{"accountType": "ENTERPRISE"}"#; // With serde-snake-case: snake_case also accepted let json = r#"{"account_type": "enterprise"}"#; ``` The `serde-snake-case` feature is primarily useful for WASM bridge scenarios where JavaScript sends snake\_case JSON to the Rust backend. For most Rust-only use cases, you only need the default `Serialize` support. ## waproto::tags The build generates the `waproto::tags` module from the compiled protobuf descriptor. You get one `pub mod` per proto message with one `pub const FIELD_NAME: u32 = N;` per field. Nested messages produce nested modules. ```rust theme={null} use waproto::tags; // Field numbers derived directly from whatsapp.proto let _ = tags::web_message_info::KEY; // 1 let _ = tags::web_message_info::MESSAGE; // 2 let _ = tags::message::MESSAGE_CONTEXT_INFO; // 35 let _ = tags::history_sync::CONVERSATIONS; // 2 let _ = tags::history_sync::PUSHNAMES; // 7 ``` The history-sync wire walkers use these constants internally, and compile-time `assert!` blocks pin them in the hand-written mirror structs. If `whatsapp.proto` renumbers a field the consts update automatically on next build; if a field referenced by the walkers is renamed or removed, compilation fails rather than the decoder silently reading the wrong wire data. ## Code generation The build generates protobuf code — `build.rs` always runs. It reads the committed binary descriptor (`src/whatsapp.desc`), verifies its SHA-256 against `src/whatsapp.desc.sha256`, and hands it to `buffa-build`, writing two files into `OUT_DIR`: * **`whatsapp.rs`** — full buffa-generated structs and enums, including zero-copy view types * **`tags.rs`** — `waproto::tags` field-number constants (see [waproto::tags](#waprototags) above) `whatsapp.proto` stays in its upstream camelCase form; `buffa-build`'s `idiomatic_field_names` option converts field and oneof identifiers to snake\_case Rust idents at codegen time (word boundaries match `heck`/prost), so the generated API keeps the prost-style names build.rs used to produce by hand. `buffa-descriptor` and `heck` remain build dependencies — they back the `waproto::tags` generation described above, which reads the original (camelCase) descriptor directly. Neither generated file is committed. `buffa-build`, `buffa-descriptor`, `heck`, and `sha2` are unconditional build dependencies. `protoc` is only needed to regenerate the descriptor after editing `whatsapp.proto` — normal builds of `waproto` (or anything depending on it) never invoke `protoc`. **To update after modifying `whatsapp.proto`:** ```bash theme={null} scripts/regenerate-proto-desc.sh # writes waproto/src/whatsapp.desc + .sha256 (wraps protoc) cargo build -p waproto # regenerates whatsapp.rs and tags.rs ``` The build aborts with a clear message if the descriptor SHA-256 does not match. The `generate` feature flag was removed. Code generation is now always-on and does not require any feature flags. Remove `--features generate` from any existing build scripts. ## Usage Examples ### Constructing Messages ```rust theme={null} use waproto::buffa::MessageField; use waproto::whatsapp as wa; // Text message let text = wa::Message { conversation: Some("Hello!".to_string()), ..Default::default() }; // Image with caption let image = wa::Message { image_message: MessageField::some(wa::message::ImageMessage { url: Some(media_url), media_key: Some(media_key.to_vec()), file_sha256: Some(file_sha256.to_vec()), file_enc_sha256: Some(file_enc_sha256.to_vec()), caption: Some("Nice photo!".to_string()), jpeg_thumbnail: Some(thumbnail), ..Default::default() }), ..Default::default() }; // Quoted reply let reply = wa::Message { extended_text_message: MessageField::some(wa::message::ExtendedTextMessage { text: Some("Great question!".to_string()), context_info: MessageField::some(wa::message::ContextInfo { stanza_id: Some(original_message_id), participant: Some(original_sender), quoted_message: MessageField::some(original_message), ..Default::default() }), ..Default::default() }), ..Default::default() }; ``` ### Pattern Matching `MessageField` doesn't pattern-match like `Option` directly — match on `.as_option()` instead: ```rust theme={null} match (&proto_msg.conversation, proto_msg.image_message.as_option(), proto_msg.video_message.as_option()) { (Some(text), _, _) => { println!("Text: {}", text); } (_, Some(img), _) => { println!("Image from: {}", img.url.as_deref().unwrap_or_default()); } (_, _, Some(vid)) => { println!("Video: {} seconds", vid.seconds.unwrap_or(0)); } _ => println!("Other message type"), } ``` ### Media Downloads See [Media Handling](/guides/media-handling) for complete examples. ```rust theme={null} use waproto::whatsapp as wa; use wacore::download::{Downloadable, MediaType}; if let Some(img) = message.image_message.as_option() { // Use the Downloadable trait to download and decrypt let data = client.download(img).await?; } ``` ## WhatsApp Version The protobuf definitions are based on: ```rust theme={null} // This file is @generated by buffa-build. // WhatsApp Version: 2.3000.1035617621 ``` This version is automatically included in the generated file header. ## Relationship with wacore wacore provides utilities for working with waproto messages: * **`proto_helpers`** - Conversion between protobuf and internal types * **`download`** - `Downloadable` trait for media messages * **`upload`** - Media encryption for upload * **`messages`** - Message encryption/decryption * **`send`** - Message building and sending Example: ```rust theme={null} use waproto::buffa::MessageField; use wacore::proto_helpers; use waproto::whatsapp as wa; // Convert internal JID to protobuf MessageKey let key = proto_helpers::message_key(&jid, &message_id, from_me); // Build revoke message let revoke = wa::Message { protocol_message: MessageField::some(wa::message::ProtocolMessage { key: MessageField::some(key), r#type: Some(wa::message::protocol_message::Type::REVOKE), ..Default::default() }), ..Default::default() }; ``` ## Next Steps Platform-agnostic protocol implementation Sending and receiving messages Working with media uploads and downloads End-to-end encryption details # Week of June 2, 2026 Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-02 Bot message decryption, streaming uploads, typed event subscriptions, and WhatsApp Web parity improvements This week brings native bot message decryption, constant-memory streaming uploads, and a host of WhatsApp Web parity fixes. ## New features **Bot message decryption (msmsg)** Meta AI and other fbid bot replies are now decrypted automatically and dispatched as a normal `Event::Message`. No special handling required — just listen for messages as usual. See the [Signal API reference](/api/signal). **Constant-memory streaming uploads** Audio and video uploads can now stream from disk without loading the full file into memory, and produce a per-64 KiB HMAC sidecar that enables progressive playback for recipients. Ideal for large media on memory-constrained hosts. See [Upload](/api/upload). **Inline edit decryption** Secret-encrypted edits (message edits, poll edits, event edits, and poll-add-option) are now decrypted automatically on receive. Most apps no longer need to call the manual decryption helpers. See [Polls](/api/polls). **Typed event subscriptions** Event handlers can now declare which event kinds they care about via `EventHandler::interest()` and `on_event_for(&[EventKind], …)`, letting the runtime skip work for events you don't consume. See [Events](/concepts/events) and [Bot](/api/bot). **`messageSecret` retention policy** A new configurable retention policy (`Managed`, `BotOnly`, `Full`, `Disabled`) controls how long `messageSecret` values are kept per message class, with sensible defaults (30d / 90d / 30d). History seeding and a fallback resolver are also available. See [Bot](/api/bot). ## Updates **Longer retry receipt window** The default `sent_message_ttl_secs` has been bumped from 5 minutes to 2 hours so retry receipts from slow or briefly offline recipients still resolve to the original message. **Persisted sender-key chains on disconnect** `disconnect()` now flushes the Signal cache to your backend before clearing it, so advanced sender-key chains survive reconnects and avoid unnecessary SKDM re-fanouts. **Renamed `ConnectFailureReason::MainDeviceGone` → `AccountLocked`** Matches WhatsApp Web's `REASON_LOCKED` (HTTP 403) and better reflects the actual condition. Update any match arms accordingly. **`TransportEvent::Disconnected` now carries a reason** Custom transports now report `DisconnectReason` (`ServerClose`, `StreamEnded`, `ReadError`, or `Unknown`) so reconnect logic can branch on cause. This is a breaking change for custom `Transport` implementations — see [Transport](/api/transport) and [Custom backends](/guides/custom-backends). **Batched app-state mutation MACs** `AppSyncStore::get_mutation_macs` provides a batched lookup that backends can override with a SQL `IN` query, eliminating an N+1 during app-state sync. See [Store](/api/store). **`CompactString` for LID lookups** `get_current_lid` and `SendContextResolver::get_lid_for_phone` now return `Option` instead of `Option` for lower allocation pressure. See [Storage](/concepts/storage). **Spawnable marker trait** A new `Spawnable` marker (`Send + 'static` on native, `'static` on wasm32) makes generic spawn helpers easier to write across targets. See [Custom backends](/guides/custom-backends). **WhatsApp Web parity** * Uniform plaintext padding (1..=16 bytes) replaces the previous biased scheme. * Full device set included on every send for participant-hash calculation, with standard base64 alphabet and persisted group metadata for not-modified group queries. * Sender-key chains are now serialized per `(group, sender)` pair. * DM sends now fail fast when every per-device encrypt fails instead of silently producing an empty stanza. * Stanza classification fixes: `poll-add-option` is classified as `poll`, and `album` as `text`. * `unwrap_message` now peels a wider set of `FutureProofMessage` wrappers (including `groupStatusV2` and `spoiler`). ## Bug fixes **`decrypt-fail=hide` coverage** `conditional_reveal_message`, `secret_encrypted_message`, and `PollAddOption` are now correctly hidden on decrypt failure, and `SenderRevoke` is excluded from hide alongside `AdminRevoke`. **Peer edit sender resolution** `MessageEdit` events from peers now resolve the original sender from the envelope rather than the edit target, fixing mis-attribution on multi-device accounts. **Session-recreate cooldown is bounded** The session-recreate history is now a bounded TTL cache (\~256 entries, 1h) with atomic per-peer check-and-stamp, so long-running clients no longer accumulate unbounded state. Per-peer cooldown behavior is unchanged. **SKDM-only decrypt acking** Sender-key distribution-only decrypts are now acked so they drain from the offline queue instead of being redelivered. # June 4, 2026 Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-04 New Labels API, a generic send_app_state_action escape hatch for syncd actions, query_info now returns Arc, and a wire-shape fix for message edits. ## New features **Chat labels API** Manage WhatsApp chat labels (etiquetas) from Rust. `client.labels()` exposes `create_label`, `delete_label`, `add_chat_label`, and `remove_chat_label`, all synced across linked devices through the `regular` app state collection. Inbound label changes made on a linked device are delivered as the new `Event::LabelEditUpdate` and `Event::LabelAssociationUpdate` events, each carrying the underlying action and a `from_full_sync` flag. See [Labels](/api/labels) and the new event types in [Events](/concepts/events#labeleditupdate). ```rust theme={null} client.labels().create_label("vip", "VIP", 3).await?; client.labels().add_chat_label("vip", &chat_jid).await?; ``` **Generic `send_app_state_action` API** Send any syncd action — including ones without a dedicated helper like `clear_chat`, `favorites`, or `quick_reply` — directly from a schema in the new `whatsapp_rust::schemas` registry. The schema picks the collection, action version, and index shape; you only fill in the typed `SyncActionValue` and the non-literal index args. The existing typed methods on `ChatActions` and `Labels` are now thin wrappers over this same call. See [`send_app_state_action`](/api/chat-actions#send_app_state_action). ```rust theme={null} use whatsapp_rust::schemas; use whatsapp_rust::waproto::whatsapp as wa; let value = wa::SyncActionValue { clear_chat_action: Some(Default::default()), timestamp: Some(1_700_000_000_000), ..Default::default() }; client .send_app_state_action( &schemas::CLEAR_CHAT, &["15551234567@s.whatsapp.net", "0", "0"], &value, ) .await?; ``` ## Updates **`query_info` returns `Arc`** `client.groups().query_info()` now returns `Result, anyhow::Error>` instead of `Result`. Repeated lookups for the same group share the same cached snapshot, so warm sends to groups no longer deep-clone the participants list or LID-to-phone map. This is a breaking change for direct callers — bind the result as `Arc` and reach through `info.participants` / `info.lid_to_pn_map()` as before. See [`query_info`](/api/groups#query_info) and the [Group management guide](/guides/group-management). ## Fixes **Message edits now use WhatsApp Web's wire shape** [`client.edit_message`](/api/send#edit_message) previously wrapped the new content in a nested `Message.edited_message` (`FutureProofMessage`) envelope — the history/storage form — which other clients did not always render. Edits are now sent as a top-level `protocolMessage` with `type = MESSAGE_EDIT`, matching WhatsApp Web, and use a fresh stanza ID so the server does not deduplicate the edit against the original message. The public signature is unchanged; rebuild against the latest release to pick up the fix. If you were hand-rolling the legacy envelope and passing it to `send_message`, switch to `client.edit_message` — see the [Sending messages guide](/guides/sending-messages#editing-messages). # June 5, 2026 Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-05 Typed MEX operations, reaction helpers, newsletter edit/revoke, and an E2E send-path guard for newsletter JIDs. ## Breaking changes **Typed MEX operations** [`MexRequest`](/api/mex#mexrequest) is now generic over its variables type (`MexRequest`), and the request carries a [`MexDoc`](/api/mex#mexdoc) (operation name + persisted document ID) instead of a free-form `doc_id: &str`. Variables are serialized straight to the wire — there's no intermediate `serde_json::Value` step — so callers pass either a typed `Variables` struct from a generated operation module or any `serde::Serialize` value. The generated modules live under [`wacore::iq::mex_operations`](/api/wacore) and expose `NAME`, `DOC_ID`, `OPERATION_KIND`, `Variables`, and `Response` per persisted query. Build a request with [`MexRequest::new`](/api/mex#new): ```rust theme={null} use wacore::iq::mex_operations::join_newsletter; use whatsapp_rust::features::mex::MexRequest; let request = MexRequest::new( join_newsletter::NAME, join_newsletter::DOC_ID, join_newsletter::Variables { newsletter_id: Some(jid.to_string()), }, ); let response = client.mex().mutate(request).await?; ``` `Mex::query` and `Mex::mutate` are now generic over `V: Serialize`, and a new `MexError::InvalidJid` variant surfaces JID parsing failures from MEX payloads. See [MEX (GraphQL)](/api/mex) for the full reference. If you previously built requests with `MexRequest { doc_id, variables }`, switch to `MexRequest::new(NAME, DOC_ID, variables)` using the generated module's constants — or, for ad-hoc operations, set `doc: MexDoc { name, id }` directly. Numeric `DOC_ID`s rotate with WhatsApp Web bundle releases; the stable `NAME` keeps diagnostics readable across versions. ## New features **`Client::send_reaction` helper** Send a reaction to a DM, group, or `status@broadcast` message in one call. The helper builds the underlying `ReactionMessage` (including `sender_timestamp_ms`) and routes the stanza through the standard send path, so retry, fan-out, and phash handling all apply. Pass an empty string to remove a previous reaction. For groups and status, set `target_key.participant` to the original sender so the receipt can be attributed. See [`send_reaction`](/api/send#send_reaction). ```rust theme={null} use waproto::whatsapp as wa; let target_key = wa::MessageKey { remote_jid: Some(group_jid.to_string()), from_me: Some(false), id: Some(target_message_id.clone()), participant: Some(sender_jid.to_string()), // required for groups/status }; client.send_reaction(&group_jid, target_key, "🎉").await?; ``` **`MessageContext::react` shortcut** Inside an event handler, [`MessageContext::react`](/api/bot#react) reacts to the incoming message without manually rebuilding the message key — it fills in `chat`, message ID, and group/status `participant` for you. ```rust theme={null} use whatsapp_rust::bot::MessageContext; .on_event(|event, client| async move { if let Some(ctx) = MessageContext::from_event(&event, client) { let _ = ctx.react("👍").await; } }) ``` Newsletter (channel) reactions still go through [`client.newsletter().send_reaction()`](/api/newsletter#send_reaction) because they use a different plaintext stanza format. **Newsletter channel edit and revoke** Channel messages now have first-class edit and revoke helpers on `client.newsletter()`. Both go out as plaintext `` stanzas (no Signal encryption) and key off the message's wire `message_id` — not the `server_id` used for reactions. See [`edit_message`](/api/newsletter#edit_message) and [`revoke_message`](/api/newsletter#revoke_message). ```rust theme={null} use waproto::whatsapp as wa; let new_body = wa::Message { conversation: Some("Updated announcement".to_string()), ..Default::default() }; client.newsletter() .edit_message(&newsletter_jid, message_id.clone(), new_body) .await?; client.newsletter() .revoke_message(&newsletter_jid, message_id) .await?; ``` **Newsletter JIDs rejected on the E2E send path** `send_message_impl`, `pin_message`, and `Client::edit_message` / `Client::revoke_message` now reject newsletter JIDs at the root of the encrypted send path. A mis-routed channel JID surfaces a clear error that names the mis-route instead of producing a malformed encrypted fan-out. Use `client.newsletter()` for any channel send, edit, or revoke. # June 5, 2026 — Typed A/B-props registry Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-05-abprops Vendored, auto-generated A/B-props registry replaces hand-maintained config codes; AbPropsCache now takes typed AbProp constants. ## Breaking changes **`AbPropsCache` now takes typed `AbProp` constants** The hand-maintained `wacore::iq::props::config_codes` module is gone. Cache lookups now take a typed `AbProp` from the vendored [`wacore::iq::abprops`](/api/wacore#a-b-props-registry) registry instead of a raw `u32` code, which lets the cache reuse each flag's `value_type` and `default` from the WA Web bundle. If you were calling `client.ab_props().is_enabled(PRIVACY_TOKEN_ON_GROUP_CREATE)` against a numeric constant, switch to the named flag: ```rust theme={null} use wacore::iq::abprops::web; let enabled = client .ab_props() .is_enabled(web::PRIVACY_TOKEN_SENDING_ON_GROUP_CREATE) .await; ``` The previous code-table entries map as follows: | Old (`config_codes::*`) | New (`abprops::*`) | | ---------------------------------------- | -------------------------------------------------------- | | `PRIVACY_TOKEN_ON_ALL_1_ON_1_MESSAGES` | `web::PRIVACY_TOKEN_SENDING_ON_ALL_1_ON_1_MESSAGES` | | `PRIVACY_TOKEN_ON_GROUP_CREATE` | `web::PRIVACY_TOKEN_SENDING_ON_GROUP_CREATE` | | `PRIVACY_TOKEN_ON_GROUP_PARTICIPANT_ADD` | `web::PRIVACY_TOKEN_SENDING_ON_GROUP_PARTICIPANT_ADD` | | `PRIVACY_TOKEN_ONLY_CHECK_LID` | `wacore::iq::props::stale::PRIVACY_TOKEN_ONLY_CHECK_LID` | ## New features **Vendored typed A/B-props registry** `wacore::iq::abprops` ships an auto-generated snapshot of every flag in WhatsApp Web's `WAWebABPropsConfigs` registry (\~1,775 entries today). Each flag is a typed `AbProp` const with its wire name, numeric code, value type, and default — so you can gate your own code on the same experiment flags WhatsApp Web reads without copy-pasting codes from the bundle. Only the consts you reference are linked into the binary. ```rust theme={null} use wacore::iq::abprops::web; println!( "{} (code {}) defaults to {:?}", web::ADMIN_REVOKE_RECEIVER.name, web::ADMIN_REVOKE_RECEIVER.code, web::ADMIN_REVOKE_RECEIVER.default, ); ``` **New `AbPropsCache` helpers: `get`, `get_int`, `watch`, `watch_many`** * `client.ab_props().get(prop)` returns the raw `Option` value the server sent. * `client.ab_props().get_int(prop)` returns the parsed `i64`, falling back to the registry default when the server omits the flag or it isn't an int. The internal `tc_token` send path uses this to follow WA Web's `TCTOKEN_DURATION` / `TCTOKEN_NUM_BUCKETS` rollouts automatically. * `client.ab_props().watch(prop)` / `watch_many(&[prop, …])` add flags to the cache's interest set so the next `fetch_props()` retains their values. By default only the flags in `wacore::iq::props::WATCHED` (the ones the library reads) are kept; everything else is discarded to avoid allocating for the \~2,000 unused props. Call `watch` before the first `fetch_props()` (i.e. before `client.connect()`), otherwise the flag's value will be dropped from the very first response. See [AB props cache](/api/client#ab-props-cache) and the [`abprops` registry](/api/wacore#a-b-props-registry) for the full API. # June 6, 2026 — Optional tracing instrumentation Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-06 Opt-in tracing feature instruments connect, send, receive, IQ, app state, pairing, media, and session flows with a wa.* span taxonomy and PII-redacted JIDs. ## New features **Optional `tracing` feature (off by default, OpenTelemetry-ready)** whatsapp-rust now ships an opt-in `tracing` Cargo feature that instruments the library end-to-end — connect, receive/decrypt, send, IQ, app state, pairing, media, receipts, retries, notifications, and session/crypto. With the feature off there is no `tracing` dependency and the instrumentation attributes vanish at compile time, so the default build has zero overhead. The library only emits spans and events. Your application installs the subscriber (and any OpenTelemetry/OTLP layer). The existing `log::{info,warn,error}!` calls keep working and bridge into the subscriber via `tracing-log`, so they appear as events attached to the active `wa.*` span. ```toml Cargo.toml theme={null} [dependencies] whatsapp-rust = { version = "0.6", features = ["tracing"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } ``` ```bash theme={null} RUST_LOG="info,whatsapp_rust=debug" cargo run --features tracing ``` Spans are grouped under a stable `wa.{conn,recv,send,iq,appstate,pair,media,receipt,retry,pdo,notif,session,usync,bot}.*` taxonomy so you can filter or build dashboards per area. Connection-lifecycle spans (`wa.conn.connect`, `wa.conn.disconnect`, `wa.conn.reconnect`, `wa.conn.run`, `wa.conn.logout`) are at `info` level; everything else is `debug`/`trace`. Failures surface at `ERROR` via `err(Debug)`. **PII-redacted JIDs across spans and logs** `Jid::observe()` renders LID, group, broadcast, newsletter, and bot JIDs in full (pseudonymous or non-personal, so peers and chats still correlate) and replaces phone-number user JIDs with `pn#`. The token is a **keyed** SipHash with a process-lifetime random key, not a plain digest — an unkeyed hash of an E.164 number is reversible by precomputation, while the keyed scheme is not. Legacy group IDs (`-`) keep the timestamp and redact only the numeric prefix. `observe_protocol_address()` applies the same scheme to Signal `ProtocolAddress` names. The library's own log calls now route JIDs and addresses through these helpers, so the bridged log lines carry the same redaction as the span fields. For local debugging where raw phone numbers are required, enable the `tracing-pii` feature (off by default, never enable in production). See [Observability with tracing](/advanced/observability) for the full wiring guide, including an OpenTelemetry / OTLP example. # June 8, 2026 — Forward messages, played receipts, and group pictures Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-08 Forward messages with a new high-level API, send played receipts for voice/video notes, and manage group profile pictures. ## New features **Forward messages with `Client::forward_message`** `Client::forward_message(to, &message)` is a high-level wrapper that turns any received message into a properly forwarded one: * Sets `context_info.is_forwarded = true` so recipients see the **Forwarded** label. * Bumps `forwarding_score`, jumping to the `127` **Forwarded many times** sentinel at 5. * Strips the source reply/quote chain and mentions. * Drops the source `message_secret` so the send path mints a fresh one. * Unwraps ephemeral and view-once wrappers before sending the inner content. * Relays existing media from the same CDN blob — no re-download or re-upload. ```rust theme={null} // `received` is a `wa::Message` from an incoming event. client.forward_message(destination_jid, &received).await?; ``` If you need to tweak the prepared body before sending (custom caption, extra stanza nodes), use the underlying `MessageExt::prepare_for_forward` helper directly and pass the result to `send_message_with_options`. See [`forward_message`](/api/send#forward_message) and the [Forwarding messages guide](/guides/sending-messages#forwarding-messages) for details. **Send played receipts for voice and video notes** `Client::mark_as_played(chat, sender, message_ids)` now sends `` (or `played-self` for newsletters) so recipients can signal that a voice note or video note was played. Pass `Some(sender)` for groups, broadcast lists, and status broadcasts; pass `None` for DMs. Extra message IDs are batched into a `` child, matching `mark_as_read`. ```rust theme={null} let chat_jid: Jid = "15551234567@s.whatsapp.net".parse()?; client.mark_as_played( &chat_jid, None, // No participant for DMs vec!["VOICE_MSG_ID".to_string()], ).await?; ``` Inbound `Played` / `PlayedSelf` receipts were already parsed into `Event::Receipt`; this closes the gap on the outbound side. See the [receipt API reference](/api/receipt#mark_as_played). *** **Set or remove a group's profile picture** The `Groups` feature now exposes admin-side methods for managing a group's display picture, mirroring the existing own-picture API on `Profile`: * `Groups::set_profile_picture(group_jid, image_data)` — upload JPEG bytes as the group avatar. * `Groups::remove_profile_picture(group_jid)` — clear the group's current picture. Both calls require the authenticated user to be a group admin. Passing empty bytes to `set_profile_picture` routes to removal, so callers that already model "no image" as an empty buffer keep working. The caller is responsible for sizing and cropping the JPEG (WhatsApp uses 640x640). ```rust theme={null} use std::fs; let group_jid: Jid = "120363012345678@g.us".parse()?; // Set let image_bytes = fs::read("group_avatar.jpg")?; let response = client.groups().set_profile_picture(&group_jid, image_bytes).await?; println!("New picture ID: {:?}", response.id); // Remove client.groups().remove_profile_picture(&group_jid).await?; ``` See [Groups API reference](/api/groups#set_profile_picture) and the [Group management guide](/guides/group-management#set-or-remove-the-group-picture) for details. *** **Surface verified business names from contact lookups** `IsOnWhatsAppResult` and `UserInfo` now expose `verified_name: Option` alongside the existing `is_business` flag. The certificate carried in the `` response is decoded, so you can show the verified display name (the green-checkmark name) without a second request. `verified_name` is `Some` only for verified business accounts; regular accounts and unverified businesses stay `None`. `VerifiedName` re-exports from `whatsapp_rust::features` and exposes `name`, `serial`, `issuer`, and the raw `certificate` bytes. ```rust theme={null} use whatsapp_rust::features::VerifiedName; let results = client.contacts().is_on_whatsapp(&[ Jid::pn("15551234567"), ]).await?; for result in results { if let Some(VerifiedName { name: Some(name), .. }) = &result.verified_name { println!("{} → verified as {}", result.jid, name); } } ``` Because `IsOnWhatsAppResult` is `#[non_exhaustive]`, adding this field is not a breaking change for consumers that construct it via the parser. See the [contacts API reference](/api/contacts#is_on_whatsapp) for the full field list. # June 9, 2026 — Events & RSVP, quiz polls, disappearing timers, and media builders Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-09 Create events and collect RSVPs, send quiz polls, toggle 1:1 disappearing timers, save contacts, clear chats, mute channels, and build media messages from an upload result — plus breaking changes to download, groups, and receipt APIs. This entry catches the documentation up with the remaining early-June API work that landed after the [June 8 release](/changelog/2026-06-08). ## New features **Events with RSVP (`client.events()`)** A new [`Events`](/api/events) feature creates WhatsApp event messages and collects encrypted RSVPs. * `client.events().create(to, params)` sends an event and returns `(SendResult, message_secret)`. Like polls, the event carries a per-message secret; **store it** — responders' RSVPs are encrypted against it. * `client.events().respond(chat, event_id, creator, secret, response, extra_guests)` sends an encrypted `Going` / `NotGoing` / `Maybe` reply. * `wacore::event::{encrypt_event_response_with_secret, decrypt_event_response_with_secret}` decrypt inbound RSVPs. ```rust theme={null} use whatsapp_rust::features::{EventCreationParams, EventResponseType}; // Create an event — keep the returned secret to read RSVPs later. let (sent, secret) = client.events().create(&chat, EventCreationParams { name: "Team offsite".into(), start_time: Some(1_760_000_000), ..Default::default() }).await?; // RSVP to an event you received (event id, creator, and secret come from that event). client.events() .respond(&chat, &event_id, &creator, &event_secret, EventResponseType::Going, None) .await?; ``` See the [Events API reference](/api/events) for the full field list. **Quiz polls (`Polls::create_quiz`)** `client.polls().create_quiz(to, name, options, correct_index)` sends a single-select quiz poll with one correct answer (`correct_index` is the 0-based index into `options`). It returns `(SendResult, message_secret)` just like [`create`](/api/polls#create), and votes decrypt the same way. See [`create_quiz`](/api/polls#create_quiz). **1:1 disappearing-message timer (`Client::set_chat_disappearing_timer`)** Turn disappearing messages on or off for a direct chat with `client.set_chat_disappearing_timer(chat, duration_secs)` (`0` disables). It sends an `EPHEMERAL_SETTING` protocol message and is 1:1-only — use [`Groups::set_ephemeral`](/api/groups#set_ephemeral) for groups. See [`set_chat_disappearing_timer`](/api/send#set_chat_disappearing_timer). **Save or rename contacts (`ChatActions::save_contact`)** `client.chat_actions().save_contact(jid, full_name, first_name, save_on_primary_addressbook)` writes a `contact` app-state mutation that syncs the name to your other linked devices. The contact id must be a bare phone-number JID; LIDs and device-specific JIDs are rejected. See [`save_contact`](/api/chat-actions#save_contact). **Clear a chat's messages (`ChatActions::clear_chat`)** `client.chat_actions().clear_chat(jid, delete_starred, delete_media)` clears a chat's messages while keeping the chat, syncing across devices. Inbound clears from a linked device arrive as the new [`Event::ClearChatUpdate`](/concepts/events#clearchatupdate). See [`clear_chat`](/api/chat-actions#clear_chat). **Mute a contact's status updates (`ChatActions::set_user_status_mute`)** `client.chat_actions().set_user_status_mute(jid, muted)` mutes/unmutes a contact, group, or channel's status updates across devices. Inbound changes arrive as [`Event::UserStatusMuteUpdate`](/concepts/events#userstatusmuteupdate). See [`set_user_status_mute`](/api/chat-actions#set_user_status_mute). **Mute newsletter notifications (`Newsletter::set_follower_mute` / `set_admin_mute`)** Silence a channel's follower- or admin-activity notifications via MEX: ```rust theme={null} client.newsletter().set_follower_mute(&channel, true).await?; // mute client.newsletter().set_admin_mute(&channel, false).await?; // unmute ``` See [`set_follower_mute`](/api/newsletter#set_follower_mute). **Message-secret encrypted edits (`Client::edit_message_encrypted`)** `client.edit_message_encrypted(to, original_id, new_content)` edits a message via the `secret_encrypted_message` (`MESSAGE_EDIT`) path instead of the plaintext `protocolMessage` edit. This is the form Community Announcement Groups require and what WhatsApp Web sends when `message_edit_to_message_secret_sender_enabled` is on. Newsletters are rejected — use [`Newsletter::edit_message`](/api/newsletter#edit_message). See [`edit_message_encrypted`](/api/send#edit_message_encrypted). **High-level media message builders (`whatsapp_rust::media`)** The new `media` module turns an [`UploadResponse`](/api/upload) into a ready-to-send `wa::Message`, so you no longer hand-assemble the CDN/crypto fields (url, direct\_path, media\_key, the two SHA-256 hashes, file\_length, media\_key\_timestamp, streaming\_sidecar): ```rust theme={null} use whatsapp_rust::media::{self, ImageOptions}; let upload = client.upload(bytes, MediaType::Image, Default::default()).await?; let msg = media::image_message(upload, ImageOptions { caption: Some("hi".into()), ..Default::default() }); client.send_message(to, msg).await?; ``` `image_message`, `video_message`, `document_message`, and `audio_message` each take a typed options struct with sensible MIME defaults. See [media message builders](/api/upload#high-level-message-builders). **Device list from `get_user_info`** [`UserInfo`](/api/contacts#get_user_info) now carries `devices: Vec` — the device IDs from the `` sublist the same usync query returns (device `0` is the primary). Empty when the server omits it. **`offline` flag on the Receipt event** [`Event::Receipt`](/concepts/events#receipt) gains `offline: bool`, set when the receipt was drained from the server's offline queue on reconnect rather than delivered live. See the [receipt event structure](/api/receipt#receipt-event-structure). **Read-receipt status parity** Outgoing read receipts now match WhatsApp Web: newsletter reads send `read-self`, and status reads carry `context="status"` plus `peer_participant_pn` (the resolved LID→PN) for a LID author. No API change — `mark_as_read` handles it. **View-once sends emit ``** View-once image/video/voice sends now carry the `view_once` meta attribute, matching WhatsApp Web so recipients render the one-time bubble. Members tagged for [member labels](/api/groups) also emit `appdata` / `tag_reason` meta attributes on group sends. ## Breaking changes **`Client::custom_enc_handlers` field type changed ([#792](https://github.com/oxidezap/whatsapp-rust/pull/792))** The `pub custom_enc_handlers` field type changed from `Arc>>>` to `std::sync::OnceLock>>`. Migration depends on how the field was used: code that read handlers via `.read().await` should switch to `.get()`; code that inserted handlers via `.write().await.insert(...)` must move those registrations to `BotBuilder::with_enc_handler()` before `build()` — runtime insertion is no longer possible. **`get_participating` returns `HashMap`** [`Groups::get_participating`](/api/groups#get_participating) is now keyed by `Jid` instead of `String`. Bind the key as a `Jid`; call `.to_string()` if you need the old string form. **`download_from_params` takes a `DownloadParams` struct** [`download_from_params`](/api/download#download_from_params) and `download_from_params_to_writer` now take a single `&DownloadParams` instead of six positional arguments. Build one with `DownloadParams::encrypted(direct_path, media_key, file_sha256, file_enc_sha256, file_length, media_type)`. `DownloadParams` implements `Downloadable`, so it also works directly with `client.download(¶ms)`. **`download_to_file` removed** The dead, fully-buffering `download_to_file` is gone. Use [`download_to_writer`](/api/download#download_to_writer) (streaming, constant-memory) instead — it accepts any `File`/`BufWriter` and returns the writer seeked to 0. **`set_description` takes `prev: Option<&str>`** [`Groups::set_description`](/api/groups#set_description) now borrows the previous description id (`Option<&str>`) instead of taking an owned `Option`. Pass `Some("PREV_ID")` or `None`. **`mark_as_read` / `mark_as_played` take `&[&str]`** [`mark_as_read`](/api/receipt#mark_as_read) and [`mark_as_played`](/api/receipt#mark_as_played) now take `message_ids: &[&str]` instead of `Vec`, avoiding per-call allocations. Update call sites from `vec!["ID".to_string()]` to `&["ID"]`. ## Fixes & hardening **App-state anti-tampering parity**: snapshot application now requires a valid snapshot MAC (no silent skip), rejects duplicate indices within a patch, and guards against version rollback — matching WhatsApp Web's validation so a malicious or corrupted server snapshot can't desync or tamper with state. **Poll wire shape**: poll creation now matches WhatsApp Web (`pollContentType=TEXT`, no vote metadata on the creation message), improving interop with other clients. **Companion device-identity (ADV) validation**: fetched pre-key bundles are validated against the companion device-identity, rejecting bundles whose identity doesn't check out before a session is built. **ADV account-key fallback from store** ([#790](https://github.com/oxidezap/whatsapp-rust/pull/790)): Fixed a regression where contacts' companion devices (WhatsApp Web / Desktop) stopped receiving messages after the ADV validation above was introduced. The server legitimately omits `account_signature_key` from a companion's `` because the client already holds that key as the contact's primary (device 0) identity in the Signal store — the prior validation rejected those bundles outright. The fix mirrors WA Web's `validateADVwithIdentityKey` (`e.accountSignatureKey || t`): the in-blob key is preferred; when absent, the contact's stored primary identity is loaded via `Client::load_account_identity` and used as the fallback. The validation result is now the three-state `wacore::adv::AdvValidation` (`Valid` / `Invalid` / `NoAccountKey`) instead of a boolean — an unverifiable-for-lack-of-key bundle is logged and kept rather than dropped, preserving the existing "device-identity absent" behaviour. **IQ errors keep `error_type` + `backoff`**: failed IQ responses now surface the server's `error_type` and server-directed `backoff` (seconds) instead of dropping them, so callers can honour retry hints. **wasm**: the cache backend is now target-aware so the `moka-cache` default no longer breaks `wasm32` builds. **`EncHandler` wasm portability** ([#793](https://github.com/oxidezap/whatsapp-rust/pull/793)): `EncHandler` now uses `MaybeSendSync` as its supertrait and gates `async_trait` with `?Send` on `wasm32`, matching the convention of `EventHandler` and `SendContextResolver`. Custom enc handlers that capture `!Send` JS handles now compile on the wasm32 port. No change on native — the blanket `MaybeSendSync` impl keeps `Arc` `Send + Sync`. **Networking traits wasm portability** ([#795](https://github.com/oxidezap/whatsapp-rust/pull/795)): `Transport`, `TransportFactory`, and `HttpClient` now use `MaybeSendSync` as their supertrait, matching the convention established by `EventHandler`, `SendContextResolver`, and `EncHandler`. Custom transport and HTTP implementations that hold `!Send` JS handles (e.g. a browser WebSocket or `fetch` backing) now compile on the `wasm32` port. No change on native — the blanket `MaybeSendSync` impl keeps `Arc` and `Arc` `Send + Sync` there. # June 9, 2026 — App state sync: move-not-clone through blocking handoff Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-09-appstate-blocking-handoff process_patch_list no longer deep-clones the SyncdSnapshot or each SyncdPatch to satisfy the 'static bound on spawn_blocking. The data moves into each closure and comes back via the return tuple, eliminating multi-MB copies on bootstrap and resume. ## Performance **App state sync: move-not-clone through the blocking thread handoff ([#818](https://github.com/oxidezap/whatsapp-rust/pull/818))** `process_patch_list` offloads snapshot and patch processing to `runtime::blocking`, whose closure requires `'static`. Previously, the only way to satisfy that bound was to deep-clone the data before moving it in: | eliminated copy | shape | measured cost | | -------------------------------- | ------------------------------ | -------------------------- | | `snapshot.clone()` per bootstrap | \~20k records × 1 KB (\~21 MB) | \~8.4 ms, \~60k allocs | | `patch.clone()` per resume patch | \~1k mutations × 2 KB (\~2 MB) | \~305 µs, \~3k allocs | | `keys_map.clone()` per patch | key-cache `HashMap` | now an `Arc` refcount bump | **How it works.** The snapshot is taken out of `PatchList` with `Option::take`, moved into the blocking closure, returned through the result tuple, and restored to `pl.snapshot` before the function returns. Patches are drained with `mem::take`, processed one by one the same way, and reassembled in order into `pl.patches`. The key cache is wrapped in `Arc` once so each per-patch closure handoff is a cheap refcount increment. The staleness guard runs on a borrow before the take, so its behavior is unchanged. On any error path `?` propagates before the caller can observe the intermediate empty fields. The caller contract (`get_missing_key_ids` and has-more bookkeeping read `pl.snapshot` and `pl.patches` after the call) is protected by a regression test. ## Breaking changes None. `process_patch_list` keeps its existing signature and returns a `PatchList` carrying the same data as before. # June 9, 2026 — Device snapshot cached as Arc Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-09-arc-device-snapshot get_device_snapshot() is now sync and returns Arc. The related client accessors (get_pn, get_lid, get_push_name, generate_message_id) are also sync. Breaking change: drop .await at all call sites. ## Performance **Device snapshot cached as `Arc` ([#808](https://github.com/oxidezap/whatsapp-rust/pull/808))** `PersistenceManager::get_device_snapshot()` previously deep-cloned the entire `Device` struct on every call — two `Jid`s, `push_name`, `props_hash`, `edge_routing_info`, `nct_salt`, roughly 5–8 heap allocations. With \~72 call sites including once per inbound message, this added up. `PersistenceManager` now keeps a `device_snapshot: std::sync::RwLock>` that is rebuilt **under the existing device write guard** inside `modify_device` — the single mutation funnel. Mutations are rare (pairing, push-name sync, prekey counter); reads now pay only a `std::sync::RwLock` read plus a refcount bump, with no clone and no contention against writers. **Breaking changes:** * **`get_device_snapshot()`**: `async fn → Device` → `fn → Arc`. * **`get_pn()` / `get_lid()` / `get_push_name()` / `require_pn()`**: no longer `async`. * **`generate_message_id()`**: no longer `async`. **Migration is mechanical**: drop `.await` at every call site. The return of `get_device_snapshot()` is now `Arc` — borrow fields directly from the held `Arc` (`snapshot.pn.as_ref()`), or clone individual fields where ownership is needed. Do not hold the snapshot longer than the current scope; doing so pins an old `Arc` allocation rather than paying for a fresh borrow. ```rust theme={null} // Before let device = client.persistence_manager().get_device_snapshot().await; let pn = device.pn.clone(); let push_name = client.get_push_name().await; let id = client.generate_message_id().await; // After let snapshot = client.persistence_manager().get_device_snapshot(); // fn, no .await let pn = snapshot.pn.clone(); let push_name = client.get_push_name(); // fn, no .await let id = client.generate_message_id(); // fn, no .await ``` `get_device_arc()` is kept but its role is narrowed to store-adapter internals that need `&mut Device` trait access. All plain read paths should use `persistence_manager().get_device_snapshot()` instead. **No other breaking changes.** `modify_device`, `process_command`, and `DeviceCommand` are unchanged. # June 9, 2026 — Signal session cache: Arc-shared records, zero-copy peek Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-09-arc-session-cache peek_session now returns Arc — cache hits are a refcount bump instead of a 1–2 KB deep clone. Breaking: return type changes from Option to Option>. ## Performance **Signal session cache: `Arc`-shared records, zero-copy `peek_session` ([#809](https://github.com/oxidezap/whatsapp-rust/pull/809))** `SessionEntry::Present` now wraps `Arc` instead of `Box`, aligning sessions with the sender-key cache pattern already in use. **`peek_session`** — the non-destructive read used by retry-receipt handling and LID-migration checks — previously deep-cloned the full `SessionRecord` on every call (1–2 KB: archived session states plus skipped message keys). It now returns `Option>`, so a cache hit is a refcount bump. The backend-miss path wraps the deserialized record once in `Arc` and shares the same allocation between the cache slot and the return value, eliminating a second clone the old code always paid. **`get_session`** (checkout) uses `Arc::try_unwrap` to move the record out in the common case — the entry is unique in steady state, so this is a zero-cost move. A clone only occurs when a peek's short-lived `Arc` is still alive at checkout time (the rare overlap the old code paid for on every peek). ## Breaking changes **`peek_session` return type: `Option` → `Option>`** (pre-1.0) Read-only consumers are source-compatible via `Deref` — code that only reads through the returned value compiles unchanged. Code that requires an owned `SessionRecord` (e.g., passing by value to a function) should dereference and clone: `(*arc).clone()`. # June 9, 2026 — Device list: primary device preserved on identity rotation Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-09-device-list Fixed a bug where a companion relink or identity rotation could silently drop device 0 from the stored device list, preventing encrypted messages from reaching the primary phone. ## Fixes **Device list always retains primary after identity rotation ([#797](https://github.com/oxidezap/whatsapp-rust/pull/797))** **The problem.** When a device-add notification arrives with a `raw_id` that differs from your stored value, `patch_device_add` clears the device list and rebuilds it from the notification. `filter_devices_by_key_index` only keeps device 0 if it is already present in the input. After the clear, device 0 is gone. The rebuilt list ends up as something like `[{device_id: 19}]`, or empty if the notified device's key index is rejected. `get_user_devices` treats any existing record as authoritative. It only goes to the network when there is no record at all. So the broken list sticks. The self-healing usync re-fetch never fires. Every subsequent encrypted send to that contact skips their primary phone. **The fix.** `patch_device_add` now unconditionally re-adds device 0 after every rebuild. This mirrors `WAWebHandleAdvDeviceNotificationApi.handleDeviceAddNotification`, which always pushes `{ id: DEFAULT_DEVICE_ID, keyIndex: 0 }` back onto the list. whatsmeow enforces the same invariant. **Impact.** If a contact performed a companion relink or identity rotation during your session, messages you sent afterward could silently miss their primary phone until the next full usync ran. No API changes. No breaking changes. # June 9, 2026 — Device list: primary device preserved on device-remove Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-09-device-list-remove Fixed a bug where a remove notification targeting device 0 could silently drop the primary from the stored device list, suppressing the usync re-fetch and causing encrypted messages to miss the primary phone. ## Fixes **Device-remove path now ignores removes for the primary device ([#801](https://github.com/oxidezap/whatsapp-rust/pull/801))** **The problem.** `patch_device_remove` retained devices by filtering out the given `device_id` with no floor on the list. A remove notification targeting device 0 would delete its sender-key rows and persist a record with no device 0. Because `get_user_devices` treats any existing record as authoritative and only goes to the network when there is no record at all, the broken list would stick and suppress the usync re-fetch forever — the symmetric failure mode to the add-path bug fixed in [#797](https://github.com/oxidezap/whatsapp-rust/pull/797). The pre-existing `device_id_u16 != 0` guard only skipped *session* cleanup for the primary; it did not prevent the registry removal or the sender-key-row delete. **The fix.** `patch_device_remove` now returns immediately when `device_id == 0`. WA Web never drops the primary device, so a remove targeting it is treated as a no-op. This mirrors the invariant enforced on the add path since #797. **Impact.** No API changes. No breaking changes. A device-remove notification for device 0 is an invalid notification that WA Web ignores; handling it as a no-op now matches that behavior. # June 9, 2026 — Protocol error-handling strictness Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-09-error-strictness Several protocol paths that previously swallowed errors or returned ambiguous results now signal failures explicitly. is_on_whatsapp rejects unsupported JID types, per-subprotocol errors are preserved on IsOnWhatsAppResult and UserInfo, device-list/LID batch queries degrade per user, and IQ responses with unexpected type are rejected. ## Breaking changes **`is_on_whatsapp` rejects unsupported JID types ([#817](https://github.com/oxidezap/whatsapp-rust/pull/817))** Passing a non-PN/non-LID JID (group, newsletter, etc.) to `contacts().is_on_whatsapp()` now returns an error immediately. Previously the method silently dropped those JIDs with a `warn!` log, making it easy to miss the ignored input. ```rust theme={null} // This now returns Err("is_on_whatsapp only supports PN and LID JIDs, got …") client.contacts().is_on_whatsapp(&[Jid::group("120363021033254949")]).await?; ``` Validate that your inputs to `is_on_whatsapp` contain only `Jid::pn(...)` or `Jid::lid(...)` values. **`IqError::UnexpectedResponseType` added ([#817](https://github.com/oxidezap/whatsapp-rust/pull/817))** IQ responses whose `type` attribute is neither `result` nor `error` (e.g. `get`, `set`, or absent) now produce `IqError::UnexpectedResponseType { got: Option }` instead of being silently accepted. If you exhaustively match on `IqError`, add an arm for this variant. It is classified as a transient keepalive failure. ## New fields **Per-subprotocol error fields on `IsOnWhatsAppResult` and `UserInfo` ([#817](https://github.com/oxidezap/whatsapp-rust/pull/817))** The usync response parser now preserves server-returned per-subprotocol errors instead of silently omitting the associated field. Both result types gain new `Option` fields: `IsOnWhatsAppResult` gains: * `contact_error` — server error for the contact lookup subprotocol * `lid_error` — server error for the LID resolution subprotocol * `business_error` — server error for the business-info subprotocol `UserInfo` gains: * `lid_error`, `status_error`, `picture_error`, `business_error`, `devices_error` `UsyncSubprotocolError` is a new public type: ```rust theme={null} pub struct UsyncSubprotocolError { pub code: Option, pub text: Option, pub backoff: Option, } ``` When a subprotocol error is present its corresponding data field (`lid`, `status`, `picture_id`, etc.) will be `None` / `false` / empty. Check `*_error` when you need to distinguish "not returned" from "server refused". Both structs are `#[non_exhaustive]`; add `..` to any exhaustive struct destructuring that matches them. ## Fixes & hardening **Device-list and LID batch queries degrade per user ([#817](https://github.com/oxidezap/whatsapp-rust/pull/817))** `DeviceListSpec` and `LidQuerySpec` now skip individual users whose `` or `` subprotocol node contains an error, rather than letting that error propagate and fail the entire batch. A server-side per-user error (e.g. a temporarily unavailable device list for one noisy participant) no longer blocks sends to the rest of the group. A result-level `devices` subprotocol error is demoted to a `warn!` log rather than an abort, matching WA Web behavior. **App-state MAC validation ignores `hasMissingRemove` bypass ([#817](https://github.com/oxidezap/whatsapp-rust/pull/817))** `hasMissingRemove` was previously used to skip snapshot and patch MAC validation when a REMOVE mutation was missing its prior value, matching a misread of WA Web's behavior. WA Web actually tracks this flag only as telemetry for MAC-failure diagnostics and always rejects MAC mismatches. The bypass is removed; MAC mismatches are now fatal regardless of `hasMissingRemove`. **Retry receipts: peer-device retry without `recipient` is aborted ([#817](https://github.com/oxidezap/whatsapp-rust/pull/817))** A retry receipt from a peer device (your own linked device) that omits the `recipient` attribute has no resolvable target chat — WA Web returns `null` in this case. Previously the library logged a warning and fell back to `from.to_non_ad()`, which would produce a failing message lookup anyway. The receipt is now silently dropped rather than proceeding with a wrong chat target. **Retry key bundles require a one-time prekey for regular retries ([#817](https://github.com/oxidezap/whatsapp-rust/pull/817))** Regular retry receipts (non-FBID-bot) must include a `` (one-time prekey) child in the `` bundle. Bundles missing the one-time prekey are now rejected with an explicit error. The exception is primary `@bot` FBID bot retries, which follow WA Web's `bot_retry` parser path and may proceed with identity + signed prekey alone. **Rejected key bundles abort retry resend ([#817](https://github.com/oxidezap/whatsapp-rust/pull/817))** When a `` node is present but the key bundle is rejected (e.g. ADV validation failure, missing prekey), the retry resend is now aborted immediately. Previously the code logged a warning and continued, potentially sending with a stale or wrong session. **AD-JID decoder rejects unknown domain type bytes ([#817](https://github.com/oxidezap/whatsapp-rust/pull/817))** The binary-protocol AD-JID decoder now returns a parse error for domain type bytes that do not map to a known server (the WA Web `decodeJidU` behavior). Previously unknown bytes silently mapped to PN, masking malformed protocol frames. **ADV `NoAccountKey` log downgraded to `debug` ([#817](https://github.com/oxidezap/whatsapp-rust/pull/817))** The log emitted when a companion device's pre-key bundle or retry bundle omits `account_signature_key` and no stored account identity is available has been downgraded from `warn` to `debug`. This server shape is legitimately common when no account identity has been cached yet; the `warn` level created noise without being actionable. # June 9, 2026 — Group send: session setup hoisted out of chain lock Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-09-group-send-perf Concurrent sends to the same group no longer serialize behind prekey fetch I/O. The sender-key chain lock now covers only the CPU-bound SKDM creation and skmsg encrypt. A new per-group session-setup lock serializes cold prekey fetch and X3DH without blocking warm sends. ## Performance **Group send: session setup hoisted out of the sender-key chain lock ([#807](https://github.com/oxidezap/whatsapp-rust/pull/807))** Previously, `prepare_group_stanza` took the per-(group, sender) chain lock at the top and held it through the entire SKDM path — including cold-path device resolution and prekey fetch + X3DH session establishment. Any group send that needed to establish a new pairwise session held the chain lock across a server round-trip, so concurrent sends to the same group serialized behind that RTT. **Two-phase split.** `encrypt_for_devices` is now composed of two separately callable halves: * **`ensure_sessions_for_devices`** — network phase: LID-first session lookup, batch prekey fetch, parallel X3DH. Returns a `SessionPlan` (per-device LID overrides + 406 flag). Touches only session/identity state, never a sender-key chain. May span network I/O. * **`encrypt_for_devices_with_sessions`** — CPU phase: the bounded pairwise encrypt fan-out, consuming the `SessionPlan`. Safe to run under a lock that must not span I/O. `encrypt_for_devices` remains as the composition of both, so the DM path is unchanged. The group path now runs `ensure_sessions_for_devices` before taking the chain lock; the chain lock covers only SKDM creation + pairwise fan-out + `skmsg` encrypt. This matches WA Web, where `ensureE2ESessions` is a separate step before `GroupSkmsgJob`'s encrypt. **Session-setup lock.** Hoisting session setup out of the chain lock would have let two concurrent cold sends to the same group race prekey fetch + X3DH writes to the same per-device sessions. A new `SenderKeyStore::session_setup_lock` (per-group, default-uncontended; backed by the same `SignalStoreCache` lock map under a `::setup` key suffix) is held only during `ensure_sessions_for_devices`. Same-group cold sends serialize their setup exactly as before; warm sends (no SKDM needed) never take it, so the chain lock stays network-free. **New public items:** * `wacore::send::encrypt::ensure_sessions_for_devices` * `wacore::send::encrypt::encrypt_for_devices_with_sessions` * `wacore::send::encrypt::SessionPlan` * `SenderKeyStore::session_setup_lock` — defaulted trait method (returns a fresh uncontended mutex by default); production stores override via `SignalStoreCache::session_setup_lock` **Tracing.** Both the DM and group paths now emit `wa.send.ensure_sessions` + `wa.send.encrypt_fanout` instead of a single combined `wa.send.encrypt_fanout` span. **No breaking changes** — `encrypt_for_devices` and `prepare_group_stanza` keep their existing signatures. # June 9, 2026 — GroupInfo: derived PN→LID index, smaller cache and disk footprint Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-09-groupinfo-lid-pn-maps The pn_to_lid_map reverse index is now derived and skipped during serialization, roughly halving LID-group mapping bytes in memory and on disk. Breaking: lid_jid_for_phone_user is renamed to lid_user_for_phone_user and returns Option<&CompactString> instead of Option<&Jid>. ## Performance **`GroupInfo`: reverse PN→LID index derived and no longer persisted ([#810](https://github.com/oxidezap/whatsapp-rust/pull/810))** `GroupInfo` (in `wacore`) previously kept two parallel maps — `lid_to_pn_map` (LID user → phone `Jid`) and `pn_to_lid_map` (phone user → LID `Jid`) — and both were serialized into every `group_metadata` cache blob. Because `pn_to_lid_map` is a pure function of `lid_to_pn_map`, this doubled the mapping bytes in both the in-memory 250-entry TTL cache and on disk. The reverse index is now: * **`HashMap`** (LID user string stored inline, not a `Jid`). * **`#[serde(skip)]`** — absent from serialized `group_metadata` blobs. Old blobs that carry `pn_to_lid_map` still decode correctly; `serde_json` ignores the unknown field and the index is rebuilt from the forward map on deserialize. * **Rebuilt on deserialize** via a `GroupInfoDe` shadow struct, so `query_info` warm-cache lookups always have a valid reverse index without any extra I/O. For a large LID group this roughly halves the serialized mapping section in `group_metadata`. ## Breaking changes **`lid_jid_for_phone_user` → `lid_user_for_phone_user`, returns `Option<&CompactString>`** ```rust theme={null} // Before let lid_jid: Option<&Jid> = info.lid_jid_for_phone_user("15551234567"); // After — returns the LID user string; reconstruct the Jid on demand if needed let lid_user: Option<&CompactString> = info.lid_user_for_phone_user("15551234567"); if let Some(u) = lid_user { let jid = Jid::lid(u.clone()); } ``` The sole internal consumer (`phone_device_jid_into_lid`) reconstructs the `Jid::lid` on demand — the new API avoids pre-allocating N `Jid`s per `GroupInfo` build while keeping the same behaviour for callers. **`lid_to_pn_map()` accessor removed** The `lid_to_pn_map() -> &HashMap` method had zero external callers and is removed. Use `phone_jid_for_lid_user(lid_user)` to look up individual entries. # June 9, 2026 — LidPnEntry: Arc fields for unbounded-cache memory savings Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-09-lid-pn-arc-str LidPnEntry.lid and .phone_number are now Arc. Cache keys share the entry's Arc allocations, halving per-mapping heap objects in the unbounded LID-PN cache. ## Performance **`LidPnEntry` fields changed from `String` to `Arc` ([#811](https://github.com/oxidezap/whatsapp-rust/pull/811))** `LidPnEntry.lid` and `.phone_number` are now `Arc`. `LidPnCache::add` clones the entry's own `Arc`s as the map keys for both lookup directions, so each identifier lives once per mapping instead of once as a key and again inside the entry. This cache is **unbounded by design** (no TTL, no capacity ceiling — matching `WAWebLidPnCache`), so per-entry byte savings compound over the full contact base: roughly **40–80 bytes per mapping**, or **\~0.5–1 MB retained** for a process that has learned 10k LID-PN mappings. ### Migration **Constructors are source-compatible.** `LidPnEntry::new` and `LidPnEntry::with_timestamp` accept `impl Into>`, so both `String` and `&str` arguments continue to compile without changes. **Direct field reads return `Arc` instead of `String`.** Code that accesses `.lid` or `.phone_number` directly needs a deref for `&str` comparisons: ```rust theme={null} // Before assert_eq!(entry.lid, some_string); // After assert_eq!(&*entry.lid, some_string); // or assert_eq!(entry.lid.as_ref(), some_string); ``` `Display` and format strings work unchanged — `Arc` implements `Display`. **`get_phone_number` return type unchanged.** It still returns `String` (cost-identical to before). **Wire/persistence unchanged.** `Arc` serializes as a plain JSON string, so persisted `lid_pn_mapping` rows and custom `CacheStore` backends are unaffected. ### Summary | | Before | After | | --------------------------------------- | ----------- | ---------------------------------------------- | | `LidPnEntry.lid` / `.phone_number` type | `String` | `Arc` | | Constructor argument types | `String` | `impl Into>` (String/\&str both work) | | `get_phone_number` return type | `String` | `String` (unchanged) | | Heap allocations per mapping in cache | \~4 strings | \~2 strings (shared) | | Wire/persistence format | JSON string | JSON string (unchanged) | # June 9, 2026 — Response structs marked #[non_exhaustive] Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-09-non-exhaustive All library-returned response and result structs are now #[non_exhaustive], completing the pre-1.0 API stabilisation pass. ## Breaking changes **Response and result structs are `#[non_exhaustive]` ([#794](https://github.com/oxidezap/whatsapp-rust/pull/794))** `#[non_exhaustive]` has been applied to all public structs the library returns to consumers but never requires consumers to construct. `IsOnWhatsAppResult` already carried the attribute; this pass extends it to the remaining types before 1.0, while adding new fields is still cheap. **Affected types:** *wacore:* * `UserInfo`, `LidQueryResponse` * `BusinessProfile`, `BusinessHours`, `BusinessHoursConfig`, `BusinessCategory` * `GroupInfoResponse`, `GroupParticipantResponse`, `GroupParticipatingResponse`, `ParticipantChangeResponse` *whatsapp-rust:* * `SendResult`, `UploadResponse` * `CreateGroupResult` * `CreateCommunityResult`, `CommunitySubgroup`, `LinkSubgroupsResult`, `UnlinkSubgroupsResult` **What changes:** External crates can no longer construct these types via struct-literal syntax or match them exhaustively without a `..` wildcard. Field reads are unaffected. **In practice:** The library constructs all of these — consumers only receive them. The PR confirmed zero struct-literal constructions outside the defining crate, so the attribute enforces the already-intended API contract. **Migration:** Add `..` to any exhaustive struct destructuring patterns: ```rust theme={null} // Before (fails to compile outside the crate): let SendResult { message_id, to } = result; // After: let SendResult { message_id, to, .. } = result; ``` # June 9, 2026 — Zero-copy inbound frame feeding Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-09-recv-zero-copy FrameDecoder gains feed_bytes, eliminating the last full memcpy on the receive path in steady state. ## Performance **Zero-copy inbound frame feeding ([#803](https://github.com/oxidezap/whatsapp-rust/pull/803))** `FrameDecoder` in `wacore-noise` gains a new `feed_bytes(Bytes)` method that adopts an owned payload's allocation wholesale instead of copying it into the internal staging buffer. In steady state — the WebSocket transport is message-oriented, so each message arrives on a clean frame boundary and the payload has no other references — `Bytes::try_into_mut` succeeds and the bytes are adopted without copying. The one remaining full `memcpy` of every inbound byte on the receive path is eliminated. Shared payloads (refcount > 1) and partial-frame leftovers fall back to the existing `extend_from_slice` path, so correctness never depends on the fast path firing. As a secondary benefit, each adopted payload is its own allocation. A node retained by the app (queued chat lane, `Event::RawNode`) now pins only its own WebSocket message's bytes instead of holding a reference into a shared staging buffer alongside unrelated data. The main `read_messages_loop` has been updated to move the payload into `feed_bytes` instead of borrowing it for the old `feed`. The borrowing `feed` method is unchanged and remains available for the handshake path. **No breaking changes.** `feed` is unchanged; `feed_bytes` is additive. # June 9, 2026 — Retry: device-list resync on unknown-device retry Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-09-retry-resync Fixed an infinite retry loop where a newly-linked device absent from the local registry would retry group messages forever because it was never included in subsequent sends. Retries from unknown devices now trigger a device-list resync, learning the device for the next send. ## Fixes **Device-list resync triggered on retry from unknown device ([#802](https://github.com/oxidezap/whatsapp-rust/pull/802))** **The problem.** When a contact links a new device after your last device-list sync, your local registry does not know about it. The device receives your group `skmsg` from the server but never got a sender key, so it can only fail to decrypt and send a retry receipt. The previous fix ([#795](https://github.com/oxidezap/whatsapp-rust/pull/795)) recovered retries that carry a `` bundle by building a fresh Signal session from the bundle and resending. But a retry that arrives *without* a bundle was simply dropped — and nothing scheduled a resync. The reconciliation that fires when a prekey fetch returns 406 during a send never triggered for this device either, because the device was not in the set being sent to. The result: the device was never learned, retries continued indefinitely, and the user's new device never received messages. **The fix.** A retry from an unknown device is now treated as a staleness signal regardless of whether it carries a bundle. `handle_retry_receipt` calls `schedule_unknown_device_sync` for any requester that is missing from the local device registry, *before* consulting `should_drop_unknown_device_retry`. The method deduplicates per user via `PendingDeviceSync` so a retry storm from one unknown device cannot fan out into a usync storm. Once the resync completes, the device appears in the registry, the next send includes it in the sender-key distribution, and the retries stop. This mirrors WA Web's `syncDeviceListJob` trigger on the retry path. The drop predicate (`!keys_present && !device_known`) is unchanged — a retry without a bundle is still not recovered for the *current* message. The resync ensures it is recovered on the *next* send. **Impact.** If a contact linked a new device after your last usync, messages you sent to that contact's group would be missing that device indefinitely. The device now self-heals on the next send after the resync completes. No API changes. No breaking changes. # June 9, 2026 — Device registry: empty record treated as a cache miss Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-09-usync-empty-record Fixed a bug where a present-but-empty device record was returned as authoritative, permanently blocking encrypted sends to the affected user. The registry now treats an empty record as a miss so it self-heals from the network. ## Fixes **Empty device record now falls through to network instead of blocking sends ([#799](https://github.com/oxidezap/whatsapp-rust/pull/799))** **The problem.** `get_user_devices` only goes to the network when a user is completely absent from the device registry. A record that exists but contains no devices was returned as-is — `Some([])` — and because any present record was treated as authoritative, the usync re-fetch never fired again. An empty device list is never valid: WA Web always keeps the primary (device 0) in a user's device list, so an empty record can only be local corruption. The practical effect was that the affected user became permanently unreachable: every send silently returned an empty recipient list with no network call and no error. **The fix.** Both cache layers (L1 in-memory and L2 database) now treat an empty result the same as a missing record — `get_devices_from_registry` returns `None` instead of `Some([])` when the resolved device list is empty. `get_user_devices` then falls through to the usync fetch as it would for a brand-new contact, and the corrupted record is overwritten with the valid server-side list on the next send. Additionally, `process_device_list_response` now skips persisting any empty device list that usync itself returns (transient or corrupt server response), so a good cached record is never clobbered with an empty one that would then trigger a re-fetch on every subsequent send. The hot path is unchanged: a record with real devices (including the common single-device `[0]` case) is still served from the registry with no network round-trip. **Impact.** If a contact had an empty device record in your local store (for example, as a result of a corrupted usync response), messages you sent to them after that point would silently produce no encrypted recipients and never be delivered. The state now self-heals on the next send. No API changes. No breaking changes. # June 10, 2026 — Binary: inline attribute storage via SmallVec Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-10-attrs-inline-smallvec Attrs now stores up to 2 attributes inline, cutting per-node heap allocations on the encode path. IqError::Disconnected now wraps Box. ## Performance **Binary: `Attrs` backed by `SmallVec` for inline storage ([#819](https://github.com/oxidezap/whatsapp-rust/pull/819))** `Attrs` previously used a plain `Vec` as its backing store, so every node on the encode path paid one heap allocation just for the attribute buffer. In a steady-state send/receive profile this was \~7 allocations per message stanza. On a group fanout it was two per recipient (`to` + `enc`). `Attrs` now uses `AttrsVec` — `SmallVec<[(Cow<'static, str>, NodeValue); 2]>` — so nodes carrying ≤2 attributes keep them on the stack alongside the node with zero heap allocation. Nodes with ≥3 attributes spill to the heap as before. **Measured impact (iai-callgrind, instruction counts):** | Benchmark | Delta | | -------------------------------------------------- | -------------------------------------------------------- | | `marshal_allocating` (typical stanza) | −6.6% instructions | | `marshal_reusing_buffer` | −6.5% instructions | | `marshal_many_children_allocating` (2048 children) | −9.9% instructions, −10% estimated cycles, −25% RAM hits | | `marshal_long_string` | +4.4% (+208 instructions, content-dominated shape) | | unmarshal / unpack / attr\_parser | unchanged | Allocation counts per iteration (counting allocator): message-shaped stanza 15→11 (−27%); group stanza with 800 participants 4012→2412 (−40%), bytes allocated −22%. ## Breaking changes **`Attrs.0` type (`wacore-binary`)** `Attrs.0` changes from `Vec<(Cow<'static, str>, NodeValue)>` to `AttrsVec`. Construction via `Attrs::new()`, `Attrs::with_capacity()`, `NodeBuilder`, iteration, and the serde representation are all unchanged (the serde wire format is byte-identical). Code that held a reference to `Attrs.0` typed as `Vec` needs updating: ```rust theme={null} // Before let v: &Vec<_> = &attrs.0; // After — use AttrsVec directly, or convert: let v: Vec<_> = attrs.0.into_vec(); ``` `AttrsVec` is re-exported as `wacore_binary::node::AttrsVec`. The `spilled()` method on `SmallVec` reports whether the list has overflowed to the heap. **`IqError::Disconnected` now carries `Box`** Both `wacore::request::IqError` and the main crate's mirror change `Disconnected(Node)` to `Disconnected(Box)`. The larger `Node` size after the `SmallVec` inline array triggered clippy's `large_enum_variant`; boxing the rare disconnect payload is correct regardless. ```rust theme={null} // Before IqError::Disconnected(node) => { /* node: Node */ } // After IqError::Disconnected(boxed) => { let node = *boxed; } ``` # June 10, 2026 — Encrypted CAG reactions and channel comments Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-10-cag-enc-reactions-comments Reactions to Community Announcement Group posts are now encrypted automatically. A new comments() API lets you send and receive encrypted threaded replies on CAG channel posts. ## New features **Encrypted reactions in Community Announcement Groups** `Client::send_reaction` now transparently detects Community Announcement Groups (CAG — the default announcement subgroup of a community) and encrypts the reaction before sending. No API change is needed; the same call works for DMs, regular groups, and CAGs: ```rust theme={null} // Works identically for DMs, regular groups, and CAGs. client.send_reaction(&cag_jid, target_key, "🔥").await?; ``` For a CAG chat the library checks `GroupInfo::is_community_announce` (cached from group metadata). When true, the reaction emoji and timestamp are encrypted with the target post's `messageSecret` under the `"Enc Reaction"` HKDF use-case and shipped as an `enc_reaction_message` envelope — matching WA Web's `WAWebReactionEncryptMsgData` flow. If the parent secret was not captured (message received before session started, or `msg_secret_policy` disabled without a resolver) the call fails with a descriptive error rather than silently emitting a plaintext reaction the channel would reject. Incoming encrypted reactions are decrypted transparently by the receive path and dispatched in the same plaintext `reaction_message` shape as an ordinary group reaction. The `key` field is filled from the envelope's `target_message_key` so event handlers require no changes. *** **Channel comments via `client.comments()`** A new `Comments` feature handle lets you post encrypted threaded replies under a CAG post. Comments require the parent post's `messageSecret` (captured during receive) and are authored under the LID identity, matching WA Web's `getMeLidUserOrThrow`: ```rust theme={null} let parent_key = wa::MessageKey { remote_jid: Some(cag_jid.to_string()), from_me: Some(false), id: Some("3EB0POSTID".to_string()), // Must be the post author so receivers can derive the HKDF key. participant: Some(post_author_jid.to_string()), }; // Text comment (extended_text_message body) let result = client.comments() .send_text(&cag_jid, parent_key.clone(), "Great post!") .await?; // Arbitrary message body let body = wa::Message { extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { text: Some("Great post!".to_string()), ..Default::default() })), ..Default::default() }; let result = client.comments() .send_message(&cag_jid, parent_key, body) .await?; ``` The comment carries a fresh `messageSecret` of its own so it can receive encrypted reactions. Both the comment body secret and the parent-post secret are persisted under the correct ids. Incoming encrypted comments are decrypted transparently. The decrypted body is dispatched as a normal `Event::Message`. The parent post key surfaces on the new `MessageInfo::comment_target` field, since the inner `Message` proto has no slot for the threading link: ```rust theme={null} Event::Message(msg, info) => { if let Some(parent_key) = &info.comment_target { println!("Comment on post: {:?}", parent_key.id); if let Some(text) = msg.text_content() { println!("Comment text: {}", text); } } } ``` The comment's own `messageSecret` (from the outer envelope) is persisted under the comment's id and sender so that future encrypted reactions to the comment can be decrypted. ## Breaking changes Pre-1.0, additive surface. The following structs and enums gain new fields/variants. If you construct them with exhaustive struct-literal syntax (without `..Default::default()`), add the new fields: **`MessageInfo`** gains `comment_target: Option`: ```rust theme={null} // Add the field or use ..Default::default(): let info = MessageInfo { id: "...".to_string(), // ... peer_recipient_pn: None, comment_target: None, // new bcl_participants: vec![], }; ``` **`GroupInfo`** gains `is_community_announce: Option`. Blobs persisted before this field was added will deserialize with `None` ("unknown") and trigger one metadata re-query when the value is first needed. No migration is required — existing serialized blobs remain valid. **`SecretEncKind`** gains two variants that must be covered in exhaustive `match` arms: * `SecretEncKind::EncReaction` — an `enc_reaction_message` envelope * `SecretEncKind::EncComment` — an `enc_comment_message` envelope # June 10, 2026 — Contacts: fix async_trait / boxed-future compilation error Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-10-hrtb-closures is_on_whatsapp() and get_user_info() now compile correctly when called from #[async_trait] implementations or any context that boxes the returned future. ## Bug Fix **Contacts: `is_on_whatsapp` and `get_user_info` compile in `#[async_trait]` contexts ([#826](https://github.com/oxidezap/whatsapp-rust/pull/826))** Calling either method from an `#[async_trait]` implementation — or any other context that boxes the returned future — previously produced a hard compiler error: ``` error: implementation of `FnOnce` is not general enough = note: closure with signature `fn(&'0 IsOnWhatsAppResult) -> (&Jid, Option<&Jid>)` must implement `FnOnce<(&'1 IsOnWhatsAppResult,)>`, for any two lifetimes `'0` and `'1`... ``` The root cause was inside the library: the internal `persist_lid_mappings` helper received closures that returned references tied to a concrete lifetime. Rust infers such closures at a single lifetime rather than the higher-ranked `for<'r> Fn(&'r _)` form. Because the closure types were embedded in the public methods' future types, the unprovable HRTB obligation leaked to every boxing consumer — nothing in user code could work around it. **Fix.** The three offending closures are now named `fn` items (`forward_lid_pair`, `reverse_lid_pair`, `user_info_lid_pair`), which implement `Fn` for every lifetime by construction. No API changes, no extra allocations, identical behavior. A compile-time regression guard (`tests/async_trait_boxed_future_compat.rs`) was added that reproduces the exact consumer shape from the original report — an `#[async_trait]` impl holding an `RwLock>>` — and fails to compile if the issue is ever reintroduced. **No breaking changes.** Fixes [#825](https://github.com/oxidezap/whatsapp-rust/issues/825). # June 10, 2026 — impl Into API convention Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-10-jid-into-api-convention All mutating public methods now accept impl Into. Pass &jid directly without cloning; existing owned-Jid call sites continue to compile unchanged. ## Change **`From<&Jid> for Jid` is now implemented in `wacore-binary`.** All mutating public methods that previously took either `Jid` (by value) or `&Jid` (by reference) now take `impl Into` uniformly. This covers the full public surface: `send_message`, `forward_message`, `send_message_with_options`, `edit_message`, `edit_message_encrypted`, `revoke_message`, `keep_message`, `pin_message`, `unpin_message`, `send_reaction`, and all 53 feature methods across `Groups` (26), `Community` (6), `Newsletter` (2), `Presence` (1), `Polls`, `Events`, `Comments`, and `Reactions`. ### Before ```rust theme={null} // Required .clone() to re-use the JID let result = client.send_message(jid.clone(), msg).await?; client.revoke_message(jid, &result.message_id, RevokeType::Sender).await?; // Features took &Jid, core methods took Jid — two conventions on one surface client.groups().leave(&group_jid).await?; client.send_message(group_jid, msg).await?; // group_jid now moved ``` ### After ```rust theme={null} // Pass &jid whenever you want to keep it; owned Jid still moves for free let result = client.send_message(&jid, msg).await?; client.revoke_message(&jid, &result.message_id, RevokeType::Sender).await?; // One convention everywhere client.groups().leave(&group_jid).await?; client.send_message(&group_jid, msg).await?; ``` ### How it works `From<&Jid> for Jid` clones the JID. For borrow-callers this is one allocation; for typical phone-number JIDs the `user` part is stored inline (up to 24 bytes via `CompactString`), making the clone heap-free. Owned callers continue to move the value at zero cost. Both forms are pinned by the new `jid_into_convention` compile-time guard in the test suite. **Monomorphization discipline:** Large async state machines (`send_message_with_options`, `edit_message`, `edit_message_encrypted`, `revoke_message`) keep a monomorphic `_inner` behind a thin generic shim that only does `.into()`, so each `Into` instantiation cannot duplicate the full state machine. ## Breaking changes Pre-1.0 signature change. All converted methods now declare `impl Into` in their public signature. * **Owned callers** (`jid` by value) — compile unchanged. * **Borrow callers** (`&jid`) — compile unchanged via `From<&Jid>`. * **Trait-object / fn-pointer uses** of these inherent methods (e.g. `let f: fn(&Client, Jid, ...) = Client::send_message`) — need a wrapping closure, since `impl Trait` parameters are not compatible with concrete fn-pointer types. This is an uncommon pattern for inherent async methods. ## Deliberate exceptions Read-only methods keep `&Jid`. Converting them would force a clone on every borrow-caller for no benefit. \~59 query methods (`query_info`, `get_metadata`, etc.) are unchanged. `&[Jid]` slices and `Option<&Jid>` parameters are also out of scope. # June 10, 2026 — Group send: participant-list hash via single arena Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-10-phash-arena-sort participant_list_hash now formats all device JIDs into one shared string buffer and sorts lightweight range views, cutting per-call heap allocations from O(n) to 3 for an 800-device group. ## Performance **Group send: `participant_list_hash` uses a shared arena instead of one `String` per device ([#822](https://github.com/oxidezap/whatsapp-rust/pull/822))** `participant_list_hash` computes the `phash` that travels on every group send (since #678 it is included on all sends, not just multi-device ones). Previously it collected all participant JIDs into a `Vec` — one heap `String` per device — sorted them, and hashed the concatenation. For an 800-device group this was 801 discarded allocations per message on the hot send path. **Arena approach.** All device JIDs are now formatted into a single shared `String` (the arena). A second `Vec<(usize, usize)>` stores `(start, end)` range pairs into the arena. Sorting those range pairs by `&arena[start..end]` is lexicographically identical to sorting the individual strings, so the resulting SHA-256 + base64 hash is byte-for-byte the same as before. The existing pinned cross-implementation test vectors (`phash_crosscheck_vectors`, locked against whatsmeow and WA Web) pass unchanged. **Allocation counts (release, 800 devices, 1 000 iterations):** | | Allocations per call | | -------------------------------------------------------------------- | -------------------- | | Before (one `String` per device + sort) | 801 | | After (arena + range sort, full function including SHA-256 + base64) | 3 | Wall time is dominated by the SHA-256 over \~26 KB of concatenated JIDs and stays flat; the win is 798 fewer allocations per group send. **New public item — `Jid::push_ad_to`** A new `#[inline]` method on `Jid` in `wacore-binary`: ```rust theme={null} pub fn push_ad_to(&self, buf: &mut String) ``` Appends the AD-string form (`user.agent:device@server`) directly into an existing `String` buffer without allocating a new one. `to_ad_string` now delegates to it, keeping identical output. Use `push_ad_to` when batching many JIDs into a single buffer. **No breaking changes.** `to_ad_string` output is unchanged; `push_ad_to` is purely additive. # June 10, 2026 — Prekey upload window reuse (WA Web parity) Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-10-prekey-unupload-watermark The prekey upload path now tracks a first-unuploaded watermark alongside the generation counter, matching WA Web's two-watermark model. Batches re-offer leftover generated-but-unuploaded keys before minting new ones, and retry-receipt keys are reused from the window head. Custom backend implementations must add the new mark_prekeys_uploaded method. ## What changed **Background:** Previously, every prekey upload generated a full fresh batch of 812 keys regardless of how many were already stored but never uploaded (e.g. after a failed IQ). The old `next_pre_key_id` counter served double duty as both the generation watermark and the upload watermark, and a failed upload left dead rows in the store that the next attempt silently skipped. WA Web uses two separate meta-keys — `NEXT_PK_ID` (advances at generation time) and `FIRST_UNUPLOAD_PK_ID` (advances past the upload window) — so that `getOrGenPreKeys(812)` is a *target-total* operation: it re-offers the `[FIRST, NEXT)` leftover window and only generates `812 - (NEXT - FIRST)` new keys. This release brings the Rust client into full parity with that model. ## Behavior changes **Prekey upload window reuse** Upload batches now re-offer leftover generated-but-unuploaded keys first, generating only as many new keys as needed to reach the configured count: * **FIRST is advanced past the window *before* the IQ is sent** (matching WA Web's `markKeyAsUploaded` ordering in `PreKeysJob.js`). This means that after a mid-flight IQ failure the window is intentionally empty — the server state is unknown and re-offering an id a peer may already have consumed would corrupt the server pool. The next upload attempt mints strictly fresh ids. The abandoned rows stay stored locally and remain decryptable if the upload did land. What the window *reuse* protects is everything *before* the send: keys generated but never sent (process death or disconnect between the generation and IQ phases). * A window with more leftovers than the target uploads only `wanted` of them and generates nothing, without regressing `NEXT_PK_ID`. * A corrupt `first > next` pair and windows that would cross the 24-bit id boundary collapse to a fresh window automatically. **Retry-receipt single prekey (`getOrGenSinglePreKey` parity)** The single prekey allocated for retry receipts now mirrors WA Web's `getOrGenSinglePreKey = getOrGenPreKeys(1)`: * When a leftover exists in the window, the window head is returned unchanged. The next batch upload re-offers the same stored key (true WA Web parity). * A consumed window head (the peer spent the key) heals by skipping the dead slot to the next live key, instead of failing like WA Web does. * A fully exhausted window generates a fresh key at `NEXT_PK_ID`. **Upload marking is UPDATE-only** After a successful IQ, keys are marked uploaded via an SQL `UPDATE` (not `UPSERT`). A key deleted between the upload snapshot and the mark (a one-time key consumed by an inbound pkmsg while the IQ was in flight) stays deleted and is never resurrected. ## Breaking changes ### New `SignalStore` method: `mark_prekeys_uploaded` Custom backend implementations must add this method. It marks already-stored prekeys as uploaded using `UPDATE` semantics — consuming rows must not be resurrected by the mark: ```rust theme={null} async fn mark_prekeys_uploaded(&self, ids: &[u32]) -> Result<()>; ``` The `InMemoryBackend` no-ops this (it doesn't track the uploaded flag). The `SqliteStore` implementation uses chunked `UPDATE` to stay under SQLite's host-parameter limit. **If you implement a custom backend**, add: ```rust theme={null} async fn mark_prekeys_uploaded(&self, ids: &[u32]) -> Result<()> { if ids.is_empty() { return Ok(()); } // UPDATE prekeys SET uploaded = true WHERE id IN (...) AND device_id = ? // Use UPDATE, not UPSERT — consumed rows must stay deleted. todo!() } ``` ### `DeviceCommand::SetPreKeyWatermarks` replaces `SetNextPreKeyId` `DeviceCommand::SetNextPreKeyId(u32)` has been removed. Use `SetPreKeyWatermarks` to update both watermarks atomically: ```rust theme={null} // Before DeviceCommand::SetNextPreKeyId(next_id) // After DeviceCommand::SetPreKeyWatermarks { next_pre_key_id: next_id, first_unupload_pre_key_id: first_id, } ``` Both watermarks must always move together — the split-update model was exactly how the pre-watermark code lost track of generated keys. ### `Device` gains `first_unupload_pre_key_id` A new `u32` field is added to `Device`: ```rust theme={null} pub first_unupload_pre_key_id: u32, // serde(default) = 0 (unset/legacy) ``` The value `0` means "unset" (legacy device before this watermark existed). The upload path initialises it on the first upload from the legacy-safe starting point. Existing serialized blobs and SQLite rows load with `0` thanks to `#[serde(default)]` and `DEFAULT 0` on the new column — no migration step is required for existing deployments. ### SQLite migration A new migration adds the column with a safe default: ```sql theme={null} -- up ALTER TABLE device ADD COLUMN first_unupload_pre_key_id INTEGER NOT NULL DEFAULT 0; -- down ALTER TABLE device DROP COLUMN first_unupload_pre_key_id; ``` The `SqliteStore` applies this automatically via Diesel migrations on startup. ### `allocate_next_one_time_prekey_id` removed The crate-private `Client::allocate_next_one_time_prekey_id` method has been replaced by `get_or_gen_single_pre_key`. This was an internal surface and is only relevant if you had a fork that called it directly. # June 10, 2026 — Group send: server-aware LID-PN probe on warm device lookups Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-10-server-aware-lookup-probe get_devices_from_registry now probes only the single relevant direction of the LID-PN cache per member, cutting device resolution from 3 cache operations to 2 and reducing warm 800-member send latency by 26%. ## Performance **Group send: directed LID-PN probe in `get_devices_from_registry` ([#823](https://github.com/oxidezap/whatsapp-rust/pull/823))** Each call to `get_devices_from_registry` resolves a `Jid` to its canonical lookup keys before hitting the device registry cache. Previously, the key resolver (`resolve_lookup_keys`) only received the bare user string. It blindly probed **both** directions of the LID-PN map — `get_phone_number` then `get_current_lid` — for every group member. This resulted in 3 moka lookups per participant before the registry lookup. **Server-aware probe.** A hot caller in `get_devices_from_registry` already holds the full `Jid`, and the namespace fully determines which direction is meaningful: * A **LID** user (`@lid`) can only appear in the `lid → pn` map. * A **PN** user (`@s.whatsapp.net`) can only appear in the `pn → lid` map. The new `resolve_lookup_keys_for_jid` dispatches on the JID's server field and probes exactly one direction for LID/PN JIDs. All other namespaces keep the existing two-probe fallback. This turns 3 cache operations per member into 2 with no change in the resulting canonical keys. > A concurrent-resolution prototype (`buffered(50)` stream, mirroring WA Web's `SESSION_CHECK` batching) was measured and rejected: 962 µs vs. 675 µs serial for a warm 800-member send (+43% slower). Moka hits resolve immediately, so there is no I/O to overlap and the stream machinery is pure overhead. The reliable gain comes from doing less work per member, not reordering the same work. **Benchmark** (release, warm caches, 800 members × 2 devices, 500 iterations): | | Warm 800-member send | | -------------------------- | -------------------- | | Before (blind two-probe) | 675 µs | | After (server-aware probe) | 497 µs (**−26%**) | **No breaking changes.** `get_devices_from_registry` keeps its existing signature; the directed probe is an internal implementation detail. # June 11, 2026 — Bot API overhaul (breaking) Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-11-bot-api-overhaul Lifecycle simplification, typed event registrars, single-dependency consumption, and messaging sugar ahead of 1.0. PR [#852](https://github.com/oxidezap/whatsapp-rust/pull/852) is a focused breaking-change pass over the public bot API while such changes are still cheap. You will need to migrate your code, but the migration is mechanical. See the [breaking changes](#breaking-changes) section below. ## New features ### One dependency is enough `whatsapp-rust` now re-exports `wacore`, `wacore_binary`, and `waproto` wholesale. A consumer needs exactly one `Cargo.toml` line: ```toml theme={null} [dependencies] whatsapp-rust = "0.6" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ``` All sub-crate paths (`whatsapp_rust::wacore::...`, `whatsapp_rust::waproto::whatsapp`, etc.) are reachable through the main crate, including when pinning a git revision. `UreqHttpClient` moved from the misplaced `whatsapp_rust::transport` to `whatsapp_rust::http`. A new `whatsapp_rust::prelude` covers the common bot path in one import line (including `wa` as the protobuf alias): ```rust theme={null} use whatsapp_rust::prelude::*; ``` ### Simplified lifecycle `Bot::run(self)` now runs the bot on the current task with a **single** `await`. `Bot::spawn(self)` starts it in the background and returns a `BotHandle`: ```rust theme={null} // Foreground — blocks until logout or disconnect bot.run().await; // Background — returns immediately let handle = bot.spawn(); let client = handle.client(); // full Client API handle.shutdown().await; // graceful: flushes state, then stops handle.abort(); // escape hatch, skips flush ``` Awaiting a `BotHandle` resolves to `()` (was `Result<(), Canceled>`). Dropping the handle still aborts the task — the e2e harness relies on this. ### Builder defaults With the default cargo features, transport, HTTP client, and runtime are pre-filled (Tokio WebSocket, ureq, Tokio), so only the backend has to be provided: ```rust theme={null} let bot = Bot::builder() .with_backend(SqliteStore::new("whatsapp.db").await?) .build() .await?; ``` `with_*` setters are now available in every typestate, so they also work as overrides. The typestate markers got names (`MissingBackend`, `MissingTransport`, `MissingHttpClient`, `MissingRuntime`) — a missing-field compile error now names which field is missing. `with_backend` accepts `impl Backend + 'static` (no caller-side `Arc::new`). `with_backend_arc` accepts an already-shared `Arc`. ### Typed event registrars Dedicated builder methods cover the common event path — no `match &*event` needed: ```rust theme={null} Bot::builder() .on_message(|ctx| async move { // ctx: MessageContext — has reply, reply_quoting, react, send_message }) .on_qr_code(|code, timeout| async move { /* ... */ }) .on_pair_code(|code, timeout| async move { /* ... */ }) .on_connected(|client| async move { /* ... */ }) .on_logged_out(|info| async move { /* ... */ }) ``` `on_event` / `on_event_for` are unchanged as catch-alls. `with_event_handler` registers a struct-based `EventHandler` directly on the bus for stateful handlers (eliminates the clone-dance that closure captures force on consumers). All handlers **accumulate** — registering a second handler now runs both instead of silently replacing the first. The bus still skips materializing events nobody wants: the adapter registers the union of all handler interests and filters per handler at dispatch. ### Messaging sugar New helpers for the common send paths: ```rust theme={null} // On MessageContext (inside on_message) ctx.reply("pong").await?; ctx.reply_quoting("pong").await?; // On Client client.send_text(&jid, "hello").await?; // Static constructors on wa::Message (via MessageBuilderExt) use whatsapp_rust::prelude::*; let msg = wa::Message::text("hello"); let msg_with_quote = wa::Message::text_with_context("hello", ctx.build_quote_context()); ``` The raw `ctx.send_message(wa::Message { ... })` path is unchanged for advanced cases. ### `BotBuilderError` typed `BotBuilderError::Other(anyhow)` is gone. The only variant is now `Store(StoreError)` — the one error `build()` can actually produce. ## Breaking changes | Old | New | | ------------------------------------------------ | ----------------------------------------------------------------------------------- | | `bot.run().await?.await?` | `bot.run().await` (foreground) or `let handle = bot.spawn()` (background) | | `.with_backend(Arc::new(store))` | `.with_backend(store)` — `Arc` wrapping is internal | | `.with_backend(arc)` for a shared Arc | `.with_backend_arc(arc)` | | `use whatsapp_rust::transport::UreqHttpClient` | `use whatsapp_rust::http::UreqHttpClient` | | `bot::Missing` typestate marker | `bot::MissingBackend` / `MissingTransport` / `MissingHttpClient` / `MissingRuntime` | | `BotBuilderError::Other(anyhow)` | removed; only `BotBuilderError::Store(StoreError)` | | Registering two handlers — second replaces first | Both run; handlers accumulate | ### Migration guide **Lifecycle:** ```rust theme={null} // Before let mut bot = builder.build().await?; let handle = bot.run().await?; handle.await?; // After (foreground) let bot = builder.build().await?; bot.run().await; // After (background / ctrl-c handling) let bot = builder.build().await?; let handle = bot.spawn(); tokio::signal::ctrl_c().await?; handle.shutdown().await; ``` **Backend:** ```rust theme={null} // Before let backend = Arc::new(SqliteStore::new("whatsapp.db").await?); Bot::builder().with_backend(backend) // After Bot::builder().with_backend(SqliteStore::new("whatsapp.db").await?) // After (already-shared Arc) Bot::builder().with_backend_arc(existing_arc) ``` **`UreqHttpClient` import:** ```rust theme={null} // Before use whatsapp_rust::transport::UreqHttpClient; // After use whatsapp_rust::http::UreqHttpClient; ``` **Builder dependencies — drop sibling crates from `Cargo.toml`:** ```toml theme={null} # Before (all required) whatsapp-rust = "0.6" whatsapp-rust-sqlite-storage = "0.6" whatsapp-rust-tokio-transport = "0.6" whatsapp-rust-ureq-http-client = "0.6" wacore = "0.6" waproto = "0.6" # After (one line is enough) whatsapp-rust = "0.6" tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } ``` # June 11, 2026 — Docker image: share-generics, build-std, multi-arch Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-11-dockerfile-share-generics Reduces the release binary .text section by ~15% cumulatively through -Zshare-generics=y and -Zbuild-std, and enables native multi-arch docker buildx builds. ## Performance **Dockerfile: `-Zshare-generics`, `build-std`, and explicit target ([#845](https://github.com/oxidezap/whatsapp-rust/pull/845))** The Docker image build gains three related changes that reduce binary size and improve reproducibility. **`-Zshare-generics=y`** tells the nightly compiler to reuse upstream crate monomorphizations instead of re-codegening them per downstream crate. Cross-crate duplicate-symbol waste drops from 1414 KiB to 475 KiB; consumer-crate reinstantiation drops from 1484 KiB to 531 KiB. Measured impact on the release binary: `.text` shrinks by **666 KiB (−5.6%)**. **`-Zbuild-std`** (via `CARGO_UNSTABLE_BUILD_STD=std,panic_abort`) recompiles the standard library with the release profile so it participates in fat LTO and dead-code elimination instead of linking the prebuilt rustup `std`. This was measured separately in the same audit series as an additional −303 KiB. Combined with the library-level deduplication in #842–#844, the cumulative reduction since the audit began is **13.03 MiB → 11.00 MiB (−15.6%)** on `.text`. **Explicit target triple.** The Dockerfile now detects the host triple at build time via `rustc -vV` and passes it explicitly to both `cargo chef cook` and `cargo build`. This is required by `-Zbuild-std`, and as a side effect it makes `docker buildx build --platform linux/arm64` produce correct native binaries without Dockerfile changes. **Pinned `cargo-chef`.** `cargo-chef` is now installed at a fixed version (`0.1.77 --locked`) so image rebuilds are deterministic rather than tracking the latest crates.io release. Both `-Zshare-generics` and `-Zbuild-std` are nightly-only flags. They apply only inside the Docker image, which already pins the nightly toolchain via `rust-toolchain.toml`. Stable consumers and local `cargo build` invocations are unaffected. With `lto = "fat"` the historical downside of `share-generics` (lost cross-crate inlining of shared instantiations) does not apply — fat LTO sees all bitcode and re-inlines freely. Downstream images building on the same pinned nightly (e.g. Veloz) can apply the same `RUSTFLAGS` and `CARGO_UNSTABLE_BUILD_STD` variables for identical gains. # June 11, 2026 — History sync: secret-presence pre-scan, Arc chat ids, schema-pinned wire tags Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-11-history-sync-secret-prescan History sync skips prost decode for secret-free messages, shares conversation ids via Arc, and replaces magic wire-tag numbers with generated schema-pinned consts. HistoryMsgSecretRecord field types changed. ## Performance **History sync: gate prost decode behind secret-presence scan ([#836](https://github.com/oxidezap/whatsapp-rust/pull/836))** `extract_conversation_fields` previously decoded every `HistorySyncMsg` through prost — a 30-field struct with `String` allocations — to discover that most messages carry no `message_secret` and yield nothing useful. The CodSpeed profile showed 66% of `bench_process_history_sync` inside that decode path, with 56K allocations and 14.3 MB allocated per run on a 500-conversation × 40-message fixture. A shallow varint walk now runs before prost as a fast-path gate. It checks `WebMessageInfo.message_secret` and `Message.message_context_info.message_secret`; messages with no secret at any level are discarded immediately — no struct decode, no allocation. The scan mirrors prost merge semantics (repeated field occurrences count, malformed bytes degrade to the same skip a failed decode produces), and the flag logic for forwarded/poll/bot messages still runs inside prost on the subset that passes the scan. **Real-world impact is larger than the bench numbers suggest.** The fixture is secret-dense (2 of 3 messages carry a secret). Production `InitialBootstrap` blobs are secret-sparse, so the pre-scan skips proportionally more full decodes there. **Benchmark (500 convos × 40 messages, 20k messages, secret-dense fixture):** | Metric | Before | After | | -------------------------- | -------- | ---------------- | | Allocations / run | 56,020 | 39,520 (−29.5%) | | Allocated bytes / run | 14.97 MB | 14.32 MB (−4.3%) | | Instructions (core-pinned) | 2,358.7M | 2,301.7M (−2.4%) | **Shared conversation id ([#836](https://github.com/oxidezap/whatsapp-rust/pull/836))** `HistoryMsgSecretRecord.chat_id` is now `Arc`, allocated once per conversation and reference-counted into every record within that conversation. On the bench fixture: 10k clones → 500. `msg_id` switches to `CompactString` (inline up to 24 bytes for the typical 20–22 character WA message IDs), and `secret` switches to `SecretBytes` (inline up to 32 bytes, heap for larger). ## Type safety **Schema-pinned wire tags ([#836](https://github.com/oxidezap/whatsapp-rust/pull/836))** `waproto` now generates `tags.rs` at build time (alongside `whatsapp.rs`) from the compiled protobuf descriptor. The file exposes one `pub mod` per proto message with one `pub const FIELD_NAME: u32 = N;` per field. The history-sync wire walkers now reference these consts instead of magic numeric literals, and every `#[prost(tag)]` literal in the hand-written mirror structs is pinned by a compile-time `assert!` block. If `whatsapp.proto` renumbers a field the consts update automatically on next build; if a field referenced by the walkers is renamed or removed, compilation fails rather than the decoder silently reading the wrong wire data. ```rust theme={null} use waproto::tags; // Field number consts derived directly from whatsapp.proto let _ = tags::web_message_info::KEY; // 1 let _ = tags::web_message_info::MESSAGE; // 2 let _ = tags::message::MESSAGE_CONTEXT_INFO; // 35 let _ = tags::history_sync::CONVERSATIONS; // 2 let _ = tags::history_sync::PUSHNAMES; // 7 ``` ## Breaking changes **`HistoryMsgSecretRecord` field types (`wacore`)** Three fields changed type. Consumers of `HistoryMsgSecretRecord` need the following updates: ```rust theme={null} // chat_id: String → Arc // Borrow as &str (no move): let chat_str: &str = &*record.chat_id; // Clone the Arc (cheap refcount bump): let chat_arc: Arc = Arc::clone(&record.chat_id); // Convert to owned String: let chat_string: String = record.chat_id.to_string(); // msg_id: String → CompactString (compact_str::CompactString) // Borrow as &str: let id_str: &str = &*record.msg_id; // Convert to owned String (consumes field): let id_string: String = record.msg_id.into_string(); // secret: Vec → SecretBytes // Borrow as &[u8]: let key_slice: &[u8] = record.secret.as_slice(); // Convert to Vec (consumes field): let key_vec: Vec = record.secret.into_vec(); ``` `SecretBytes` stores secrets ≤32 bytes inline (no heap allocation) and falls back to `Vec` for larger values. It implements `Deref`, `From<&[u8]>`, `From>`, `PartialEq`, `Eq`, and `Debug`. The `INLINE_CAP: usize = 32` constant is public. **`waproto` build process** The generated `src/whatsapp.rs` is no longer committed to the repository. It now lives exclusively in `OUT_DIR`. Normal `cargo build` invocations are unaffected. The `generate` feature flag is removed. `prost-build`, `heck`, and `prost-types` are now unconditional `[build-dependencies]`. If you have `--features generate` in any build scripts, remove it. To regenerate after modifying `whatsapp.proto`, update the descriptor and rebuild: ```bash theme={null} scripts/regenerate-proto-desc.sh # writes waproto/src/whatsapp.desc + .sha256 cargo build -p waproto # regenerates whatsapp.rs and tags.rs in OUT_DIR ``` The build verifies the descriptor's SHA-256 against `whatsapp.desc.sha256` and aborts with a clear message on mismatch. # June 11, 2026 — PDO placeholder-resend: at-most-once per message Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-11-pdo-once-per-message Adds a pdo_requested memo cache that caps PDO placeholder-resend requests to one per message, skips a redundant migration retry decrypt when nothing moved, and adjusts noisy log levels. ## Bug Fix **PDO placeholder-resend: at-most-once per message ([#841](https://github.com/oxidezap/whatsapp-rust/pull/841))** A peer device with cloned Signal state could redeliver the same undecryptable message every \~11 seconds. Each copy triggered a PDO placeholder-resend request to our own phone — the `pdo_pending_requests` dedup only covered in-flight requests, so it emptied the moment the phone answered (\~800 ms), and the next copy immediately opened a new one. In a three-hour storm this produced \~700 redundant requests with no benefit: the phone had already answered without content, so re-asking could not produce anything new. **One request per message.** A new `pdo_requested` memo cache (24h TTL, 512 entries) gates `send_pdo_placeholder_resend_request` — mirroring `WAWebNonMessageDataRequestPlaceholderMessageResendUtils`, which uses a session-lifetime set for the same purpose. The memo slot is released if the send itself fails, so a transient error does not permanently block recovery. A content-less phone response leaves the memo in place (the phone has nothing to share for this message; re-asking on the next redelivery cannot help). **Skip migration retry decrypt when nothing moved.** `migrate_signal_sessions_on_lid_discovery` now returns `bool` indicating whether any sessions moved into a LID slot. When it returns `false`, the subsequent retry decrypt in `try_pn_to_lid_migration_decrypt` is skipped: the Signal state is unchanged, so the retry would fail identically and only add a second decrypt error to the log for every redelivered copy. **Log-level adjustments.** Three lines that fired once per redelivered copy are brought in line with WA Web's telemetry handling: * "Skipping skmsg decryption" → `debug` (WA Web's `canDecryptNext` skips silently after a retryable pkmsg failure) * "missing message content" on a PDO response → `info` (WA Web counts this outcome in telemetry only, no warning) * "Max retries reached" at the PDO fallback → `debug` (the high-retry `warn!` already fired on the way to the cap) With these changes, the same storm would produce 1 PDO request, 1 decrypt error per copy, and the existing retry receipt cap — instead of thousands of WARN/ERROR lines and hundreds of peer messages. ## Breaking changes **`CacheConfig` gains a new field (`whatsapp-rust`)** `CacheConfig` now has a `pdo_requested` field (default: 24h TTL, 512 entries). Struct literals that spell out every field rather than using `..Default::default()` will fail to compile. ```rust theme={null} // Update struct literals that do not use ..Default::default(): let config = CacheConfig { // your overrides ... ..Default::default() // pdo_requested gets its default; no action required }; ``` To tune the memo TTL or capacity: ```rust theme={null} use std::time::Duration; use whatsapp_rust::{CacheConfig, CacheEntryConfig}; let config = CacheConfig { // Extend the TTL beyond the 24h default if you want the memo to survive // across longer offline gaps without re-asking the phone. pdo_requested: CacheEntryConfig::new(Some(Duration::from_secs(48 * 3600)), 512), ..Default::default() }; ``` **`MemoryDiagnostics` gains a new field (`whatsapp-rust`)** `MemoryDiagnostics` now has a `pdo_requested: u64` field. Code that exhaustively pattern-matches or constructs `MemoryDiagnostics` directly will need updating. # June 12, 2026 — Binary size CI: per-PR budget gate and historical tracking Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-12-binary-size-ci Every PR now runs a release build and checks stripped size and .text growth against an absolute budget. A Chart.js series on gh-pages tracks every main merge over time. PR [#859](https://github.com/oxidezap/whatsapp-rust/pull/859) adds a `Binary Size` GitHub Actions workflow. It gates PRs on binary growth and keeps a historical size series. This closes the feedback loop that previously allowed dependency bloat and cross-crate monomorphization creep to go undetected between audits. ## What is tracked All metrics come from a single release build. This build uses `fat LTO`, `codegen-units=1`, and keeps symbols so `cargo bloat` can reuse it without recompiling. | Metric | Signal | | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | **Stripped file size** | Shows how much the shipping binary grew | | **`.text` section** | Measures code bloat from generic instantiation that persists even in stripped builds | | **Allocated size** (`text+data+bss`) | Catches static data tables that never appear in `.text` | | **`.text` per crate** (`cargo bloat --crates`) | Attributes `.text` growth to individual workspace crates and `std`; everything else appears as "other deps" | | **LLVM IR lines/copies** (`cargo llvm-lines`) | Detects monomorphization growth before linking, cheaply | | **Crate count** (`Cargo.lock`) | Signals when a new dependency was added | ## PR gate Every PR gets a sticky comment with all metric deltas and a per-crate top-movers table. The job **fails** when either of the two gated metrics exceeds its absolute per-PR budget: | Metric | Budget | | --------------- | -------- | | Δ stripped size | ≤ 64 KiB | | Δ `.text` | ≤ 32 KiB | Absolute budgets catch real regressions on large binaries that percentage thresholds would hide. Sizes are deterministic across the pinned toolchain, so deltas are noise-free. ## Escape hatch Add the **`size-increase-ok`** label to a PR and re-run the failed job to downgrade the gate to a warning. Use this for expected jumps such as toolchain bumps, accepted new dependencies, or deliberate feature additions. The size increase still lands in the historical series. The workflow queries the label live at gate time (not from the frozen event payload), so adding the label and re-running always works, even after the workflow has already triggered. ## Historical series The push job stores every main-branch measurement at `dev/binary-size` on gh-pages via [`github-action-benchmark`](https://github.com/benchmark-action/github-action-benchmark). A `102%` alert threshold posts a comment on the offending commit as a post-merge safety net for regressions that slipped through the PR gate (e.g. via the label). Charts: [https://oxidezap.github.io/whatsapp-rust/dev/binary-size/](https://oxidezap.github.io/whatsapp-rust/dev/binary-size/) ## Baseline semantics The PR baseline is the `size-metrics` artifact from the **latest successful main run**, not the `merge-base` commit. A long-lived stale PR can therefore show deltas inherited from main merges that happened while it sat open — rebase to clear them. ## Fork PRs Fork PRs run with a read-only token and receive the job summary and gate result but no PR comment. # June 14, 2026 — Drop moka: PortableCache is now the sole in-process cache backend Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-14-drop-moka-portable-cache Removes the moka dependency (-2.55 MiB stripped, -21% .text). PortableCache gains monotonic TTL/TTI, eager init-lock reclamation, and a reliable async clear(). TypedCache::from_moka is renamed to from_local. PR [#860](https://github.com/oxidezap/whatsapp-rust/pull/860) removes the `moka` dependency and makes `PortableCache` the only in-process cache backend on every target, including wasm32. ## Why moka was removed `moka` was the single largest contributor to the release binary — **1.8 MiB of `.text` (15.8%)** — almost entirely from per-cache-type monomorphization. Its `do_run_pending_tasks` alone was emitted **84 times (710 KiB)** across the \~15 distinct `Cache` types the client instantiates; each new typed cache dragged in moka's full generic machinery (\~100 KiB+). `PortableCache` was already shipping on wasm32 targets and mirrors the full moka `Cache` API (capacity + TTL/TTI eviction, single-flight `get_with`/`get_with_by_ref`). Making it the sole backend required no call-site changes. ## Binary size impact Real release profile (fat LTO, `codegen-units=1`, `panic=abort`, strip): | Metric | before (moka) | after (PortableCache) | Δ | | ------------------- | ------------: | --------------------: | ---------------------: | | Stripped size | 13.35 MiB | 10.81 MiB | **−2.55 MiB (−19.1%)** | | `.text` | 11.31 MiB | 8.89 MiB | **−2.42 MiB (−21.4%)** | | LLVM IR lines | 1,275,789 | 664,220 | **−47.9%** | | `Cargo.lock` crates | 357 | 354 | −3 | The net delta exceeds moka's own 1.8 MiB line because dropping moka also removes its transitive deps (crossbeam-channel/epoch, quanta, part of uuid) and unlocks further LTO savings. CodSpeed reports no performance change across 172 benchmarks. ## PortableCache hardening Making PortableCache the sole native backend surfaced a few behavioural gaps that were addressed in this PR: * **Monotonic TTL/TTI** — expiry now uses `wacore::time::Instant` instead of the wall clock, so a system-clock jump can't expire entries early. This matches moka's timer semantics and prevents `session_recreate_history`'s throttle backstop from being bypassed. * **Eager single-flight init-lock reclamation** — `get_with`/`get_with_by_ref` now drop a key's init lock once no other caller holds it, instead of waiting for `run_pending_tasks`. Fixes unbounded `init_locks` growth in high-cardinality caches (session locks, chat lanes, message-id dedup) that never call `run_pending_tasks`. * **Reliable async `clear()`** — new `PortableCache::clear()` awaits the write lock. `cleanup_connection_state` and `TypedCache::clear` now use it instead of the best-effort sync `invalidate_all()`, which could skip the clear under contention and leave a stale `ChatLane` after reconnect. * **`snapshot_entries()`** — new async method that awaits the read lock for a reliable snapshot; used by `SenderKeyDeviceCache::invalidate_entries_for_device` where a missed entry would silently drop an SKDM fanout. ## Breaking changes ### `moka-cache` feature removed The `moka-cache` Cargo feature no longer exists. Remove it from your `Cargo.toml`: ```toml theme={null} # Before whatsapp-rust = { version = "0.6", default-features = false, features = [ "sqlite-storage", "tokio-transport", "tokio-runtime", "ureq-client", "tokio-native", "signal", "moka-cache", # ← remove this line ] } # After whatsapp-rust = { version = "0.6", default-features = false, features = [ "sqlite-storage", "tokio-transport", "tokio-runtime", "ureq-client", "tokio-native", "signal", ] } ``` ### `TypedCache::from_moka` renamed to `from_local` If you construct a `TypedCache` directly in your own code, update the constructor name: ```rust theme={null} // Before let cache = TypedCache::from_moka(my_cache); // After let cache = TypedCache::from_local(my_cache); ``` ### `portable_cache` module is now always public `whatsapp_rust::portable_cache` is no longer `cfg`-gated. Any conditional compilation on `#[cfg(any(not(feature = "moka-cache"), target_arch = "wasm32"))]` around imports of that module should be removed. ## Trade-offs `PortableCache` differs from moka in two ways relevant to very high-throughput deployments: * **Eviction policy**: FIFO instead of TinyLFU (lower hit rate under heavily skewed access patterns) * **Concurrency**: one `RwLock` per cache vs moka's sharded, lock-free reads (more contention under heavy concurrent access) For typical bot/single-account workloads this is unlikely to matter; the integration benchmarks (CodSpeed) confirmed no performance change across all 172 benchmarks. # June 15, 2026 — Binary encode: length-bucketed token lookup Source: https://whatsapp-rust.jlucaso.com/changelog/2026-06-15-token-tiny-map index_of_token switches from a PTHash perfect-hash map to hashify::tiny_map!, cutting marshal_auto_small latency by 34% on the miss-heavy uncached encode path. ## Performance **Binary encode: `index_of_token` uses a length-bucketed `tiny_map` instead of PTHash ([#873](https://github.com/oxidezap/whatsapp-rust/pull/873))** `index_of_token` runs on the encode hot path: `classify_string_hint` probes every string in every outgoing stanza. The production send path (`marshal_auto` → `Encoder::new_vec`) has no `StringHintCache`, so the lookup runs uncached per string. **Previously**, the lookup used `hashify::map!` — a compile-time PTHash map with FNV-1a key hashing. On the miss-heavy encode path (JIDs, message IDs, timestamps, and content strings all fall within the token length range but are not tokens), this folded every byte of the key before probing, regardless of outcome. **Now**, the codegen switches to `hashify::tiny_map!`, which dispatches on `key.len()` first and compares only discriminator bytes within each length bucket — no full-key hash on any code path. This is the same technique used in [Bun PR #31875](https://github.com/oven-sh/bun/pull/31875). Multi-byte leaves still get a full key comparison at build time, so the map cannot silently alias a non-token onto a token — build-time resolution fails loud if any bucket is unresolvable. **Benchmark results (divan A/B, median, main vs. branch):** | Benchmark | Before | After | Change | | ---------------------------------------------------------- | -------- | -------- | -------- | | `marshal_auto_small` (dominant shape: message/receipt/ack) | 168.7 ns | 111.8 ns | **−34%** | | `marshal_plain_large` | 1.241 µs | 911 ns | −27% | | `marshal_auto_large` | 1.116 µs | 905 ns | −19% | | `marshal_to_reused_buffer_large` | 1.104 µs | 916 ns | −17% | | `marshal_auto_many_children` | 184.5 µs | 165.4 µs | −10% | These benchmarks are tracked by CodSpeed CI, so the gain is regression-guarded going forward. **Correctness.** This is a pure codegen change — same `tokens.json` source, same byte-array keys, no new dependency. A new exhaustive test (`lookup_matches_reference_under_byte_mutation`) builds a reference `HashMap` from the full token tables and verifies that the `tiny_map` lookup agrees for every one-byte input and every single-byte mutation (all 256 values) of every token string. **No breaking changes.** `index_of_token`'s signature and return type are unchanged. # August 7, 2026 — whatsapp-rust 0.7.0 published Source: https://whatsapp-rust.jlucaso.com/changelog/2026-08-07-release-0-7-0 0.7.0 is on crates.io. VoIP, native plugins, tracing, and metrics no longer need a git dependency; the moka-cache and debug-diagnostics features are gone. ## Release **`whatsapp-rust` 0.7.0 and every publishable workspace crate are on crates.io ([#1219](https://github.com/oxidezap/whatsapp-rust/pull/1219))** `whatsapp-rust`, `wacore`, `wacore-appstate`, `wacore-binary`, `wacore-derive`, `wacore-libsignal`, `wacore-noise`, `waproto`, `whatsapp-rust-sqlite-storage`, `whatsapp-rust-tokio-transport`, and `whatsapp-rust-ureq-http-client` all went out at `0.7.0`. `whatsapp-rust-chat-store` and `whatsapp-rust-plugin-metrics` are held back — see below. The [v0.7.0 release notes](https://github.com/oxidezap/whatsapp-rust/releases/tag/v0.7.0) cover the 488 PRs behind it; this entry is the upgrade-facing summary. ```toml theme={null} [dependencies] whatsapp-rust = "0.7" tokio = { version = "1.53", features = ["macros", "rt-multi-thread"] } ``` ## Features that no longer need a git dependency These landed on `main` after 0.6.0 and are now in the published crate — drop any `git = "…"` override you were carrying for them: | Feature | What it enables | | ------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | `voip`, `voip-runtime`, `voip-encoded`, `voip-mlow`, `voip-libopus` | 1:1 voice and video calls — see [VoIP Calls](/guides/voip-calls) | | `plugins`, `client-lifecycle` | Build-time native plugin host — see [Native plugins](/advanced/plugins) | | `tracing`, `tracing-pii` | Spans and events across the client — see [Observability](/advanced/observability) | | `metrics` | `wa_*` counters, histograms, and gauges — see [Metrics](/advanced/metrics) | | `legacy-session-interop` | Typed interop with the decoded legacy `SessionRecord` v1 model | | `danger-skip-cert-chain-verify` | Skips the Noise handshake certificate-chain check (unsafe) | ## Breaking changes when upgrading from 0.6.0 **The `moka-cache` feature is gone.** It was a default feature in 0.6.0. If your `Cargo.toml` names it explicitly, remove the line — see [moka dropped for a portable cache](/changelog/2026-06-14-drop-moka-portable-cache). **The `debug-diagnostics` feature is gone**, along with the `memory_diagnostics()` / `MemoryDiagnostics` surface it gated. The replacement is split in two and needs no feature flag: [`Client::memory_report()`](/api/client#memory_report) returns the cache entry counts and retained-byte estimates that `MemoryDiagnostics` carried, and [`Client::stats()`](/api/client#stats) returns the wire I/O and activity counters. **Minimum versions moved.** The workspace declares an MSRV for the first time, at **1.94** (0.6.0 declared none), and the crate requires **tokio 1.53.1** or newer. Edition 2024 needs no nightly; only the default `simd` feature does, because it uses the unstable `portable_simd` API. **`whatsapp-rust-chat-store` is not published.** Its schema and query surface are still moving, so it is held back from crates.io (`publish = false`) and stays reachable only through the git source — as does `whatsapp-rust-plugin-metrics`. When you use the chat store, put `whatsapp-rust` and `whatsapp-rust-sqlite-storage` on that same revision: a registry copy and a git copy of `whatsapp-rust-sqlite-storage` are two distinct crates to Cargo, and `ChatStore::new` only accepts the `SqliteStore` from the copy it was compiled against. See [Chat Store](/api/chat-store). ## Release pipeline The release workflow gained a **preflight** job that extracts and validates the version before anything publishes. It rejects build metadata (`0.7.0+build.5`) even though Cargo accepts it: `+` is not a legal Docker tag character, so the image would ship as `0.7.0-build.5` — the tag a real prerelease of that name would claim. Running the check ahead of publish costs a failed job; running it after would strand a release between crates.io and the tag. Hand-written release notes in `.github/release-notes/v.md` now win over `--generate-notes`, which for a release this size emits thousands of unstructured bullets. # September 9, 2026 — rustfmt fixes now auto-commit to PRs Source: https://whatsapp-rust.jlucaso.com/changelog/2026-09-09-autofix-rustfmt autofix.ci runs cargo fmt on every pull request and commits any formatting fixes back to your branch, so rustfmt failures no longer bounce PRs. PR [#1495](https://github.com/oxidezap/whatsapp-rust/pull/1495) adds an `autofix.ci` GitHub Actions workflow. It runs `cargo fmt --all` on every pull request and hands the resulting diff to [autofix.ci](https://autofix.ci), which commits any formatting fixes directly onto the PR branch. Formatting failures no longer bounce a PR back to the author for a manual `cargo fmt` round trip. ## What changes for contributors * You no longer need to run `cargo fmt` before pushing. If your branch has formatting drift, autofix.ci pushes a fix-up commit to it within a few minutes of the workflow run. * After autofix.ci commits to your branch, pull before pushing again to avoid a non-fast-forward rejection. * The workflow uses the same pinned nightly toolchain as the `Format Check` gate in CI, so the fixer and the gate never disagree on formatting. ## What stays manual Only rustfmt is automated, because it is deterministic and never changes semantics. Everything else remains a human-reviewed gate: * **Clippy.** `clippy --fix` can rewrite logic, so clippy findings still fail CI and require a manual fix. * **Generated artifacts.** Whatspec regeneration checks stay human-reviewed and are never auto-committed. * **Bartender image pins.** Pin updates stay human-reviewed. The workflow runs with read-only repository permissions. The commit-back step goes through the autofix.ci GitHub App rather than a repository token. # Changelog Source: https://whatsapp-rust.jlucaso.com/changelog/overview Product updates and improvements to whatsapp-rust Follow along with what's new in `whatsapp-rust`. For installation and upgrade instructions, see [Installation](/installation). # Architecture Source: https://whatsapp-rust.jlucaso.com/concepts/architecture Understanding the whatsapp-rust project structure, modules, and workspace layout ## Overview WhatsApp-Rust is a high-performance, async Rust library for the WhatsApp Web API. The project follows a modular, layered architecture that separates protocol concerns from runtime concerns, enabling platform-agnostic core logic with pluggable backends. ## Workspace Structure The project is organized as a Cargo workspace with multiple crates: ``` whatsapp-rust/ ├── src/ # Main client library ├── wacore/ # Platform-agnostic core │ ├── binary/ # WhatsApp binary protocol │ ├── libsignal/ # Signal Protocol implementation │ ├── appstate/ # App state management │ ├── noise/ # Noise Protocol handshake │ └── derive/ # Derive macros ├── waproto/ # Protocol Buffers definitions ├── storages/sqlite-storage/ # SQLite backend ├── transports/tokio-transport/ # Tokio WebSocket transport ├── http_clients/ureq-client/ # HTTP client for media ├── tests/e2e/ # End-to-end test suite └── examples/ # Example applications (benchmarks) ``` ## Three main crates ### wacore - platform-agnostic core **Location:** `wacore/` **Purpose:** Contains core logic for the WhatsApp binary protocol, cryptography primitives, IQ protocol types, runtime abstraction, and state management traits. **Key Features:** * **Zero runtime dependencies** — no Tokio, no async-std, only `futures`, `async-trait`, `async-lock`, and `async-channel` * **32-bit target support** — uses `portable-atomic` for 64-bit atomics with a software fallback on platforms without native `AtomicU64` (ARM32, MIPS, etc.) * `Runtime` trait for pluggable async executors (Tokio, async-std, WASM, etc.) * `Transport`, `TransportFactory`, and `HttpClient` traits for pluggable networking * `Backend` trait for pluggable storage * Cryptographic operations (Signal Protocol, Noise Protocol) * Type-safe protocol node builders **Key Modules:** ```rust theme={null} wacore/ ├── binary/ // Binary protocol encoding/decoding ├── libsignal/ // E2E encryption ├── noise/ // Noise Protocol handshake ├── appstate/ // App state sync protocol ├── derive/ // Derive macros (EmptyNode, ProtocolNode, StringEnum) ├── iq/ // Type-safe IQ protocol types ├── net.rs // Transport, HttpClient trait definitions ├── runtime.rs // Runtime trait + AbortHandle ├── protocol/ // ProtocolNode trait, keepalive, retry ├── time.rs // Pluggable time provider + portable Instant ├── types/ │ ├── events.rs // Event definitions │ └── message.rs // Message types └── store/ ├── traits.rs // Storage trait definitions (Backend, SignalStore, etc.) └── device.rs // Device state model ``` ### waproto - protocol buffers **Location:** `waproto/` **Purpose:** Houses WhatsApp's Protocol Buffers definitions compiled to Rust structs. **Build Process:** `build.rs` always runs, reads the committed binary descriptor (`src/whatsapp.desc`), verifies its SHA-256, and writes `whatsapp.rs` and `tags.rs` into `OUT_DIR`. Neither generated file is committed. To regenerate after modifying `whatsapp.proto`: ```bash theme={null} scripts/regenerate-proto-desc.sh # updates whatsapp.desc + .sha256 cargo build -p waproto ``` **Generated Types:** * `Message` - All message types * `WebMessageInfo` - Message metadata * `HistorySync` - Chat history * `SyncActionValue` - App state mutations ### Whatsapp-rust - main client **Location:** `src/` **Purpose:** Integrates `wacore` with concrete implementations (Tokio runtime, SQLite storage, ureq HTTP, Tokio WebSocket), provides the high-level `Bot` builder and `Client` API. **Key Features:** * `TokioRuntime` — default `Runtime` implementation (gated on `tokio-runtime` feature) * Typestate `BotBuilder` — compile-time enforcement that all 4 required components are provided * SQLite persistence (pluggable via `Backend` trait) * Event bus system * Feature modules (groups, media, newsletters, communities, etc.) ## Runtime abstraction The library is fully runtime-agnostic. All async operations go through four pluggable trait abstractions defined in `wacore`: | Concern | Trait | Default implementation | Crate | | ----------------- | -------------------------------- | -------------------------------- | ------------------------------------------ | | Async runtime | `Runtime` | `TokioRuntime` | `whatsapp-rust` (gated on `tokio-runtime`) | | Network transport | `TransportFactory` + `Transport` | `TokioWebSocketTransportFactory` | `whatsapp-rust-tokio-transport` | | HTTP client | `HttpClient` | `UreqHttpClient` | `whatsapp-rust-ureq-http-client` | | Storage | `Backend` | `SqliteStore` | `whatsapp-rust-sqlite-storage` | The `Runtime` trait requires four methods plus two optional methods with defaults: ```rust theme={null} pub trait Runtime: Send + Sync + 'static { fn spawn(&self, future: Pin + Send + 'static>>) -> AbortHandle; /// Fire-and-forget spawn. Defaults to `self.spawn(future).detach()`. fn spawn_detached(&self, future: Pin + Send + 'static>>) { self.spawn(future).detach(); } fn sleep(&self, duration: Duration) -> Pin + Send>>; fn spawn_blocking(&self, f: Box) -> Pin + Send>>; fn yield_now(&self) -> Option + Send>>>; /// How often to yield in tight loops (every N items). Defaults to 10. fn yield_frequency(&self) -> u32 { 10 } } ``` `AbortHandle` is `#[must_use]` — dropping the handle aborts the spawned task. Call `.detach()` on the handle for fire-and-forget tasks that should run to completion independently. See [custom backends — AbortHandle](/guides/custom-backends#aborthandle) for implementation details. As of [#1124](https://github.com/oxidezap/whatsapp-rust/pull/1124), `spawn_detached` exists so fire-and-forget callers no longer have to write `spawn(future).detach()` themselves. The trait-level default above is exactly that call: `self.spawn(future).detach()`. It still builds the `AbortHandle` and immediately drops it, so the default alone saves nothing. That `AbortHandle` boxes a `dyn FnOnce` capturing the executor's own handle — the cost this method exists to avoid. The saving comes from `TokioRuntime`'s own override, which calls `tokio::spawn(future)` directly and never constructs an `AbortHandle` at all. #1124 migrated two of the library's own fire-and-forget spawns to `spawn_detached`: the event-callback dispatcher in `bot.rs`, and the read loop's spawned-processing branch (see [WebSocket handling](/advanced/websocket-handling)). Those two pick up the saving automatically under the bundled `TokioRuntime`. Override `spawn_detached` yourself only if your executor can spawn a task with no cancellation bookkeeping at all. The `yield_frequency()` method controls how often the client cooperatively yields during tight async loops (such as processing incoming frames). It returns the number of items to process before yielding. The default value is `10`. Single-threaded runtimes should return `1` to avoid starving the event loop, while multi-threaded runtimes can use higher values or rely on `yield_now()` returning `None`. On WASM targets, `Send` bounds are automatically removed via `#[cfg(target_arch = "wasm32")]`. The `BotBuilder` uses a typestate pattern with four type parameters `` (Backend, Transport, HttpClient, Runtime). The `build()` method is only callable when all four are `Provided`, making missing-component errors compile-time instead of runtime. See [custom backends](/guides/custom-backends) for implementing your own runtime, transport, HTTP client, or storage backend. ## Key Components ### Client **Location:** `src/client.rs` **Purpose:** Orchestrates connection lifecycle, event bus, and high-level operations. The synchronization primitive follows the shape of the state, not a single default. State whose critical section awaits uses `async-lock` (runtime-agnostic, not Tokio-specific). State whose critical section never awaits — a clone, a store, a set op — uses a `std::sync` lock instead. State that is built once and never replaced uses `std::sync::OnceLock`. On a path that must produce a `Send` future (e.g. a spawned task), a `std::sync::MutexGuard` isn't `Send`, so holding one across an `.await` there is a compile error rather than something a reviewer has to catch by hand ([#1227](https://github.com/oxidezap/whatsapp-rust/pull/1227)). ```rust theme={null} pub struct Client { pub(crate) core: wacore::client::CoreClient, pub(crate) persistence_manager: Arc, pub(crate) media_conn: Arc>>, pub(crate) noise_socket: Arc>>>, // ... connection state, caches, locks } ``` **Responsibilities:** * Connection management * Request/response routing * Event dispatching * Session management ### PersistenceManager **Location:** `src/store/persistence_manager.rs` **Purpose:** Manages all state changes and persistence. ```rust theme={null} pub struct PersistenceManager { device: Arc>, backend: Arc, dirty: Arc, save_notify: Arc, } ``` **Critical Pattern:** * Never modify `Device` state directly * Use `DeviceCommand` + `process_command()` * For read-only: `get_device_snapshot()` ### Signal Protocol **Location:** `wacore/libsignal/` & `src/store/signal*.rs` **Purpose:** End-to-end encryption via Signal Protocol implementation. **Features:** * Double Ratchet algorithm * Pre-key bundles * Session management * Sender keys for groups ### Socket & Handshake **Location:** `src/socket/`, `src/handshake.rs` **Purpose:** WebSocket connection and Noise Protocol handshake. **Flow:** 1. WebSocket connection 2. Noise handshake (XX pattern) 3. Encrypted frame exchange ## Module Interactions ```mermaid theme={null} graph TB A[Client API] --> B[Client] B --> C[NoiseSocket] B --> D[PersistenceManager] B --> E[Event Bus] C --> F[Transport] D --> G[Backend Storage] D --> H[Device State] B --> I[Signal Protocol] I --> G B --> J[wacore] J --> K[Protocol Types] J --> L[Crypto] ``` ## Layer Responsibilities ### wacore layer (platform-agnostic) * Protocol logic * State traits * Cryptographic helpers * Data models **Example: IQ Protocol** ```rust theme={null} // wacore/src/iq/groups.rs pub struct GroupQueryIq { group_jid: Jid, } impl IqSpec for GroupQueryIq { type Response = GroupInfoResponse; fn build_iq(&self) -> InfoQuery<'static> { /* ... */ } fn parse_response(&self, response: &NodeRef<'_>) -> Result { /* ... */ } } ``` ### Whatsapp-rust layer (runtime) * Runtime orchestration * Storage integration * User-facing API **Example: Feature API** ```rust theme={null} // src/features/groups.rs impl<'a> Groups<'a> { pub async fn get_metadata(&self, jid: &Jid) -> Result { // Use wacore IqSpec for protocol self.client.execute(GroupQueryIq::new(jid)?).await } } ``` ## Protocol entry points ### Incoming Messages **Flow:** `src/message.rs` → Signal decryption → Event dispatch Incoming stanzas are decoded as `Arc` (zero-copy from the network buffer) and routed through per-chat message queues: ```rust theme={null} // src/message.rs pub async fn handle_message(client: &Arc, node: &Arc) { // 1. Extract encrypted message from NodeRef // 2. Decrypt via Signal Protocol // 3. Commit (or accumulate into a batch during the offline drain) // 4. Dispatch Event::Messages } ``` ### Outgoing Messages **Flow:** `src/send.rs` → Signal encryption → Socket send Outgoing stanzas are built as owned `Node` values via `NodeBuilder`: ```rust theme={null} // src/send.rs pub async fn send_message(client: &Arc, msg: &Message) { // 1. Encrypt via Signal Protocol // 2. Build protocol node (owned Node) // 3. Send via NoiseSocket } ``` ### Socket Communication **Flow:** `src/socket/` → Noise framing → Transport ```rust theme={null} // src/socket/mod.rs impl NoiseSocket { pub async fn send_node(&self, node: Node) -> Result<()> { // 1. Marshal to binary // 2. Encrypt with Noise // 3. Frame and send } } ``` ## Connection Lifecycle ### Auto-Reconnection The client implements robust reconnection handling with stream error awareness: ```rust theme={null} // Client fields for connection and reconnection management is_connected: Arc, // Lock-free connection state (Acquire/Release) pub enable_auto_reconnect: Arc, // Toggle auto-reconnect pub auto_reconnect_errors: Arc, // Error count for backoff pub(crate) expected_disconnect: Arc, // Expected vs unexpected pub(crate) connection_generation: Arc, // Detect stale tasks ``` The `is_connected` field uses an `AtomicBool` to track whether the noise socket is established. This avoids a TOCTOU race that previously occurred when `try_lock()` on the noise socket mutex failed under contention, causing false-negative connection checks and silent ack drops. **Connection timeout:** Both the transport connection and version fetch are wrapped in a 20-second timeout (`TRANSPORT_CONNECT_TIMEOUT`), matching WhatsApp Web's MQTT and DGW defaults. This prevents dead networks from blocking on the OS TCP SYN timeout (\~60-75s). Both operations run in parallel via `tokio::join!`. **Reconnection flow:** 1. Connection lost → `cleanup_connection_state()` (see [disconnect cleanup](#disconnect-cleanup)) 2. Check `enable_auto_reconnect` → exit if disabled (401, 409, 516 disable this) 3. Check `expected_disconnect` → immediate reconnect if expected (e.g., 515) 4. Stability-gated backoff reset: the counter resets to base only if the connection was authenticated (``) for at least 30s (`STABLE_CONNECTION_RESET`, WA Web's `resetDelay`) **and** no explicit penalty (429, manual `reconnect()`) is pending for this cycle — a penalty survives even a stable connection (WA Web `cancelReset`). The stability window is measured against a monotonic clock (`connected_at`, a `wacore::time::Instant`), so a system clock jump can no longer make a young connection look stable or a genuinely stable one look young ([whatsapp-rust#1379](https://github.com/oxidezap/whatsapp-rust/pull/1379)). 5. Calculate Fibonacci backoff delay (1s, 1s, 2s, 3s, 5s, 8s... max 900s with +/-10% jitter) 6. When you call `disconnect()`, `logout()`, or `signal_shutdown_sync()`, the client interrupts the backoff immediately and returns from `run()`. If the delay completes first, it attempts reconnection with a 20s connect timeout. See [WebSocket & Noise Protocol - Fibonacci backoff](/advanced/websocket-handling#fibonacci-backoff) for the stability-reset and penalty-survival mechanics, and for the counter's saturation at 64 consecutive failures ([whatsapp-rust#1410](https://github.com/oxidezap/whatsapp-rust/pull/1410)). ### Pause / resume Added in PR #1265. `Client::pause()`/`resume()` fill the gap between `disconnect()` (terminal — no way back for that `Client`) and `reconnect()` (comes back on the library's own schedule). `pause()` drops the connection and parks the `run()` supervision loop until `resume()` releases it, on the caller's own timeline. The client is not terminal while paused; it is between connections, on purpose — see point 6 below for the one case where a pause still ends the loop. ```rust theme={null} // Client fields backing pause/resume pub(crate) paused: AtomicBool, // Set by pause(), cleared by resume() pub(crate) pause_state_notifier: Arc, // Fired only by pause()/resume() pub(crate) pause_teardown_pending: AtomicBool, // One-shot: the ending connection owes no backoff pub(crate) pause_generation: AtomicU64, // Bumped by every pause(); attempts capture it once pub(crate) connection_publish: Mutex<()>, // Serializes pause()'s teardown against connect's publish ``` **Mechanics:** 1. `run()`'s loop checks `paused` at the top of every iteration, before each connect attempt, not only after one ends. A `pause()` can land as easily during a reconnect backoff as during a live connection. Finding `paused` set, the loop logs and parks on `wait_while_paused()`. That helper re-checks `paused`/`is_running` against `session_state_notifier`, so a `disconnect()` mid-pause still ends the loop instead of waiting for a `resume()` that is never coming. 2. `connect()` itself refuses with `ConnectError::Paused` up front. The connect graph also re-checks a `connect_refusal()` (shutdown-or-paused) at every checkpoint — before the transport opens, after the handshake, and at the final publish — comparing the connection attempt's captured `pause_generation` against the current one. That generation compare, not just the `paused` flag, is what catches an attempt that spanned a `pause()` **and** a `resume()` while it was mid-handshake: a level-triggered read of `paused` alone would see `false` and let it through. 3. The final refusal-check-and-publish in the connect graph and `pause()`'s own capture of what it is tearing down share the `connection_publish` mutex. A `pause()` can therefore never read "no connection" from an attempt one statement away from publishing one. 4. `pause_teardown_pending` is a one-shot fact, not a live re-read of `paused`. It's set by whichever path ends the connection — the run loop's `ConnectError::Paused` branch, or `pause()` itself — so a `resume()` landing in the gap between the teardown starting and the run loop noticing does not leave the loop believing the ending connection deserves the ordinary Fibonacci penalty. 5. `resume()` is synchronous and deliberately does not wait on a `pause()` still in flight. That teardown ends in an untimed socket close, and queuing behind it would make "come back on my word" hostage to an unresponsive transport. `resume()` just clears `paused`, fires `pause_state_notifier`, and lets the run loop reconnect immediately. 6. The `enable_auto_reconnect` check in `run()`'s post-connection logic runs *before* the `pause_teardown_pending`/`paused` check that would otherwise park the loop. So if `enable_auto_reconnect` was already `false` when `pause()` ended a connection (or interrupted an in-flight attempt), the loop takes the auto-reconnect-disabled branch instead: it calls `stop_supervision_loop()` and exits for good, the same as an ordinary auto-reconnect-disabled disconnect. `is_terminal()` reads that combination (`!enable_auto_reconnect && !is_running && !is_connected`) as terminal, so the "not terminal" guarantee in the overview above holds only while `enable_auto_reconnect` stays set. **What paused reachability means:** `pause()` publishes the `paused` flag *before* it flushes and closes the socket. `can_reach_server()`, `wait_for_socket()`, and `wait_for_connected()`/`is_fully_ready()` all treat a paused client as unreachable for that whole teardown window, not just once the socket is actually down — otherwise a caller could be handed the very connection the application just asked to close. `await_connection` is the one place that deliberately keeps waiting through a pause rather than giving up. It already waits out a 900s backoff the same way. Nothing on the next connection re-issues a consumer's task, so giving up early would drop the work instead of merely deferring it. `pause()` does **not** dispatch `Event::Disconnected` (same reasoning as `reconnect()` — the application asked for this, so it isn't news), and it is not a protocol-level presence change. Its interaction with `enable_auto_reconnect` (mechanics point 6 above) is the same ordering that lets `handle_stream_error` make a client terminal (409, 516) through that flag without a pause racing ahead of it. See [pause() / resume() in the Client API reference](/api/client#pause) for the caller-facing contract. ### Disconnect cleanup When a connection is lost or `disconnect()` is called, `cleanup_connection_state()` resets all connection-scoped state to prevent stale data from leaking into the next connection. It runs from `run()` after the message loop exits (and `disconnect()` also invokes it directly); the function is idempotent and race-tolerant, so it is not duplicated inside the message loop on transport disconnect events and resetting twice is harmless: | Resource | Action | Reason | | --------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Transport, events, noise socket | Set to `None` | Release connection resources | | `is_connected` | Set to `false` (Release ordering) | After socket is `None` so no task sees connected with a cleared socket | | `chat_lanes` | Invalidated | Drop per-chat queue senders so workers exit via channel close — prevents stale workers from the old connection surviving reconnects with outdated signal/crypto state | | `pending_retries` | Cleared | Stale keys from detached scope guard cleanup would otherwise suppress the first retry after reconnect | | `pending_lid_refreshes` | **Deliberately not cleared** | A `scopeguard` releases each key on drop as well as on completion. A refresh whose query dies with the socket already frees its own key, so there is nothing stale left to sweep. Clearing the set here would instead drop a reservation belonging to a live task: a refresh spanning a reconnect would release a key the new connection had since taken. The next `` for that peer would then fire a duplicate outbound `LidQuerySpec` re-resolve — the duplicate the set exists to prevent ([#1313](https://github.com/oxidezap/whatsapp-rust/pull/1313)) | | `signal_cache` | **Flushed, then settled entries cleared** | Pending identity / session / sender-key writes are persisted to the backend before eviction. If the flush fails, the cache is kept (not cleared) so dirty state survives. Even after a successful flush, an entry a concurrent write touched in the gap stays resident — only fully-settled entries are evicted. | | `response_waiters` | Drained | Pending IQ waiters fail fast with `InternalChannelClosed` instead of hanging until the 75s timeout | | Offline sync state | Reset | Counters, timing, and semaphore replaced with fresh single-permit instance | | Dead-socket timestamps | Cleared | Prevents stale values from triggering an immediate reconnect on the next connection | | `app_state_key_requests`, `app_state_syncing` | Replaced with empty maps | Prevents unbounded growth across reconnections | | Noise sender task | Stopped via `NoiseSocket::abort_sender()` | Cuts a send parked on a dead transport short instead of leaving it to the last `Arc` clone, so the bounded close below can't queue behind it | | Transport | Closed via `Client::close_transport_bounded` (bounded by `TRANSPORT_CLOSE_TIMEOUT`, 2s) | An untimed close shares the transport's sink with every send and can be retried by the kernel for as long as `tcp_retries2` allows (\~15 min on Linux); this runs on the caller's thread of control (the run loop, or `disconnect()`'s caller), so an unbounded close parks the whole client | **Bounded teardown ([whatsapp-rust#1410](https://github.com/oxidezap/whatsapp-rust/pull/1410)).** Every teardown path's transport close — `disconnect()`, `reconnect()`, `reconnect_immediately()`, `pause()`, and this cleanup routine — is now bounded by `TRANSPORT_CLOSE_TIMEOUT`. `abort_sender()` runs first, closing the sender task's job channel and aborting it outright; any send it cuts off (queued or already in flight) fails with `EncryptSendError::channel_closed()` rather than hanging — a send racing a teardown was going to fail once the socket closed anyway. See [WebSocket & Noise Protocol — Disconnect](/advanced/websocket-handling#4-disconnect) for the full mechanics. Chat lane invalidation is critical for correctness. Without it, stale message processing workers from the previous connection survive reconnects, holding outdated Signal session state that causes decryption failures on the new connection. **Flush-before-clear (v0.6).** The signal cache is now flushed to the backend before being cleared on disconnect. Previously the cache was dropped immediately, so a just-advanced sender-key chain that hadn't been persisted yet was lost — on the next send the client would treat the chain as fresh and re-distribute the SKDM to every group device unnecessarily. Disconnect is therefore no longer a "forget all Signal state" operation; it's a "persist, then forget" one. This is a behavior change for anyone relying on the old drop-everything semantics. **Settled-only eviction.** Teardown now calls `SignalStoreCache::clear_after_flush()` instead of an unconditional clear. A write that lands between the flush releasing its lock and teardown running — e.g. a concurrently raised sender-key reservation — used to be silently discarded, which could let ciphertext reach the wire before its new durability ceiling was ever persisted. Teardown now only evicts a store (sessions, identities, sender keys) once it has no dirty, deleted, checked-out, or pending-wire-gate entries; anything installed after the flush stays resident for the next successful flush to settle. See [Signal Protocol — clean reload vs. crash recovery](/advanced/signal-protocol#clean-reload-vs-crash-recovery) for how this interacts with the counter/iteration lease. **Stream error behavior:** * **401 (unauthorized)**: Disables auto-reconnect, emits `LoggedOut` * **409 (conflict)**: Disables auto-reconnect, emits `StreamReplaced` * **429 (rate limited)**: Adds 5 extra Fibonacci steps to backoff, then reconnects; also suppresses the next stability-gated backoff reset (see above) * **515 (expected)**: Immediate reconnect without backoff * **516 (device removed)**: Disables auto-reconnect, emits `LoggedOut` * **``** (no numeric code): Force-closes the socket and reconnects with standard backoff — WA Web treats a malformed frame as unrecoverable for the current stream ### Message loop (read loop) The `read_messages_loop` runs on the `run()` caller's task and uses `select_biased!` to multiplex shutdown signals with transport events. Frame decryption is sequential (noise counter ordering), but node processing uses a hybrid inline/concurrent strategy: * **Inline**: `success`, `failure`, `stream:error` (connection state), `message` and status-broadcast `status` (arrival order for per-chat queues), `ib` (offline sync tracking) * **Spawned concurrently**: all other stanzas (receipts, notifications, presence, etc.) A top-level `` stanza carries the same E2EE payload as `` — the server can deliver either shape — so it's retagged and routed through the identical inline enqueue path, and acked with `class="status"` plus the local device's own JID. A `` from a `@newsletter` sender is a different shape and keeps the router path instead. Any top-level stanza the router doesn't recognize is nacked (`NackReason::UnrecognizedStanza`) when it carries both `id` and `from`, rather than left unanswered — see the **Nack on unrecognized stanzas** note in [Offline sync](#offline-sync) below. After processing a batch of multiple frames, the loop refreshes `last_data_received` so the keepalive loop sees the batch completion time rather than the arrival time — preventing false-positive dead-socket triggers during large offline sync batches. The loop also cooperatively yields every `yield_frequency()` frames to avoid starving other tasks. See [WebSocket & Noise Protocol - Message loop](/advanced/websocket-handling#2-message-loop-read-loop) for implementation details. ### Keepalive loop The keepalive loop runs as a **separate spawned task**, fully decoupled from the read loop. This ensures keepalive pings are never blocked by frame processing — even during large offline sync batches that take seconds to drain. The two loops communicate solely through atomic timestamps (`last_data_received` and `first_send_since_recv` — the dead-socket watchdog anchor). Both are monotonic `wacore::time::Instant`s, not wall-clock milliseconds, so a system clock adjustment (NTP resync, waking from suspend) can't be misread as elapsed time and trip the watchdog on a live socket ([whatsapp-rust#1379](https://github.com/oxidezap/whatsapp-rust/pull/1379)). There is no "last send" timestamp: nothing reads one, and stamping every frame written would cost a clock read on the client's hottest path. ```rust theme={null} const KEEP_ALIVE_INTERVAL_MIN: Duration = Duration::from_secs(15); const KEEP_ALIVE_INTERVAL_MAX: Duration = Duration::from_secs(30); const KEEP_ALIVE_RESPONSE_DEADLINE: Duration = Duration::from_secs(20); const DEAD_SOCKET_TIME: Duration = Duration::from_secs(20); ``` **Behavior:** * Sends ping every 15-30 seconds (randomized, matching WA Web's `15 * (1 + random())`) * Skips ping if data was received within the minimum interval (connection proven alive) * Sends ping *before* dead-socket check to prevent false-positive reconnects on idle-but-healthy connections * Waits up to 20s for response * Checks dead socket on **every** tick (not just after failures) — catches scenarios where pending IQs caused the ping to be skipped, or where the ping succeeded but the connection died immediately after * Detects dead socket, triggering immediate reconnection, if no data has been received for 20s since the **first** send after the last receive (`first_send_since_recv`) — matching WA Web's `deadSocketTimer.onOrBefore`; subsequent sends do not push this deadline back out * Fatal errors (`Socket`, `Disconnected`, `NotConnected`, `InternalChannelClosed`) cause the keepalive loop to exit immediately * Three consecutive transient (`Timeout`/`ServerError`/`ParseError`) failures force `reconnect_immediately()` and exit the loop, rather than pinging forever against a socket that still receives but never answers ([whatsapp-rust#1410](https://github.com/oxidezap/whatsapp-rust/pull/1410)) * The loop exits as soon as `connection_generation` no longer matches the value it was spawned with — the shutdown signal and generation are captured once, at spawn, rather than read from inside the task, since the task's first poll can land after a reconnect has already completed ([whatsapp-rust#1410](https://github.com/oxidezap/whatsapp-rust/pull/1410)) * Error classification is exhaustive and compile-time enforced — adding a new error variant without handling it causes a build failure See [WebSocket & Noise Protocol - Keepalive](/advanced/websocket-handling#keepalive-and-dead-socket-detection) for detailed keepalive internals. ### Offline sync When reconnecting, the client tracks offline message sync progress: ```rust theme={null} pub(crate) struct OfflineSyncMetrics { pub active: AtomicBool, pub total_messages: AtomicUsize, pub processed_messages: AtomicUsize, pub start_time: Mutex>, } ``` **Sync flow:** 1. Receive `` → start tracking, reset counters 2. Process messages with `offline` attribute → increment counter 3. Receive `` → sync complete 4. Emit `OfflineSyncCompleted` — or, if the connection ends before step 3, `OfflineSyncInterrupted` (see below) **Stall timeout (PR #1380):** If the server advertises offline messages via `offline_preview` but stops sending stanzas before the end marker (``) arrives, an inactivity watchdog forces completion — mirroring WhatsApp Web's `ShiftTimer` / `OFFLINE_STANZA_TIMEOUT_MS`. The watchdog arms with the first batch request and re-arms on every offline stanza; because it compares a counter across a sleep rather than reading a clock per stanza, it fires somewhere in `[60s, 120s)` after the last one, not at a fixed 60 seconds. On expiry the client logs a warning, completes the drain with the count actually processed, and `OfflineSyncCompleted` fires — but only while the connection that owns the drain is still up. **Interrupted resume (PR #1380):** A resume that ends because its connection drops — rather than because the end marker arrived or the stall timer fired — never claims completion. Every path that tears down connection-scoped state (`connect()`'s reset, `cleanup_connection_state()`, and the internal waiters' own generation checks) reports the drain's end by emitting `Event::OfflineSyncInterrupted { total, delivered }` exactly once, so a consumer watching only for `OfflineSyncCompleted` no longer mistakes silence for either success or failure. The event itself says nothing about what gets redelivered — that still follows the pre-existing commit-batch ack contract, unchanged by this PR: see [Inbound Durability → Batching](/advanced/inbound-durability#batching) for exactly which batch a mid-drain disconnect does and doesn't redeliver on the next connection's `offline_preview`. See [`OfflineSyncInterrupted`](/concepts/events#offlinesyncinterrupted). **Concurrency gating:** During offline sync, the client restricts message processing to a single concurrent task (1 semaphore permit) to preserve ordering. Once sync completes — either by the server end marker, all expected items arriving, or timeout — the semaphore is expanded to 64 permits, switching to parallel message processing. The drain→live transition also flushes the tail of the [inbound commit batch](/advanced/inbound-durability#batching): the last accumulated batch of decrypted messages commits (buffer → Signal flush → durability hook → acks → `Event::Messages`) before the semaphore widens, so no live-mode message is processed ahead of it. If that tail commit fails, the transition is deferred and retried every 3 seconds while the client stays in single-permit drain mode; `OfflineSyncCompleted` still fires immediately so startup waiters are not blocked on the retry. **Semaphore transition safety:** When the semaphore is swapped from 1 to 64 permits, tasks that were already waiting on the old semaphore must not be silently dropped. The client uses a **generation-checked re-acquire loop** to handle this transition safely: 1. Each semaphore swap increments an atomic `message_semaphore_generation` counter 2. When a task acquires a permit, it checks whether the generation has changed since it started waiting 3. If the generation changed (meaning the semaphore was swapped while the task was blocked), the task drops the stale permit and re-acquires from the new semaphore 4. This loop continues until the task holds a permit from the current-generation semaphore This prevents a critical issue where `pkmsg` messages (which carry Sender Key Distribution Messages for group chats) could be silently dropped during the offline-to-online transition. Without this safety mechanism, a dropped `pkmsg` would cause all subsequent `skmsg` messages from that sender to fail with `NoSenderKeyState`, since the SKDM they depended on was never processed. **State reset:** On reconnect or cleanup, all offline sync state is reset (counters, timing, and the semaphore is replaced with a fresh single-permit instance) so stale state does not leak into the next connection attempt — reported first as an interrupted resume, per above, if a drain was still active. **Pull-batch backlog drain (v0.6):** Offline resume now drives the same pull-batch loop WA Web uses to drain the backlog: stanzas that the client can't process (unrecognized ``, known-but-empty `` content, duplicates, ciphertexts that decrypt-fail terminally) are transport-acked alongside their retry receipt so the server stops re-delivering them. Before this, such a stanza fell through `classify_incoming_message` silently, so the server kept replaying it from the offline queue every reconnect until `` closed the stream. The drain logic also acks duplicate-message PDOs that previously hit the silent-drop branch in `handle_decrypted_plaintext`, and preserves the original `recipient` attribute (via `Client::spawn_node_transport_ack`, which echoes the raw `NodeRef` instead of rebuilding from `MessageInfo`) so LID-routed offline stanzas don't trigger `` on the ack. The Meta AI bot's `msmsg` (``) encryption type was the original motivating case for this drain — it could not be decrypted, only acked. Since the bot-secret decryption landed (see [Bot message decryption](/api/signal#bot-message-decryption-msmsg)), `msmsg` stanzas are decrypted and dispatched as normal `Event::Messages`; the drain still covers genuinely unrecognized or undecryptable enc types. **Nack on terminal decrypt failure:** When a ciphertext exhausts retries (`max-retry` reached in the PDO recovery state machine), the client now emits a `` carrying a structured `NackReason` code instead of silently dropping the message. The full set of 21 codes (`ParsingError`, `InvalidProtobuf`, `MissingMessageSecret`, etc.) mirrors WA Web's reason set so the server stops retransmitting once it sees a terminal nack — see [Protocol → Nack reasons](/advanced/binary-protocol#nack-reasons). **Ack SKDM-only session decrypts:** A `pkmsg`/`msg` that decrypts successfully but carries only a Sender Key Distribution Message (no user-facing content to dispatch) is now explicitly acked. Previously it could decrypt, skip event dispatch, and leave no ack — so the server kept replaying it from the offline queue. The fix closes that gap so SKDM-only stanzas drain like any other processed message. **Nack unparseable message stanzas:** A `` stanza whose required `id`/`from` attrs (and `participant`, for group/status messages) are missing or carry an invalid JID now gets an immediate `` (`NackReason::ParsingError`) instead of a bare warning log and silent drop. `parse_message_info` itself became fail-fast for the same fields — it no longer falls back to a lenient default JID. Consumers driving their own ack/nack flow for intercepted stanzas can reach the same typed responses via `Client::acknowledge_stanza`/`reject_stanza` — see [Manual stanza acknowledgement](/advanced/binary-protocol#manual-stanza-acknowledgement). **Top-level `` stanzas are handled and acked.** The server can deliver E2EE status updates as a top-level `` stanza instead of wrapping them in ``. This shape differs from `` only in its tag, so the client retags it and runs it through the same pipeline, and acks it with `class="status"` (the class the server names in the corresponding ``) plus the local device's own JID, matching WA Web's `sendAck`. Before this, the tag went unhandled — the stanza was neither processed nor acked, so the server kept redelivering it and periodically recycled the stream to demand the ack it was owed. **Nack on unrecognized stanzas:** Answering nothing at all when the stanza router doesn't recognize a top-level tag is what let one unhandled `` stanza (above) stall a stream for days — the server queued it forever and cycled the connection roughly every 50 minutes asking for its ack. Any stanza the router declines now gets `` with `NackReason::UnrecognizedStanza` when it's addressable (`id` and `from` present, the same guard WA Web's `createNackFromStanza` uses); the nack replaces the deferred ack, so the server never gets both for one stanza. This only fires for a tag with no handler at all — see [Protocol → Nack reasons](/advanced/binary-protocol#nack-reasons). ### Critical app-state sync (pairing bootstrap) Right after a fresh pairing (and on any reconnect before the account's critical app-state collections have synced), the client fetches the `CriticalBlock` and `CriticalUnblockLow` collections — blocked contacts and push name — via a batched IQ. Decoding those snapshots requires the app-state **sync-key-share**, an E2E message the primary phone sends automatically, which can arrive late if a heavy history sync is saturating the stream at the same time. **A single 180-second deadline** (`CRITICAL_SYNC_TIMEOUT_SECS`, matching WhatsApp Web's `WAWebSyncBootstrap`) bounds the whole critical-sync path: 1. A watchdog task is armed first against this deadline, before anything else runs. As of PR #1291, the watchdog no longer owns retrying a bad answer (see below) — its only job is to force a reconnect if the batched sync produces *no* answer at all within the window, for example a request that never gets an IQ response. Whichever of the sync and the watchdog settles first claims the outcome; the other stands aside rather than fight over the same reconnect. 2. The client waits up to 10 seconds (`KEY_SHARE_GRACE_SECS`) for the auto-shared key before running the batched critical-collections IQ. This grace period is purely an optimization to skip a redundant explicit key request in the common fast case — it does not gate correctness. 3. If a collection still can't be decoded because its key hasn't landed, the client sends an explicit `AppStateSyncKeyRequest` — fanned out to every discovered companion device the same way as the non-critical path below — and waits for the re-share. For this initial critical bootstrap, the wait is bounded by whatever time remains on the shared 180s deadline (rather than a short fixed wait), so a key that arrives late — or is never auto-shared at all — still has a chance to recover on the same connection instead of failing the sync outright. **The batched critical-collections IQ reports a per-collection outcome** — synced, fatal (the server refused the collection outright with an IQ-level error code, e.g. `400`/`404` — terminal for this connection, not permanently), retryable, or skipped (another in-flight sync or patch send already held it) — rather than a bare success/failure. As of PR #1291, every shape the batch can come back in now reaches the same conclusion: **the connection gets announced.** By the time this sync runs, `set_passive(false)` has already gone out and the socket is delivering offline stanzas, so withholding `Connected` would leave a consumer with no signal that anything had connected while messages kept arriving. What didn't sync is reported instead, and retried in the background rather than by holding the announcement hostage to it: * **All synced:** the client dispatches `Connected` and the watchdog is cancelled. * **Any collection fatal:** repeating the request on this connection would get the same answer, so the client stops waiting on it, dispatches `Connected` anyway, and follows it with [`Event::AppStateSyncFailed`](/concepts/events#appstatesyncfailed) (`connected: true`). * A fatal outcome does not retry on its own connection. Recovering that collection needs a fresh connection — WhatsApp Web's `COMPANION_SYNCD_SNAPSHOT_FATAL_RECOVERY` path is explicitly gated off for `critical_block`. This client now implements that escalation (see [Peer snapshot recovery](#peer-snapshot-recovery) below) for `critical_unblock_low` and the non-critical collections, but not here: it answers a snapshot MAC that fails local verification, a different failure from the IQ-level `400`/`404` refusal this bullet covers, so a batch that comes back `Fatal` still has no automatic recovery on this connection regardless of which collection it names. * The account is usable in the meantime, but missing whatever that collection carries. `critical_block` includes the `setting_pushName` mutation, so presence stays unavailable until the next connection syncs it — unless the push name was already known some other way (e.g. `Bot::with_push_name`, or learned earlier via history sync or a prior session). * WhatsApp Web instead notifies the primary device and logs out on a fatal `critical_block`; this client does not end a session on its own. * **Retryable and/or skipped collections outstanding** (a transient error, or another writer holding the collection — including a batch that failed transport-side before producing any per-collection buckets, which is reported as every requested collection being retryable): the client dispatches `Connected` and [`Event::AppStateSyncFailed`](/concepts/events#appstatesyncfailed) (`connected: true`) the same way, then hands the leftovers to the background sync that follows the bootstrap. Both buckets get one more attempt as part of that sync's own batched request; whatever is still `retryable` after that enters the same bounded backoff every background app-state retry uses — up to `APP_STATE_RETRY_MAX_ROUNDS` (8) rounds doubling from 1 second (a theoretical one-hour cap the round limit never lets it reach, so in practice up to \~128 seconds) — roughly a four-minute window — before giving up and emitting a final `AppStateSyncFailed` for whatever is still unsynced. A round spent waiting for a socket (no connection to retry over) isn't charged as an attempt. A `skipped` collection isn't itself re-queued into that scheduler, matching the non-critical case described below — the equivalent work is understood to be happening via whichever operation already holds it. * **Nothing answers at all within the 180s deadline** (the batched sync itself never returns): the watchdog fires and forces a reconnect. This is the one shape left that can't announce a connection, because there is no answer to announce; the next connection runs the critical bootstrap again from the top. A connection is only actually announced if it's still live and authenticated when the sync finishes. If the generation has been retired by then — a replacement connection has already taken over — neither `Connected` nor the failure report fires for this one; the replacement reports for itself once its own sync finishes. If instead the client has been asked to pause or disconnect, or the server has since rejected the session (429/503, which clear login state inline without tearing down the socket), `Connected` is withheld but [`Event::AppStateSyncFailed`](/concepts/events#appstatesyncfailed) still fires with `connected: false`, and the leftovers still go to the background sync. WhatsApp Web logs out (`socketLogout`) when the critical sync doesn't close cleanly; this client keeps the session and reports the gap instead. Non-critical app-state sync (background regular collections, group `server_sync`, and the `ib` dirty-resync path) is unaffected by this deadline — those callers keep waiting up to 10 seconds (`APP_STATE_KEY_REQUEST_TIMEOUT`) for a missing key before giving up and re-syncing on a later cycle. That request fans out concurrently to every other device on the account (discovered via the same usync device-list lookup used for message routing, current device excluded), falling back to the known primary alone if discovery fails or returns nothing. This dedup-override behavior is shared with the critical bootstrap path (step 3 above): any active wait — critical or non-critical — overrides an existing 24-hour passive dedup stamp for that key, shortening it to a bounded retry window so it isn't starved by an earlier passive request's cooldown. These background syncs report incomplete outcomes through the same `Event::AppStateSyncFailed` event (always with `connected: true`, since the client was already connected). Only their `retryable` collections back off and retry automatically, on the same schedule as the critical path; a `fatal` collection reported this way gets no automatic retry either, and needs the account's next fresh connection. The net effect: a key-share that's delayed by a saturated stream during pairing no longer strands the critical sync until the 180s watchdog forces a reconnect — it recovers via the explicit request within the same window, so contacts and push name sync reliably on the first connection. And a collection that comes back retryable, refused, or held by another writer no longer strands the connection either — the client connects immediately and reports exactly what didn't sync via `Event::AppStateSyncFailed`, instead of looping a silent reconnect against an answer that, for a refusal, was never going to change. Only the retryable/skipped case actually gets retried in the background; a refusal is reported once and left for the account's next fresh connection to pick up. **Behavior change (PR #1291):** before this, only the "all synced" and "fatal" outcomes above announced the connection. A retryable or skipped outcome — including a batch that failed outright before producing any buckets — used to return in silence: an authenticated, no-longer-passive connection with no `Connected` and no `AppStateSyncFailed`, recoverable only by the 180s watchdog forcing a reconnect. If the server kept refusing or failing the same collection, that reconnect looped forever without ever announcing. A consumer that relied on the eventual reconnect producing a working session now instead gets `Connected` immediately alongside an actionable `AppStateSyncFailed`, and must treat that report as something to act on rather than something to wait out. ### Peer snapshot recovery A snapshot MAC mismatch is the one app-state failure the server can't resolve by retrying: it serves the same bytes every time, they fail local verification the same way, and the collection stays at version 0 forever — so every mutation written to it (a chat marked read, a mute, an archive) keeps refusing with a conflict that can never clear. WhatsApp Web's answer is to ask the primary device to resend the collection outright (`COMPANION_SYNCD_SNAPSHOT_FATAL_RECOVERY`); this client implements the same escalation. **Trigger.** Applying a synced collection — via the batched sync path or the single-collection patch-send path — raises `AppStateError::SnapshotMACMismatch` or `SnapshotMACMissing`. Both escalate; no other apply failure does (a missing app-state key is answered by a key share instead, and a bad decode by nothing). **Two exclusions.** `critical_block` is never asked for — rebuilding the block list from a primary that may itself be behind risks talking to someone who was blocked, matching WA Web. And the whole escalation is off when the `enable_peer_snapshot_recovery` ab-prop reads explicit `0`/`false`; the prop simply being absent (this client isn't in WhatsApp's staged rollout) still proceeds, since silence isn't a refusal. **The ask.** [`Client::request_syncd_snapshot_recovery`](/api/client#request_syncd_snapshot_recovery) sends the collection name to the primary as a fire-and-forget peer (PDO) message — the sync paths call it automatically on a matching failure, and it's public so a caller can invoke it directly. `AppStateProcessor` dedupes per collection: an unanswered request suppresses a repeat for 120 seconds, and once a reply has been claimed and is being applied, that window extends to 900 seconds so the apply itself (an app-state key repair, then up to 450 seconds waiting on the collection's own sync reservation) isn't preempted by its own retry. **The reply.** The request and the reply both travel as ordinary peer (PDO) messages, over the same Signal-encrypted session as any other device-to-device message — nothing here is sent outside that channel. What's skipped is the *app-state* encryption layer on top of it: `SyncdSnapshotRecovery` carries the primary's version, ltHash, and every mutation's `SyncActionData` **unencrypted at that layer**, so only each record's index MAC needs re-deriving here, from the app-state key. That's what makes a collection recoverable even when its value encryption is precisely what couldn't be followed before. The version and ltHash are written as the primary sent them rather than recomputed, since recomputing them would be this side re-deriving the very value it just failed to agree on. A reply over the `snapshot_recovery_max_mutations_count_allowed` ab-prop (default 2000 records) is refused rather than applied. Correlation is by request id first (the id this client minted for the ask) and by collection name second — nothing else ties an unprompted peer message back to a specific request — so a reply for the wrong collection, or one answering a request that already expired or was superseded, is dropped rather than applied. Applied mutations dispatch through the same path an ordinary sync uses — the existing `Event::ContactUpdate`, mute, archive, etc. events — so there is no separate event for a recovered collection. What changed is observable via [`memory_report`](/api/client#memory_report)'s `app_state_recovery_requests` field: collections currently awaiting or applying a reply. ### Deferred device sync During offline sync, the client may receive group messages from devices not yet present in the local device registry (for example, a companion device that was paired while the client was offline). Rather than firing a network request for each unknown device individually, the client batches these into a `PendingDeviceSync` set. **Flow:** 1. During offline message processing, `is_from_known_device()` detects an unrecognized sender device 2. The sender's user JID is added to `PendingDeviceSync` (deduplicated — each user is queued at most once) 3. A retry receipt is sent so the sender will redeliver the message after the device list is updated 4. When `` arrives (offline sync complete), the client waits 2 seconds (`OFFLINE_DEVICE_SYNC_DELAY`, matching WhatsApp Web) 5. All batched user JIDs are flushed in a single bulk usync request via `flush_pending_device_sync()` 6. If the flush fails, the JIDs are re-enqueued for the next attempt This batching approach minimizes network overhead — instead of N individual usync requests for N unknown devices, a single bulk request resolves all pending users. When online (not during offline sync), unknown devices trigger an immediate background usync request instead of being batched. ```rust theme={null} // src/pending_device_sync.rs pub(crate) struct PendingDeviceSync { pending: std::sync::Mutex>, } ``` The `PendingDeviceSync` state is cleared on reconnect to prevent stale entries from leaking across connections. The `` hash-only path described in [Storage → Granular cache patching](/concepts/storage#granular-cache-patching) resolves its target contact and joins this same `schedule_unknown_device_sync()` batching — offline resumes queue it here instead of firing an immediate usync mid-drain. Location: `src/pending_device_sync.rs`, `src/handlers/ib.rs`, `src/usync.rs` See also: [Unknown device detection](/advanced/signal-protocol#unknown-device-detection) for the detection mechanism during group message decryption. ## History sync pipeline History sync transfers chat history from the phone to the linked device. The pipeline is designed for minimal RAM usage through a multi-layered zero-copy strategy. ### Processing flow ```mermaid theme={null} graph LR A[Phone uploads
compressed blob] --> B[Download &
stream-decrypt] B --> C[Decompress
zlib] C --> D[Manual protobuf
field walking] D --> E[Bounded channel
cap=4] E --> F[LazyHistorySync
lazy blob] F --> G[Event dispatch] ``` ### RAM optimization layers 1. **Heuristic pre-allocation with `compressed_size_hint`** — the decompression buffer is pre-allocated using a 4x multiplier on the compressed blob's `file_length` (clamped to 256 bytes – 8 MiB). When the notification provides `file_length`, this avoids repeated `Vec` reallocation during decompression. The hint comes from the decrypted (but still compressed) blob size, which is a better estimate than the encrypted size that includes MAC/padding overhead 2. **Compressed bytes retained** — after streaming extraction, the original compressed input is handed back as the event payload. Queued events cost O(compressed) rather than O(decompressed). Peak extraction memory is approximately the largest single conversation 3. **Hand-rolled protobuf parser** — instead of decoding the entire `HistorySync` message tree (which allocates every nested message), the core walks varint tags manually and only extracts field 2 (conversations) and field 7 (pushnames) 4. **`Bytes` zero-copy slicing** — decompressed data is wrapped in a reference-counted `Bytes` buffer; each conversation is extracted as `buf.slice(pos..end)`, which is an Arc refcount increment with no per-conversation heap allocation 5. **Bounded channel streaming** — an `async_channel::bounded::(4)` streams conversation bytes from the blocking parser thread to the async event dispatcher, providing backpressure with only \~4 conversations in-flight 6. **`LazyHistorySync` wrapper** — the compressed payload is wrapped in a `LazyHistorySync` with cheap metadata (sync type, chunk order, progress) available without decoding. Full protobuf decoding only happens if the event handler calls `.get()`. Clone is a refcount bump (no cache carried over). Consumers use `.stream()` for memory-bounded incremental decoding, `.decompress()` for one-shot inflation, or `.compressed_bytes()` for zero-copy access to the stored payload 7. **Compile-time callback elimination** — when no event handlers are registered, the callback is `None`, causing the parser to skip conversation extraction entirely at the protobuf level 8. **Secret-presence pre-scan** — before running buffa decode on each `HistorySyncMsg`, a shallow varint walk checks whether the message carries `message_secret` at any level (`WebMessageInfo.message_secret` or `Message.message_context_info.message_secret`). Messages without a secret (the majority in production blobs) are discarded immediately — no struct decode, no allocation. The scan mirrors protobuf merge semantics so repeated field occurrences and malformed bytes are handled identically to a full decode 9. **Shared conversation id** — `HistoryMsgSecretRecord.chat_id` is `Arc`, allocated once per conversation and reference-counted into every record within that conversation (10k clones → 500 on the bench fixture). `msg_id` uses `CompactString` (inline for typical 20–22 char WA IDs) and `secret` uses `SecretBytes` (inline for secrets ≤32 bytes) 10. **Filter-before-materialize retention hook** — `process_history_sync_bytes_filtered` runs a caller-supplied retention predicate against a borrowed `HistoryMsgSecretRecordRef` (a zero-copy view into the not-yet-built record) before allocating the owned, heap-backed `HistoryMsgSecretRecord`. A record the predicate rejects is discarded without ever being materialized. `process_history_sync_bytes` — and `process_history_sync` itself — keep their existing accept-all behavior by wrapping the filtered entry point with an always-`true` predicate, so callers that don't own a retention policy are unaffected. Bench (synthetic 500-conversation blob, upstream PR's rejection-heavy fixture): allocation churn 21.61 MiB → 14.00 MiB, allocation count \~84k → \~26k. The reduction scales with how much of the record set the predicate rejects — the default accept-all behavior sees none of it. See [`wacore` — history\_sync types](/api/wacore#history_sync-types) for the exported signatures 11. **Streaming record-visitor path** — `process_history_sync_bytes_with_record_visitor` (closure-based) and `process_history_sync_bytes_with_record_sink` (trait-based, via `HistoryMsgSecretRecordVisitor`) go further than the filter hook above. You can build your own storage row directly from the borrowed `HistoryMsgSecretRecordRef`. This ensures the owned `HistoryMsgSecretRecord` is never allocated, even for accepted records. The visitor trait's optional `reserve` and `retained_item_size` hooks let you size your own collection (e.g., a batched SQL insert buffer) up front. Bench (allocator-instrumented synthetic history extraction): 20.20 MiB → 14.43 MiB allocated (-28.6%); CodSpeed history stream-drain memory: 243.8 KB → 115.2 KB (2.1× less). See [`wacore` — history\_sync types](/api/wacore#history_sync-types) 12. **Inline PN↔LID mappings and tctoken candidates, cached group senders** — `HistoryLidMapping.phone_number`/`.lid` and `TcTokenCandidate.id` are `CompactString`, and `TcTokenCandidate.tc_token` is `SmallVec<[u8; 32]>`: typical phone/LID user parts (11–16 digits) and typical tctoken payloads (16–24 bytes) fit inline on 64-bit targets (`CompactString`'s inline capacity is smaller on 32-bit/wasm32, and a longer value in either type spills to the heap), so the PN↔LID harvest — including the two indexes `dedupe_lid_mappings` builds — and the tctoken extraction allocate nothing for typical inputs. The tctoken extraction's chat-kind check now reuses the same borrowed `parse_jid_ref` scan used for the PN/LID guess, instead of building a second owned `Jid` just to read `server`. On the `whatsapp-rust` side, `HistorySecretSeedCollector` caches the previous group message's `(raw participant, Arc sender)` pair — group history arrives in bursts from the same sender, so a repeat of the raw field reuses the cached `Arc` instead of re-parsing the JID and re-rendering it; the cache is keyed on the raw field and cleared on every chat change, so it can't disagree with a fresh resolve. Bench (`bench_process_history_sync`, rebuilt mixed DM/group fixture): 7507 → 3507 allocations (-53%). Deliberate breaking change: `HistoryLidMapping.phone_number`/`.lid` and `TcTokenCandidate.id` move from `String`, and `TcTokenCandidate.tc_token` from `Vec`. Both new types `Deref` to `str`/`[u8]`, so a call site that consumes them as `&str`/`&[u8]` (method calls, `.parse()`, comparisons against `&str`) still compiles via deref coercion; a call site typed explicitly as `&String`/`&Vec` does not coerce — see [`wacore` — Allocation optimizations](/api/wacore#allocation-optimizations) for the full migration note ([#1349](https://github.com/oxidezap/whatsapp-rust/pull/1349)) ### Skip mode For bots that don't need chat history, `skip_history_sync()` sends a receipt so the phone stops retrying uploads but downloads nothing. See [Bot - History Sync](/api/bot#history-sync). ## Concurrency Patterns ### Per-Chat Lanes Prevents race conditions where a later message is processed before the PreKey message. Each chat gets a lane combining an enqueue lock, a worker-liveness lock, and an **unbounded** channel into a single cached entry. Backpressure comes from capping the number of cached lanes rather than messages within a lane: the `chat_lanes` cache itself has a capacity (`chat_lanes_capacity`, default **5,000**) and evicts idle lanes once full. This is a soft cap — a lane with an in-flight message is protected from eviction (see the note below), so if every cached lane happens to be active at once, the map can briefly exceed capacity rather than evicting a live lane and letting a second worker start on the same chat: ```rust theme={null} pub(crate) chat_lanes: Cache, // ChatLane { enqueue_lock, queue_tx, worker_running } // Each queue: async_channel::unbounded::() ``` **Active lanes survive capacity eviction (v0.6).** Every queued item is a `QueuedChatMessage { node, lane_liveness }`, where `lane_liveness` is a clone of the lane's `enqueue_lock`. The cache's `evict_guard` refuses to evict a lane while any in-flight message still holds that clone (`Arc::strong_count(&lane.enqueue_lock) > 1`); the worker drops its copy only after it finishes processing that message. Previously, a lane could be capacity-evicted right after its worker dequeued a message, and a later stanza for the same chat would then miss the cache and spawn a second worker — letting two workers process the same chat concurrently and out of order. Idle lanes (no in-flight message) remain evictable exactly as before. **Idle lane workers self-exit after 60 seconds (v0.7).** A lane's worker awaits the inbound-message handler inline instead of boxing it per message, so the spawned task holds that future's whole state machine (\~9 KiB) for as long as the worker runs — message or no message. Left running for the connection's lifetime, that is one such future per chat that ever spoke, bounded only by `chat_lanes_capacity`; an account active in a few thousand groups could park tens of MiB in idle workers, and the capacity-eviction guard above only ever protected *active* lanes from that cost, not idle ones. A worker now closes its queue and exits after `LANE_IDLE_TIMEOUT` (60s) of silence — the idle timer is armed only once the queue is empty, so a busy lane never pays for it. This is independent of the capacity eviction above: an idle worker exits on its own schedule regardless of how much headroom `chat_lanes_capacity` has left. A message that arrives for a closed lane replaces it. `enqueue_lock` and `worker_running` carry over from the predecessor rather than being re-minted — the chat's enqueue order stays a single total order across the swap (whichever lane generation a handler happened to fetch), and the successor worker takes `worker_running` before processing its first message, so it can never run concurrently with the predecessor still draining whatever raced its idle close. ### Per-device session locks Prevents concurrent Signal protocol operations on the same session. Each device JID gets its own lock, keyed by protocol address strings generated by `to_protocol_address_string()` (format: `user[:device]@server.0`): ```rust theme={null} pub(crate) session_locks: Cache>>, // Key examples: "5511999887766@c.us.0", "123456789:33@lid.0" ``` The DM send path resolves all known recipient devices and own companion devices from the local device registry, filters out hosted devices, excludes the sender device, and deduplicates for self-DMs — matching WA Web's `WAWebSendUserMsgJob` and `WAWebDBDeviceListFanout` behavior. The local registry is checked first; a network fetch is only triggered on a cache miss to avoid unnecessary LID-migration side effects. Session locks are acquired for all involved devices in sorted order to prevent deadlocks. The `build_session_lock_keys()` helper resolves encryption JIDs (normalizing the recipient to bare form via `to_non_ad()`), sorts by `(server, user, device)` using `cmp_for_lock_order()`, and deduplicates. The `session_guards_for()` helper then takes each device's lock as its mutex is resolved from the sorted JIDs, rather than resolving the whole set before locking any of them (PR [#1131](https://github.com/oxidezap/whatsapp-rust/pull/1131)); acquisition order is still `jids` order, which is what keeps two overlapping sends from deadlocking. The peer message path (single-device) acquires a single lock for the resolved encryption JID. **Group SKDM fan-out now shares the DM per-device session locks (v0.6).** `prepare_group_stanza`'s pairwise SKDM fan-out (`encrypt_for_devices_with_sessions`) mutates each target device's pairwise Signal session the same way the DM path does, but it was previously only covered by the per-`(group, sender)` sender-key chain lock — a disjoint key from the DM path's per-device session locks. A concurrent DM (or another group send) sharing a device could therefore race that device's pairwise ratchet: both sides load chain index *N* and both store *N+1*, silently dropping one advance. If the lost advance carried the SKDM, that member never received the sender key and every subsequent `skmsg` was undecryptable for it until a retry re-distributed. `prepare_group_stanza` now acquires the SKDM targets' per-device session locks — via the new `SendContextResolver::lock_device_sessions()` hook, whose `Client` implementation reuses `build_session_lock_keys()` + `session_guards_for()` so both paths serialize on the identical mutexes — before taking the sender-key chain lock, and releases them right after the SKDM fan-out (the `skmsg` chain encrypt that follows only touches the sender-key chain, not any pairwise session). Lock order is always session locks → chain lock on every path, so this cannot introduce a deadlock. The hook defaults to a no-op, so custom `SendContextResolver` implementations (tests, benches) are unaffected unless they opt in. Group stanza preparation uses `sort_dedup_by_user()` to deduplicate participants before device resolution, and `sort_dedup_by_device()` to deduplicate resolved device JIDs after LID conversion — both operate in-place on sorted `Vec` without `HashSet` allocations. ### Sender-key chain lock (group receive) The group receive path (Pass 2 of the [two-pass decryption model](/guides/receiving-messages#two-pass-decryption-model)) acquires a per-`(group, sender)` lock around each `skmsg`'s `group_decrypt` call, keyed by the same `sender_key_name` the sender-key store uses: ```rust theme={null} let chain_lock = adapter.sender_key_store.sender_key_lock(&sender_key_name).await; // ... let _chain_guard = chain_lock.lock().await; let decrypt_result = group_decrypt(ciphertext, &mut adapter.sender_key_store, &sender_key_name).await; ``` Without this lock, two decrypt workers for the same `(group, sender)` — reachable when a [chat lane](#per-chat-lanes) is capacity-evicted while its worker is still draining, and a later stanza for that chat misses the cache and spawns a second worker at the same connection generation — could both load the sender-key chain, advance it, and store their result, with the last store silently winning and dropping a chain step. This mirrors the per-device session lock the 1:1 send/receive paths already hold around their Signal ratchet mutations. **Chat-lane eviction trigger closed (v0.6).** The specific double-worker path described above — a chat lane evicted while its worker was still draining — is now closed by the [active-lane eviction guard](#per-chat-lanes): a lane with an in-flight message can no longer be capacity-evicted, so a later stanza for that chat can no longer spawn a second worker. The [idle-exit lane replacement](#per-chat-lanes) added since (v0.7) is synchronized the same way, via `worker_running`, so it likewise cannot start a second worker for a chat whose predecessor is still draining. This chain lock remains in place as defense-in-depth against any other path that could produce two concurrent decrypt workers for the same `(group, sender)`. ### Background Saver Periodic persistence with dirty flag optimization: ```rust theme={null} impl PersistenceManager { pub fn run_background_saver(self: Arc, runtime: Arc, interval: Duration) { runtime.spawn(Box::pin(async move { loop { // Wait for notification or interval self.save_to_disk().await; } })); } } ``` ## Feature Organization **Location:** `src/features/` ``` features/ ├── mod.rs // Feature exports ├── blocking.rs // Block/unblock contacts ├── chat_actions.rs // Archive, pin, mute, star ├── chatstate.rs // Typing indicators ├── community.rs // Community management ├── contacts.rs // Contact operations ├── groups.rs // Group management ├── media_reupload.rs // Media re-upload for retry ├── mex.rs // GraphQL MEX queries ├── newsletter.rs // Newsletter/channel operations ├── polls.rs // Poll creation and voting ├── presence.rs // Presence updates ├── profile.rs // Profile management ├── signal.rs // Signal protocol feature operations ├── status.rs // Status updates └── tctoken.rs // Trusted contact tokens ``` Media upload and download operations are in `src/download.rs` and `src/upload.rs` as separate top-level modules. **Pattern:** Features are accessed through accessor methods on `Client`: ```rust theme={null} // Access features through the client let metadata = client.groups().get_metadata(&group_jid).await?; let result = client.groups().create_group(options).await?; client.presence().set_available().await?; ``` ## State management flow ```mermaid theme={null} sequenceDiagram participant User participant Client participant PM as PersistenceManager participant Device participant Backend User->>Client: Operation Client->>PM: process_command(DeviceCommand) PM->>Device: modify_device() PM->>PM: Set dirty flag PM->>PM: Notify saver Note over PM: Background task PM->>Backend: save() Backend->>Backend: Persist to disk ``` ## Best Practices ### State Management ```rust ✅ Correct theme={null} // Use DeviceCommand for state changes client.persistence_manager .process_command(DeviceCommand::SetPushName(name)) .await; ``` ```rust ❌ Wrong theme={null} // Never modify Device directly let mut device = client.device.write().await; device.push_name = name; // DON'T DO THIS ``` ### Async Operations ```rust ✅ Correct theme={null} // Wrap blocking I/O in spawn_blocking let result = tokio::task::spawn_blocking(move || { // Heavy crypto or blocking HTTP expensive_operation() }).await?; ``` ```rust ❌ Wrong theme={null} // Never block the async runtime let result = expensive_operation(); // Stalls all tasks ``` ### Error Handling ```rust theme={null} use thiserror::Error; use anyhow::Result; #[derive(Debug, Error)] pub enum SocketError { #[error("socket is closed")] SocketClosed, #[error("noise cipher operation failed")] Cipher(#[from] NoiseError), } // Use anyhow::Result for functions with multiple error types pub async fn complex_operation() -> Result<()> { // Automatically converts errors with ? socket_operation()?; storage_operation()?; Ok(()) } ``` Error variants across the workspace preserve typed sources (via `#[from]` or `#[source]`) instead of stringifying inner errors. Callers can walk `std::error::Error::source()` to downcast to the original cause. ## Related Sections Learn about QR code and pair code flows Understand the event system and handlers Explore storage backends and state management Build your first WhatsApp bot # Authentication Source: https://whatsapp-rust.jlucaso.com/concepts/authentication QR code, pair code, and passkey authentication flows in whatsapp-rust ## Overview WhatsApp-Rust supports three authentication methods for linking companion devices: 1. **QR Code Pairing** - Scan a QR code with your phone 2. **Pair Code (Phone Number Linking)** - Enter an 8-character code on your phone 3. **Passkey Linking (SHORTCAKE\_PASSKEY)** - Gate the link behind a WebAuthn passkey already registered to the account All three methods use the Noise Protocol for secure key exchange (passkey linking additionally requires a WebAuthn assertion) and can run concurrently - whichever completes first wins. ## Authentication Flow ```mermaid theme={null} sequenceDiagram participant Companion as Companion Device
(Your App) participant WA as WhatsApp Server participant Phone as Primary Device
(Phone) Companion->>WA: Connect (Noise Handshake) WA->>Companion: pair-device (QR refs) Companion->>Companion: Generate QR codes Companion->>Phone: Display QR / Pair Code Phone->>WA: Scan QR / Enter Code WA->>Companion: pair-success Companion->>Companion: Sign identity Companion->>WA: pair-device-sign WA->>Companion: Connected! ``` ## QR code pairing ### How it works **Location:** `src/pair.rs`, `wacore/src/pair.rs` 1. **Server sends pairing refs:** After connection, server sends `pair-device` with multiple refs 2. **Generate QR codes:** Each ref becomes a QR code containing device keys 3. **QR rotation:** First code valid for 60s, subsequent codes for 20s each 4. **Phone scans:** User scans QR with WhatsApp > Linked Devices 5. **Crypto handshake:** Noise-based key exchange establishes trust 6. **Completion:** Server sends `pair-success`, device signs identity ### QR code contents ```rust theme={null} // src/pair.rs // Auto-derives the client type from `device_props`. pub fn make_qr_data(store: &Device, ref_str: &str) -> String { let client_type = companion_web_client_type_for_props(&store.device_props); make_qr_data_with_client_type(store, ref_str, client_type) } // Use this to override the trailing client-type field explicitly. pub fn make_qr_data_with_client_type( store: &Device, ref_str: &str, client_type: CompanionWebClientType, ) -> String { /* ... */ } ``` **QR Format:** `ref,noise_pub,identity_pub,adv_secret,client_type` * `ref`: Pairing reference from server * `noise_pub`: Static Noise public key (32 bytes, base64) * `identity_pub`: Signal identity public key (32 bytes, base64) * `adv_secret`: Advertisement secret key (32 bytes, base64) * `client_type`: Single-byte [`CompanionWebClientType`](#companionwebclienttype) wire id (e.g. `1` for Chrome, `9` for `OtherWebClient`) Current WhatsApp Web emits the **5-field** form (with the trailing `client_type`). The parser (`PairUtils::parse_qr_code`) still accepts the legacy 4-field string for backwards compatibility, but `make_qr_data` always produces 5 fields. ### Implementation ```rust theme={null} use std::sync::Arc; use whatsapp_rust::bot::Bot; use whatsapp_rust::TokioRuntime; use whatsapp_rust::store::SqliteStore; use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory; use whatsapp_rust_ureq_http_client::UreqHttpClient; use wacore::types::events::{Event, PairingQrCode}; #[tokio::main] async fn main() -> Result<(), Box> { let backend = Arc::new(SqliteStore::new("whatsapp.db").await?); let mut bot = Bot::builder() .with_backend(backend) .with_transport_factory(TokioWebSocketTransportFactory::new()) .with_http_client(UreqHttpClient::new()) .with_runtime(TokioRuntime) .on_event(|event, _client| async move { match &*event { Event::PairingQrCode(PairingQrCode { code, timeout, .. }) => { println!("Scan this QR code (valid for {}s):", timeout.as_secs()); println!("{}", code); } Event::PairSuccess(info) => { println!("Paired as {}", info.id); } _ => {} } }) .build() .await?; bot.run().await?.await?; Ok(()) } ``` ### QR code events **Event:** `Event::PairingQrCode(PairingQrCode)` ```rust theme={null} // wacore/src/types/events.rs #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairingQrCode { pub code: String, // ASCII art QR or data string pub timeout: std::time::Duration, // Validity duration (60s first, 20s subsequent) } ``` Breaking change: `PairingQrCode` moved from inline fields on `Event::PairingQrCode { code, timeout }` to a dedicated `#[non_exhaustive]` struct sealed with a `bon` builder — `Event::PairingQrCode(PairingQrCode)`. Update `match`/`if let` patterns to destructure through the newtype (with a `..` rest), and construct via `PairingQrCode::builder().code(code).timeout(timeout).build()` instead of a struct literal. **Generated in:** `src/pair.rs:63-116` The rotation loop includes a **safety guard** that checks `is_logged_in()` before emitting each QR code. This prevents stale QR events from firing after pairing completes — important for single-threaded runtimes, fast auto-pair scenarios, and mock servers where the spawned task may not be polled until after pairing succeeds. ```rust theme={null} for code in codes_clone { // Safety guard: pairing may complete before this task gets polled if client_clone.is_logged_in() { info!("Already logged in, stopping QR rotation."); return; } let timeout = if is_first { is_first = false; Duration::from_secs(60) } else { Duration::from_secs(20) }; client.core.event_bus.dispatch(Event::PairingQrCode( PairingQrCode::builder().code(code).timeout(timeout).build(), )); let sleep = client_clone.runtime.sleep(timeout); let stop = stop_rx.recv(); futures::pin_mut!(sleep); futures::pin_mut!(stop); match futures::future::select(sleep, stop).await { futures::future::Either::Left(_) => { // Timeout elapsed — check again in case login happened during sleep if client_clone.is_logged_in() { info!("Logged in during QR timeout, stopping rotation."); return; } } futures::future::Either::Right(_) => { info!("Pairing complete. Stopping QR code rotation."); return; } } } ``` The rotation uses `futures::future::select` with an `async_channel` stop signal rather than Tokio-specific primitives. This keeps the QR rotation compatible with any async runtime, since the `Client` uses the pluggable `Runtime` trait for sleep and spawn operations. ### QR ref exhaustion The server hands out six `pair-device` refs per connection (60s for the first, 20s for each of the other five — 160s total). When the rotation runs out of refs, it dispatches [`Event::PairingQrCodesExhausted`](/concepts/events#pairingqrcodesexhausted) rather than disconnecting unconditionally: ```rust theme={null} // src/pair.rs let pair_code_outstanding = client_clone .pair_code_state .lock() .await .is_outstanding(wacore::time::Instant::now()); client_clone.core.event_bus.dispatch(Event::PairingQrCodesExhausted( PairingQrCodesExhausted::builder() .disconnected(!pair_code_outstanding) .build(), )); if !pair_code_outstanding { client_clone.disconnect().await; } ``` A [pair code](#pair-code-phone-number-linking) flow has an unrelated lifetime — a code sits on a phone screen for up to its \~180s validity window (and longer still while a `companion_finish` is pending), which outlasts the 160s the six QR refs buy. Disconnecting unconditionally would revoke a code the client had just told the user was still good, and any `primary_hello` for it would then arrive at a session the server had already dropped. So the client now disconnects **only when no pair-code flow is outstanding** — a QR-only consumer keeps the reconnect-for-fresh-refs behavior it relies on, while a phone-number flow in progress keeps its socket up. `disconnected: true` means the client is about to tear down its own socket, not that it already has: as the snippet above shows, the event dispatches *before* `disconnect().await` is called. A handler registered as a plain `EventHandler` runs inline during `dispatch`, ahead of the disconnect; a `Bot`/`on_event` closure runs off a channel on its own task and can race it either way. Note that no [`Event::Disconnected`](/concepts/events#disconnected) follows this particular teardown: `Client::disconnect()` sets the `expected_disconnect` flag, and `Disconnected` is scoped to disconnects the client did *not* intend — so waiting on it here would hang forever. `disconnect()` also disables auto-reconnect and, as of PR #1258, is final for this `Client` instance: it fires the same sticky shutdown signal that [`connect()`](/api/client#connect) now checks on entry, so every later `connect()` call on *this* client returns `ConnectError::Shutdown`, not `AlreadyConnected` — retrying with backoff never succeeds. To resume, construct a fresh [`Client`](/api/client#creating-a-client) against the same `persistence_manager` (the not-yet-paired device state lives there) and call `connect()` on that new instance instead. Breaking change: this is a new event. If you match on `Event` exhaustively with a wildcard already in place, no change is needed. Code that used to rely on the client always disconnecting when QR refs ran out — e.g. treating any disconnect during pairing as "start over" — should instead branch on `PairingQrCodesExhausted.disconnected` and reload only when it's `true`. ### Native-camera deep link (open WhatsApp directly) By default the `Event::PairingQrCode` `code` is the **raw** comma-separated string above. It is meant to be scanned from *inside* WhatsApp (**Linked Devices → Link a Device**) — it is **not** a URL and tapping it does nothing. WhatsApp Web (`WAWebLinkDeviceQrcode`) also supports a **deep-link** shape for **iOS native-camera linking**: prefixing the same payload with a `wa.me` URL turns the QR into a link. On iOS, scanning it with the **native Camera app** (not the in-app scanner) opens WhatsApp straight to the Linked Devices screen and hands off the pairing payload via the URL fragment (`#...`). The prefix is exported as a constant: ```rust theme={null} // wacore/src/companion_reg.rs (re-exported from whatsapp_rust::pair) pub const NATIVE_CAMERA_DEEP_LINK_PREFIX: &str = "https://wa.me/settings/linked_devices#"; ``` `make_qr_data` and `Event::PairingQrCode` **never** add this prefix — the emitted string is always the raw 5-field payload. If you want the deep-link behavior you must prepend the prefix yourself before rendering the QR. `PairUtils::parse_qr_code` transparently strips the prefix, so the raw and deep-link forms are interchangeable on the scanning side. #### Mini example — render a scannable deep-link QR ```rust theme={null} use whatsapp_rust::pair::NATIVE_CAMERA_DEEP_LINK_PREFIX; use wacore::types::events::{Event, PairingQrCode}; // Depends on the `qrcode` crate: qrcode = "0.14" fn render_qr(payload: &str) { use qrcode::{QrCode, render::unicode}; let code = QrCode::new(payload).expect("valid QR payload"); let art = code .render::() .quiet_zone(true) .build(); println!("{art}"); } // ...inside your event handler: .on_event(|event, _client| async move { if let Event::PairingQrCode(PairingQrCode { code, timeout, .. }) = &*event { // Prepend the prefix so iOS's native Camera opens WhatsApp directly. let deep_link = format!("{NATIVE_CAMERA_DEEP_LINK_PREFIX}{code}"); println!("Scan with your iOS Camera app (valid {}s):", timeout.as_secs()); render_qr(&deep_link); // Tip: `deep_link` is also a working https:// URL you can share as a // clickable link on the device itself. } }) ``` Render the **raw** `code` instead of `deep_link` if you want the classic in-app scanner flow; both produce a valid, scannable code. ## Pair code (phone number linking) ### How it works **Location:** `src/pair_code.rs`, `wacore/src/pair_code.rs` 1. **Generate code:** 8-character Crockford Base32 code 2. **Stage 1 - Hello:** Send phone number + encrypted ephemeral key 3. **Server response:** Returns pairing reference 4. **User enters code:** On phone: WhatsApp > Linked Devices > Link with phone number 5. **Stage 2 - Finish:** Phone confirms, companion sends key bundle 6. **Completion:** Server sends `pair-success` ### Pair code format **Alphabet:** Crockford Base32 (excludes 0, I, O, U) ``` 123456789ABCDEFGHJKLMNPQRSTVWXYZ ``` **Length:** Exactly 8 characters **Example:** `ABCD1234`, `MYCODE12` ### Implementation #### Random Code ```rust theme={null} use whatsapp_rust::pair_code::PairCodeOptions; let options = PairCodeOptions { phone_number: "15551234567".to_string(), show_push_notification: true, ..Default::default() }; let code = client.pair_with_code(options).await?; println!("Enter this code on your phone: {}", code); ``` #### Custom Code ```rust theme={null} let options = PairCodeOptions { phone_number: "15551234567".to_string(), custom_code: Some("MYCODE12".to_string()), ..Default::default() }; let code = client.pair_with_code(options).await?; assert_eq!(code, "MYCODE12"); ``` ### One code at a time A second code does not replace the first *for the phone*: the server routes `primary_hello` by phone number, never seeing the code itself, so whoever is still reading the older code reaches stage 2 and is handed a key bundle their code cannot open — the phone reports a failed link and the companion sees nothing. WA Web forbids the overlap outright (`invariant(stage === Initialized)` in `Alt/DeviceLinkingApi.js`). `pair_with_code` now enforces the same rule: it fails with [`PairCodeError::CodeAlreadyOutstanding { remaining }`](#pair-code-errors) while the previous code is still outstanding, instead of silently overwriting it. "Outstanding" is either clock: the code's own validity window, or — once `primary_hello` has been accepted — the pending `pair-success` that follows it, which can run up to a minute past the window (`remaining` reads as `0` in that case, since there's no window left to report). Call `cancel_pair_code` first when the replacement is intentional: ```rust theme={null} use wacore::pair_code::PairCodeError; use whatsapp_rust::pair_code::PairError; match client.pair_with_code(options).await { Ok(code) => println!("Enter this code on your phone: {code}"), Err(PairError::PairCode(PairCodeError::CodeAlreadyOutstanding { remaining })) => { // `remaining` is the code's validity window, not a countdown on the // overall block: it reads `0` while a pair-success is pending on an // already-entered code, not "no time left before you may retry". eprintln!("A code is already outstanding ({remaining:?} left in its validity window) — cancel it first"); client.cancel_pair_code().await; } Err(e) => eprintln!("Pair code request failed: {e}"), } ``` **Do not drive `pair_with_code` from QR-code rotation.** The two flows have unrelated lifetimes — a pair code is read off a screen and typed into a phone minutes later, well past the point a QR ref would rotate. Re-requesting a code on every QR rotation trips `CodeAlreadyOutstanding` and does not match WA Web, which only regenerates on the server's `refresh_code`, on `force_manual_refresh`, or on its own expiry timers — never on a QR ref rotating. See [Pair code refresh events](#pair-code-refresh-events) for the cases that *do* warrant a new request. `Client::cancel_pair_code()` abandons the outstanding flow, if any — WA Web's `initializeAltDeviceLinking()`. ```rust theme={null} // src/pair_code.rs pub async fn cancel_pair_code(self: &Arc); ``` **Cancellation is now reliable on both sides of `primary_hello`.** Before a `primary_hello` has been accepted, cancelling is immediate and complete: a later `primary_hello` for the cancelled ref is dropped rather than answered with a bundle its holder cannot open. Deriving the key bundle and sending `companion_finish` runs under the same lock `cancel_pair_code` takes, so the two never interleave mid-derivation — `cancel_pair_code` either runs before stage 2 starts (and the notification is dropped) or after stage 2 has already sent `companion_finish` and released the lock. In that second case, cancelling **re-mints the device's `adv_secret_key`**: the value stage 2 derived and persisted is keyed to a primary that has just been told to stop, so a `pair-success` that still arrives for it fails signature verification instead of silently completing the link. A flow already in `PairCodeState::Completed` is left untouched — that secret belongs to a device that did pair, and re-minting it would invalidate the account's own ADV signatures. An expired code never blocks a new request — `CodeAlreadyOutstanding` is only returned while the previous code (or a pending `pair-success` for it) is still live. ### Pair code options ```rust theme={null} // wacore/src/pair_code.rs pub struct PairCodeOptions { /// Phone number in international format (e.g., "15551234567"). /// Non-digit characters are automatically stripped. pub phone_number: String, /// Whether to show a push notification on the phone (default: `true`). pub show_push_notification: bool, /// Custom 8-character code (must be valid Crockford Base32). /// If `None`, a random code is generated. pub custom_code: Option, /// Override for `companion_platform_id`. When `None`, the value is /// derived from `Device.device_props.platform_type`. pub platform_id: Option, /// Advanced OS override for `companion_platform_display`. `None` /// (default) canonicalizes `DeviceProps::os` to a small server-safe set /// (an unrecognized/branding value coerces to `Linux`). `Some(os)` sends /// `os` **verbatim**, bypassing that coercion — use it to keep a real OS /// name the server accepts but the canonical set drops (e.g. `"Ubuntu"`, /// `"Fedora"`). At your own risk: the server rejects a non-OS string with /// `bad-request`. An all-whitespace value is ignored (falls back to the /// safe coercion). pub display_os: Option, } ``` ### CompanionWebClientType `CompanionWebClientType` is the wire-level enum emitted in the `` child of the pair-code IQ. Each variant has a fixed single-byte ASCII identifier returned by [`wire_byte`](https://github.com/oxidezap/whatsapp-rust/blob/main/wacore/src/companion_reg.rs): ```rust theme={null} // wacore/src/companion_reg.rs pub enum CompanionWebClientType { // Web (digit codes from WAWebCompanionRegClientUtils.DEVICE_PLATFORM) Chrome, // b'1' Edge, // b'2' Firefox, // b'3' Ie, // b'4' Opera, // b'5' Safari, // b'6' Electron, // b'7' Uwp, // b'8' OtherWebClient, // b'9' — default fallback // Mobile (letter codes from the official WhatsApp Android client). // Reachable only via an explicit `PairCodeOptions::platform_id` override // because the server requires attestation that this crate cannot fake. AndroidTablet, // b'd' AndroidPhone, // b'e' AndroidAmbiguous, // b'f' } ``` The proto's `UNKNOWN` (wire `'0'`) is intentionally absent — WA Web never emits it from a real browser and the server rejects it. The default is `OtherWebClient` (`'9'`). The server accepts 23 single-byte ids (`0..9` and `a..m`); only the 12 with a confirmed platform meaning are exposed. #### Mapping from `PlatformType` `companion_web_client_type_for_platform` maps each `wa::device_props::PlatformType` to a wire variant. Web platforms map to their browser variant (Chrome, Firefox, Edge, etc.). `Desktop` maps to `Electron`. The Android `PlatformType` variants (`AndroidPhone`, `AndroidTablet`, `AndroidAmbiguous`) map to **`Chrome`** — that's what real WA Web on Chrome-Android emits and what the server accepts without attestation. To request the Android letter codes (`'d'`/`'e'`/`'f'`) explicitly, set `PairCodeOptions::platform_id`. iOS, AR/VR, Wear OS, `WAIL`, and the proto's `UNKNOWN` collapse to `OtherWebClient` — the Android letters need attestation this crate cannot produce, and `'0'` is server-rejected. #### `companion_platform_display` The display string sent in `` is built from the resolved wire variant and a **canonicalized** OS derived from `DeviceProps::os`: * Web variants emit ` ()`, e.g. `Chrome (Linux)`, `Firefox (Windows)`. Non-browser web variants (Electron, UWP, OtherWebClient) and Android-mapped-to-Chrome fall back to `Chrome ()`, mirroring WA Web's reported renderer name. * Explicit `AndroidPhone`/`AndroidTablet`/`AndroidAmbiguous` overrides emit `Android ()`, e.g. `Android (Android)`. Unlike QR pairing — which never sends this field and so tolerates an arbitrary branding string in `DeviceProps::os` — the pair-code `companion_hello` server **rejects a non-OS `companion_platform_display` with `bad-request`**. The OS component is therefore canonicalized through `wacore::companion_reg::CompanionOs` into a small, server-safe set instead of using `DeviceProps::os` verbatim. `CompanionOs::from_hint` classifies a free-form OS hint (case-insensitive, with whole-word guards so branding like `"KaiOS"`/`"March"`/`"across"` doesn't false-match a substring) into one of: | `CompanionOs` | Wire string | Matches (examples) | | ------------- | ----------- | -------------------------------------------------------- | | `Windows` | `Windows` | `windows`, `Windows 11` | | `MacOs` | `Mac OS` | `Mac`, `macOS`, `Mac OS X`, `darwin` | | `Linux` | `Linux` | `Linux`, `Ubuntu`, `Fedora`, `Arch`, `Chrome OS`, `CrOS` | | `Android` | `Android` | `Android`, `android 14` | | `Ios` | `iOS` | `iOS`, `iPhone`, `iPad`, `iPadOS` | An OS that doesn't classify (empty, or a branding string such as `"Veloz"`) coerces to `Linux` — the same fallback QR pairing already used for an empty OS, now also covering non-OS branding strings. The client logs a one-time `warn!` when this coercion actually changes a non-empty `os`, so a consumer sees why their custom branding didn't ride through. **Escape hatch:** set `PairCodeOptions::display_os` to send an OS **verbatim**, bypassing canonicalization entirely — useful to keep a real, server-accepted name the canonical set collapses (e.g. `"Ubuntu"` → `Linux`). This is at the caller's risk: a non-OS string here is rejected with `bad-request`. An all-whitespace override is ignored and falls back to the safe coercion. The server also validates that the display string is 1..=100 bytes. ### Pair code events **Event:** `Event::PairingCode(PairingCode)` ```rust theme={null} // wacore/src/types/events.rs #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairingCode { pub code: String, // The 8-character pairing code pub timeout: std::time::Duration, // Validity (~180 seconds), remaining at time of emission } ``` **Generated in:** `src/pair_code.rs` The validity clock is stamped *before* the stage-1 `companion_hello` request is sent, matching WA Web's `startAltLinkingFlow`, and held as a deadline — `code_expires_at: wacore::time::Instant`, the generation instant plus `PairCodeUtils::code_validity()` — rather than the generation instant itself ([whatsapp-rust#1379](https://github.com/oxidezap/whatsapp-rust/pull/1379)). A monotonic clock has no history before the process started, so subtracting a validity window from "now" in a young process can saturate at the clock's origin and land in the future; storing the deadline directly sidesteps that and matches what both readers (the event's `timeout` and `handle_primary_hello`'s expiry check) actually want. Because of this, `timeout` on the dispatched event is the *remaining* window, not always the full \~180 seconds — otherwise a UI countdown built from the event would outlast the server's (and this crate's own `handle_primary_hello`) actual expiry check by however long stage 1 took. ```rust theme={null} let requested_at = wacore::time::Instant::now(); let code_expires_at = requested_at + PairCodeUtils::code_validity(); // ...stage 1 companion_hello round-trip... let remaining = code_expires_at.saturating_duration_since(wacore::time::Instant::now()); self.core.event_bus.dispatch(Event::PairingCode( PairingCode::builder() .code(code.clone()) .timeout(remaining) .build(), )); ``` Breaking change: `PairCodeState`'s `code_generation_ts: i64` (wall-clock seconds) field is replaced by `code_expires_at: wacore::time::Instant` (the deadline, not the generation instant), and `is_outstanding` / `live_flow_remaining` now take an `Instant` instead of a wall-clock reading. The boundary is unchanged — the deadline is still the last live instant, matching `handle_primary_hello` rejecting only strictly-past arrivals (WA Web `OldCodeError`) — but a caller comparing against wall-clock time no longer compiles. Breaking change: `PairingCode` and `PairingCodeRefresh` moved from inline enum-variant fields (`Event::PairingCode { code, timeout }`, `Event::PairingCodeRefresh { force_manual }`) to dedicated `#[non_exhaustive]` structs sealed with a `bon` builder — `Event::PairingCode(PairingCode)` / `Event::PairingCodeRefresh(PairingCodeRefresh)`. Destructuring patterns need a `..` rest; construction goes through `PairingCode::builder()…build()`. ### Pair code refresh events **Event:** `Event::PairingCodeRefresh(PairingCodeRefresh)` ```rust theme={null} // wacore/src/types/events.rs #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairingCodeRefresh { pub force_manual: bool, // true when the server requires an explicit re-request } ``` `PairingCodeRefresh` now covers **two** triggers, matching WA Web's `Alt/DeviceLinkingApi.js` and `Link/DevicePhoneNumberCodeScreen.react.js`: 1. **Server-requested.** A `link_code_companion_reg` notification arrives with `stage="refresh_code"` (WA Web `refreshAltLinkingCode` / `forceManualRefresh`) and its `link_code_pairing_ref` matches the outstanding request. `force_manual` reflects the notification's `force_manual_refresh` attribute. 2. **Silent `pair-success`.** The code was entered on the phone (`primary_hello` accepted), `companion_finish` was not refused — either the server accepted it, or the 30s wait for its own answer timed out unanswered — but no `pair-success` arrived within `PairCodeUtils::primary_hello_pair_success_timeout()` (WA Web's one-minute `primary_hello_expire` timer). A primary that fails to open the key bundle just goes quiet at this stage — silence is the only signal there is — so the client times the wait out itself and dispatches `PairingCodeRefresh` with `force_manual: false`. A *refused* `companion_finish` is a different case and no longer falls under this event: the server answers with an error immediately, so the client doesn't need to wait out the silence timer to know the flow is dead. See [Pair code failure events](#pair-code-failure-events). In both cases the outstanding flow is cleared **before** the event fires, so a handler can call [`Client::pair_with_code`](/api/client#pair_with_code) immediately without hitting [`PairCodeError::CodeAlreadyOutstanding`](#one-code-at-a-time). The previous code is no longer valid either way. Register a handler with [`Bot::on_pair_code_refresh`](/api/bot#on_pair_code_refresh) or match on the event directly via `on_event`. ### Pair code failure events **Event:** `Event::PairingCodeError(PairingCodeError)` ```rust theme={null} // wacore/src/types/events.rs #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairingCodeError { pub rejection: Option, // The server's refusal, when it answered with one pub backoff: Option, // The server's requested retry delay, when it named one pub error: String, // The failure rendered for logs — do not branch on it } ``` The counterpart to `PairingCode` on the failure path, and the only surface that reports a failed request when pairing is driven by [`BotBuilder::with_pair_code`](/api/bot#with_pair_code): that call runs [`Client::pair_with_code`](/api/client#pair_with_code) inside a detached task, so nothing returns its `Err` to a caller. `pair_with_code` dispatches this event in addition to returning `Err`, mirroring how the success path both returns the code and emits `Event::PairingCode`. Register a handler with [`BotBuilder::on_pair_code_error`](/api/bot#on_pair_code_error) or match on the event directly via `on_event`. Fires for **every** stage-1 failure, including local validation — a phone number that's too short never reaches the server. `rejection` carries the server's refusal as a typed status in most cases where the server answered: `Some(PairCodeRejection::Unknown(code))` even for a code outside WA Web's own five — the code is still preserved, just not aliased to a named arm. `None` means one of two things instead: the failure never reached the server at all (local validation, no connection, timeout), *or* it did, but the server paired a **named** code with a `text` that contradicts it — see [`PairCodeRejection::from_server`](#paircoderejection) for why a contradiction is refused rather than trusted. In every `None` case the message from `error` is still the only description available. A claim the failed request itself held is released before this fires, so `pair_with_code` can be called again immediately. **Also fires for a stage-2 refusal.** Since the [Stage 2: Finish](#stage-2-finish) `companion_finish` round trip was made to wait for its answer, a server refusal there dispatches this same event — immediately, not after the minute-long `pair-success` silence timer. There is no separate event type: both round trips fail the same way for a consumer, which is that this code is finished and another has to be requested. `rejection` is classified from the same `PairCodeRejection` set stage 1 uses — WA Web's own `companion_finish` parser only ever answers with two of those codes (see [`PairCodeRejection`](#paircoderejection) below), but a server response outside that pair is still classified rather than discarded, exactly as an out-of-set stage-1 code is. An unanswered (timed-out) `companion_finish` does **not** dispatch this event; that silence is still owned by the one-minute timer and surfaces as [`PairingCodeRefresh`](#pair-code-refresh-events) instead. Two failures do **not** dispatch this event, because for them a code may still be on its way and the event would say the opposite: * **`PairCodeError::CodeAlreadyOutstanding`** — refused precisely because an earlier code is still live; the consumer already has it from the `PairingCode` event that minted it. * **`PairCodeError::Cancelled`** — the caller withdrew this request via `cancel_pair_code`, and a replacement may already own the slot by the time this one resolves; reporting the withdrawn request would be uncorrelated with the flow that's actually running. Both are consequences of something the caller did, so neither is news to them, and a direct caller still receives the `Err` either way — only the event is suppressed. `PairError::lost_the_flow_to_another_request()` is `true` for exactly these two variants. #### PairCodeRejection ```rust theme={null} // wacore/src/pair_code.rs pub enum PairCodeRejection { BadRequest, // 400 — malformed, or throttled per phone number (server reuses this code) Forbidden, // 403 RateOverlimit, // 429 — requesting codes too fast; the only correct response is to slow down FeatureNotAvailable, // 452 — phone-number linking disabled for this account; retrying will not help InternalServerError, // 500 Unknown(i32), // a code outside WA Web's own accepted set } ``` The five named variants are the complete set WA Web's stage-1 response parser (`WASmaxInMdIqMixinErrors.parseIqMixinErrors`) accepts; anything else makes its own RPC throw "unknown error", which is what `Unknown(code)` preserves here. Classified via `PairCodeRejection::from_server(code, text)` from *both* wire attributes together — WA Web asserts them as a literal pair (e.g. `429`/`rate-overlimit`) and falls back to its generic error path when they disagree, so a contradicting `text` classifies as `None` rather than aliasing the code to the named arm. An absent `text` is not treated as a contradiction; the code alone decides in that case. Both pair-code round trips report through this same type, though only stage 1's `companion_hello` accepts the full five. Stage 2's `companion_finish` has its own, narrower server-side parser (`WASmaxInMdCompanionFinishErrors`) that admits only `BadRequest` (400) and `InternalServerError` (500) — WA Web shows its generic failure for anything else there. A stage-2 code outside that pair is still classified here rather than discarded: what a consumer does about a refusal follows from the code, which is one namespace across both requests. `PairCodeRejection::is_throttled()` is `true` for `RateOverlimit` and `BadRequest` — deliberately wider than the literal 429, because the server throttles pair-code requests per phone number under `bad-request` instead of `rate-overlimit`, and the two are indistinguishable on the wire. Treat a `true` here as "back off, then retry a bounded number of times," not as proof the request would eventually succeed. `FeatureNotAvailable` is never throttled — retrying cannot fix it, and WA Web falls back to the QR code instead. `PairError` (the `Err` `pair_with_code` returns) exposes the same classification without depending on the event: ```rust theme={null} impl PairError { pub fn rejection(&self) -> Option; pub fn backoff(&self) -> Option; pub fn lost_the_flow_to_another_request(&self) -> bool; } ``` ### Two-Stage Flow #### Stage 1: Hello **Purpose:** Register phone number and encrypted ephemeral key ```rust theme={null} // src/pair_code.rs:165-174 let iq_content = PairCodeUtils::build_companion_hello_iq( &phone_number, &noise_static_pub, &wrapped_ephemeral, options.platform_id, &options.platform_display, options.show_push_notification, req_id.clone(), ); ``` **Response:** Pairing reference ```rust theme={null} let pairing_ref = PairCodeUtils::parse_companion_hello_response(&response) .ok_or(PairCodeError::MissingPairingRef)?; ``` #### Stage 2: Finish **Trigger:** `link_code_companion_reg` notification from server **Handling:** `src/pair_code.rs` `handle_pair_code_notification` dispatches on the notification's `stage` attribute, mirroring WA Web's `handleAltDeviceLinkingNotification`. An unrecognized stage is ignored without touching the in-progress flow: ```rust theme={null} match reg_node.get_attr("stage").map(|v| v.as_str()).as_deref() { Some("primary_hello") => handle_primary_hello(client, reg_node).await, Some("refresh_code") => handle_refresh_code(client, reg_node).await, _ => { /* ignored */ false } } ``` **`primary_hello`** — the user entered the code on their phone: 1. Extract primary's wrapped ephemeral pub (80 bytes) 2. Extract primary's identity pub (32 bytes) 3. Validate the notification before touching any crypto (checked in this order, so a rejected notification never consumes a retry slot): * `link_code_pairing_ref` must match the ref cached from `companion_hello` (WA Web `InvalidRefError`) * the code must still be within its \~180s validity window (WA Web `OldCodeError`) * at most `PairCodeUtils::max_primary_hello_attempts()` (3, matching WA Web's `T`) genuine attempts are processed per code (WA Web `MaxPrimaryHelloError`) — a rejected attempt does not count against this cap 4. Decrypt primary's ephemeral key (expensive PBKDF2, run in `spawn_blocking`) 5. Prepare encrypted key bundle 6. Send `companion_finish` IQ and **wait for its answer**, up to `PairCodeUtils::companion_finish_iq_timeout()` (30s) The whole of stage 2 runs under the `pair_code_state` lock, held from validation through the socket send — not through the wait for the answer. The transport dispatches `` stanzas on concurrent detached tasks, so two `primary_hello` notifications for the same code could otherwise each derive a *different* random `adv_secret` and race `SetAdvSecretKey` (last-write-wins) — desyncing the persisted secret from the `companion_finish` the server acts on. Holding the lock across the full stage through the send makes concurrent notifications for the same code process sequentially, matching WA Web's single-threaded model; releasing it before the wait means `cancel_pair_code` is never blocked for the length of a round trip to an unresponsive server. State stays `WaitingForPhoneConfirmation` after a successful `companion_finish` (rather than moving to `Completed`) so a genuine retry can still reuse it — only `pair-success` (see [`crate::pair`](#qr-code-pairing)) completes the flow. `companion_finish` used to be sent fire-and-forget: nothing read its answer, so a server refusal surfaced only as a generic "unhandled IQ" log line and the consumer learned the flow had died from a minute of silence (the timer below). It now goes out through the same IQ path as stage 1 and the response is handled explicitly: * **Accepted.** Nothing changes — the flow stays `WaitingForPhoneConfirmation`, waiting on `pair-success` as before. * **Refused** (the server answers with an ``, e.g. `bad-request` or `internal-server-error`). The flow is retired immediately and reported through [`Event::PairingCodeError`](#pair-code-failure-events) with a typed [`PairCodeRejection`](#paircoderejection) — the same surface stage 1 already had, so a consumer no longer has to wait out the silence timer to learn stage 2 failed. * **Timeout** (no answer within 30s). This is deliberately *not* reported and does not retire the flow: silence already belongs to the one-minute `pair-success` timer described below, over a longer window, and ending the flow on the shorter IQ timeout could cut a link the server is still completing. Retiring a flow that reached stage 2 — via a refusal, the one-minute timer, or `cancel_pair_code` — also re-mints the device's `adv_secret_key` (see the cancellation note in [One code at a time](#one-code-at-a-time)). That secret was derived and persisted for a primary that will now never link, so leaving it in place would only let a later, unrelated flow inherit a stale value. **`refresh_code`** — the server asks the companion to regenerate the code it is displaying. Dispatches [`Event::PairingCodeRefresh`](/concepts/events#pairingcoderefresh) with `force_manual` taken from the notification's `force_manual_refresh` attribute, but only when the notification's ref matches the outstanding flow (WA Web's `getCurrentRef()` guard) — otherwise it is ignored. ## Passkey linking (SHORTCAKE\_PASSKEY) **Breaking change (unreleased):** enable the opt-in `passkey` feature flag if you use passkey linking — it's off by default. This lands after the published **0.7.0** release, so it needs a git dependency until a later version ships it: ```toml Cargo.toml theme={null} [dependencies] whatsapp-rust = { git = "https://github.com/oxidezap/whatsapp-rust", rev = "", features = ["passkey"] } ``` On the published `0.7.0`, nothing gates it yet — passkey linking works with no feature flag at all. On a build past this change, skip the `passkey` feature and `whatsapp_rust::passkey` doesn't exist for you — `set_passkey_authenticator`, `send_passkey_response`, and `send_passkey_confirmation` are unavailable, and a `passkey_prologue_request` notification from the server reaches your app as `Event::Notification` instead of the `Event::PairPasskey*` events below, the same way any notification type this client doesn't model does. See [Feature flags](/installation#feature-flags). ### How it works **Location:** `wacore/src/shortcake.rs` (pure crypto/protobuf core), `src/passkey/mod.rs` (the `PasskeyAuthenticator` seam), `src/passkey/flow.rs` (the client driver) This gate requires a WebAuthn passkey **already registered** to the WhatsApp account (e.g. in Google Password Manager or iCloud Keychain) — it is not a standalone pairing method you can bootstrap from scratch like QR or pair code. The server asks the companion to prove possession of that passkey before it will hand over the ADV secret. ```mermaid theme={null} sequenceDiagram participant Companion as Companion Device
(Your App) participant Auth as PasskeyAuthenticator
(WebAuthn) participant WA as WhatsApp Server participant Phone as Primary Device
(Phone) WA->>Companion: notification: passkey_prologue_request Companion->>Auth: get_assertion(AssertionRequest) Auth-->>Companion: Assertion (WebAuthn signature) Companion->>WA: (credential_id, webauthn_assertion,
ephemeral-identity commitment[, handoff proof]) WA->>Phone: relay prologue Phone->>WA: primary_ephemeral_identity WA->>Companion: notification: crsc_continuation Companion->>Companion: derive shared key + verification code Companion->>Phone: display/confirm "XXXX-XXXX" (unless skip_handoff_ux) Companion->>WA: (rotated ADV secret, AES-256-GCM) WA->>Companion: pair-success ``` 1. **Server requests a WebAuthn assertion:** a `passkey_prologue_request` notification carries (or points to, via IQ) the `PublicKeyCredentialRequestOptions` JSON. 2. **Companion obtains an assertion:** delegated to a registered [`PasskeyAuthenticator`](#passkeyauthenticator-trait) — e.g. Android Credential Manager — since the passkey's private key is non-extractable and never touches this crate. 3. **Ephemeral-identity commit/reveal:** the companion generates a fresh X25519 keypair and nonce, commits to them (``), and the primary reveals its own ephemeral identity in return (`crsc_continuation`). 4. **Shared key + verification code:** both nonces and public keys derive an AES-256-GCM key and an 8-character "XXXX-XXXX" verification code. 5. **Encrypted pairing request:** the companion encrypts its static Noise/identity public keys plus a freshly **rotated** ADV secret under that key and sends ``. 6. **Completion:** as with QR/pair-code, the server sends `pair-success` and linking finishes through the same [`PairSuccess`/`PairError`](#success-events) path. The rotated ADV secret is held only in memory until [`send_passkey_confirmation`](#implementation-2) succeeds — it is committed to the device store (`DeviceCommand::SetAdvSecretKey`) only after the primary has it. An abandoned or failed attempt never leaves the device on a secret the primary never received. ### Re-links skip the verification code On a **re-link** — the device already has a prior linked identity (`account`, phone number, or LID persisted from an earlier pairing) — the client derives an HMAC "handoff proof" from the stored `adv_secret_key` and includes it in ``. If the server accepts it as proof of continuity, `Event::PairPasskeyConfirmation.skip_handoff_ux` is `true` and the link can complete without showing the user a code. A **fresh** link (no prior `account`/`pn`/`lid`) never derives a handoff proof, even though `adv_secret_key` itself is always present — it's randomly generated at device creation, so it can't by itself signal continuity with a real prior link. A brand-new link therefore always shows the verification code. ### `PasskeyAuthenticator` trait **Location:** `src/passkey/mod.rs` ```rust theme={null} #[async_trait] pub trait PasskeyAuthenticator: MaybeSendSync { async fn get_assertion(&self, request: &AssertionRequest) -> Result; } ``` ```rust theme={null} pub struct AssertionRequest { pub challenge: Vec, // already base64url-decoded pub rp_id: Option, pub allow_credentials: Vec>, // empty = discoverable credential pub user_verification: UserVerification, // Required | Preferred | Discouraged pub timeout_ms: Option, pub raw_options_json: String, // verbatim server JSON, e.g. for Android Credential Manager } pub struct Assertion { pub assertion_json: Vec, // WA's `` JSON shape pub credential_id: Vec, } ``` Two helper functions parse/build the wire shapes so a host authenticator doesn't have to: ```rust theme={null} // Parses the server's PublicKeyCredentialRequestOptions JSON into an AssertionRequest pub fn parse_request_options(json: &str) -> Result; // Assemble WA Web's exact `` JSON from raw WebAuthn assertion components // (for authenticator backends that return raw bytes instead of WA-shaped JSON). pub fn build_webauthn_assertion_json( credential_id: &[u8], client_data_json: &[u8], authenticator_data: &[u8], signature: &[u8], user_handle: Option<&[u8]>, ) -> Vec; ``` If you don't need a custom integration, `CallbackAuthenticator` wraps any async closure as a `PasskeyAuthenticator`: ```rust theme={null} use std::sync::Arc; use whatsapp_rust::passkey::{Assertion, AssertionRequest, CallbackAuthenticator, PasskeyError}; let authenticator = CallbackAuthenticator::new(|request: AssertionRequest| { Box::pin(async move { // e.g. hand `request.raw_options_json` to Android Credential Manager's // GetPublicKeyCredentialOption(requestJson = ...) and map the result: Ok(Assertion { assertion_json: get_webauthn_assertion_json(&request).await?, credential_id: get_credential_id(&request).await?, }) }) }); client.set_passkey_authenticator(Arc::new(authenticator)).await; ``` ### Driving modes * **Automatic:** with `set_passkey_authenticator` called, the client drives the assertion step — it calls `get_assertion` when the server asks and sends the response for you. It also auto-confirms re-links whose `skip_handoff_ux` is `true`, since continuity is already proven. A **fresh** link does not auto-confirm even in this mode: it still emits `Event::PairPasskeyConfirmation`, and you must show the code to the user and call `send_passkey_confirmation()` yourself once they approve it — otherwise the link stalls before `` is ever sent. * **Manual:** with no authenticator registered, the host drives every step from the three `Event::PairPasskey*` events (see below) and calls `send_passkey_response` / `send_passkey_confirmation` itself. ### Implementation ```rust theme={null} use std::sync::Arc; use wacore::types::events::Event; use whatsapp_rust::passkey::CallbackAuthenticator; // Automatic: register an authenticator once. Re-links finish on their own; a fresh // link still needs you to show the code from PairPasskeyConfirmation and call // send_passkey_confirmation() once the user approves it (see the Manual example below). client.set_passkey_authenticator(Arc::new(CallbackAuthenticator::new(my_get_assertion))).await; ``` ```rust theme={null} // Manual: drive each step from the events yourself. .on_event(|event, client| async move { match &*event { Event::PairPasskeyRequest(req) => { let assertion = my_get_assertion_from_json(&req.request_options_json).await?; client.send_passkey_response(assertion).await?; } Event::PairPasskeyConfirmation(conf) => { if conf.skip_handoff_ux { client.send_passkey_confirmation().await?; } else { println!("Confirm {} matches on your phone, then continue", conf.code); // ...await user confirmation, then: client.send_passkey_confirmation().await?; } } Event::PairPasskeyError(err) => { eprintln!("Passkey link failed (continuation={}): {}", err.continuation, err.error); } Event::PairSuccess(info) => println!("Paired as {}", info.id), _ => {} } }) ``` ### Passkey events ```rust theme={null} // wacore/src/types/events.rs Event::PairPasskeyRequest(PairPasskeyRequest { request_options_json: String, // verbatim PublicKeyCredentialRequestOptions JSON }) Event::PairPasskeyConfirmation(PairPasskeyConfirmation { code: String, // 8-char "XXXX-XXXX" verification code skip_handoff_ux: bool, // true on a proven re-link: no need to show the code }) Event::PairPasskeyError(PairPasskeyError { error: String, continuation: bool, // false = failed during the initial request, true = during continuation }) ``` Linking completes through the ordinary [`PairSuccess`/`PairError`](#success-events) events — there is no separate "passkey success" event. `PairPasskeyRequest`, `PairPasskeyConfirmation`, and `PairPasskeyError` are `#[non_exhaustive]`, sealed with a `bon` builder (e.g. `PairPasskeyRequest::builder().request_options_json(json).build()`). Field access by name (`req.request_options_json`) is unaffected; only an exhaustive struct-pattern destructure would need a `..` rest. ### Client methods | Method | Purpose | | -------------------------------------------------------------- | ----------------------------------------------------------------------------- | | `set_passkey_authenticator(Arc)` | Register an authenticator to auto-drive the flow | | `send_passkey_response(Assertion) -> Result<(), PasskeyError>` | Call after `PairPasskeyRequest`, with the obtained assertion | | `send_passkey_confirmation() -> Result<(), PasskeyError>` | Call after `PairPasskeyConfirmation` (or automatically, for a proven re-link) | See [Client API — Connection Management](/api/client#set_passkey_authenticator) for full signatures and error variants. ## Cryptography ### Noise protocol handshake whatsapp-rust supports three Noise patterns to mirror WhatsApp Web: | Pattern | When it runs | Round trips | | -------------------- | ---------------------------------------------------------------------------- | ----------------------------------- | | **Noise XX** | Cold start, after pairing, or any reconnect with no cached server cert chain | 1.5 (3 messages) | | **Noise IK** | Reconnect with a valid cached `server_cert_chain` | 0.5 (2 messages) | | **Noise XXfallback** | Server-driven recovery when an IK attempt's cached server static is stale | 0.5 (continues from IK ClientHello) | ```rust theme={null} // wacore/noise/src/handshake.rs pub struct XxHandshakeState { /* ... */ } // Noise_XX_25519_AESGCM_SHA256 pub struct IkHandshakeState { /* ... */ } // Noise_IK_25519_AESGCM_SHA256 pub struct XxFallbackHandshakeState { /* ... */ } // Resumes the IK transcript as XX pub enum IkServerHelloOutcome { Continue(Box), // Cached static accepted Fallback(Box), // Pivot into XXfallback } ``` **XX flow (cold start):** 1. Initiator → Responder: ephemeral pub 2. Responder → Initiator: ephemeral pub, static pub, encrypted payload (cert chain) 3. Initiator → Responder: encrypted static pub, encrypted payload The verified `server_cert_chain` is persisted at the end of XX so the next connect can use IK. **IK flow (resumed):** 1. Initiator → Responder: ephemeral pub, encrypted static, encrypted 0-RTT payload (built against the cached server static) 2. Responder → Initiator: ephemeral pub, encrypted payload — handshake is complete after this single round trip. If the server's static no longer matches the cached value, step 2 returns an `IkServerHelloOutcome::Fallback(...)` and the client pivots to **XXfallback** in-place — without dropping the connection — finishing as if it had been XX from the start. After a single crypto-fatal IK failure the client clears the cached cert chain (`DeviceCommand::ClearServerCertChain`), increments a process-local failure counter, and forces XX on the next connect. See [WebSocket & Noise Protocol — Noise Protocol Handshake](/advanced/websocket-handling#noise-protocol-handshake) for the full state machine. ### ClientProfile **Location:** `wacore/src/client_profile.rs` `ClientProfile` is the identity that gets baked into `ClientPayload.UserAgent` during the Noise handshake. It controls the `platform`, `device`, `os_version`, `os_build_number`, `manufacturer` fields, and whether `web_info` is attached to the payload. It is independent of `DeviceProps` — `device_props` describes the companion entry on the phone, while `ClientProfile` describes the client identity to WhatsApp's server during the handshake itself. The two can be set independently. ```rust theme={null} // wacore/src/client_profile.rs pub struct ClientProfile { pub user_agent_platform: wa::client_payload::user_agent::Platform, pub device: String, pub os_version: String, pub manufacturer: String, pub include_web_info: bool, pub passive_login: bool, // ClientPayload.passive pub phone_id: Option, // UserAgent.phone_id (anti-abuse UUID) pub locale_language: String, // UserAgent.locale_language_iso6391, e.g. "pt" pub locale_country: String, // UserAgent.locale_country_iso31661_alpha2, e.g. "BR" } ``` Since v0.6 the locale and `phone_id` come from the active `ClientProfile` instead of being hard-coded. The locale is split into two ISO fields — `locale_language` (ISO-639-1, e.g. `"en"`) and `locale_country` (ISO-3166-1 alpha-2, e.g. `"US"`) — both written to the matching `UserAgent` proto attributes. When `phone_id` is `None` the client builds a **fresh UUID-v4 on every `ClientPayload` build**; it is not persisted on `Device`, so if you need a stable WA Web–style `WAWebClientPayload.phoneId` you must supply it yourself (e.g. generate once at install time and pass it in via your own `ClientProfile` constructor). Login counter (`ClientPayload.lc`) lives on `Device`, not here — see the [Login counter](#login-counter-clientpayloadlc) section below. `passive_login` mirrors WA Web's `ClientPayload.passive`: `false` (the default) tells the server to deliver queued offline messages on connect, `true` keeps the connection passive until you pull explicitly. #### Built-in profiles | Constructor | Platform | Device | Manufacturer | `web_info` | | ---------------------------------------- | ------------ | ------------ | ------------ | ---------- | | `ClientProfile::web()` (default) | `Web` | `Desktop` | `""` | included | | `ClientProfile::android(os_version)` | `Android` | `Smartphone` | `""` | omitted | | `ClientProfile::smb_android(os_version)` | `SmbAndroid` | `Smartphone` | `""` | omitted | | `ClientProfile::ios(os_version)` | `Ios` | `iPhone` | `Apple` | omitted | | `ClientProfile::macos(os_version)` | `Macos` | `Desktop` | `Apple` | omitted | | `ClientProfile::windows(os_version)` | `Windows` | `Desktop` | `""` | omitted | The `web()` profile reproduces the legacy desktop-web payload (`os_version` and `os_build_number` are both `"0.1.0"`). Native profiles propagate the supplied `os_version` to both fields and drop `web_info`. #### Setting a profile `Device.client_profile` is `#[serde(skip)]`, so it is never persisted. Set it on every fresh process before calling [`connect()`](/api/client#connect): ```rust theme={null} use whatsapp_rust::ClientProfile; client.set_client_profile(ClientProfile::android("13")).await; client.connect().await?.read_until_disconnected().await; ``` Internally this dispatches `DeviceCommand::SetClientProfile(profile)` through the persistence manager (see [State Management](/advanced/state-management#deviceCommand-pattern)). ### Key Derivation **For QR Code:** ```rust theme={null} // Direct key exchange - keys in QR code ``` **For Pair Code:** ```rust theme={null} // wacore/src/pair_code.rs // Expensive PBKDF2 operation (wrapped in spawn_blocking) let wrapped_ephemeral = tokio::task::spawn_blocking(move || { PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, &code_clone) }).await?; ``` **Parameters:** * **Algorithm:** AES-256-CBC * **KDF:** PBKDF2-HMAC-SHA256 * **Iterations:** 2^16 (65,536) * **Salt:** 16 random bytes * **IV:** 16 random bytes ### Signal protocol setup **After pairing:** 1. Server sends signed device identity 2. Companion verifies signature 3. Identity keys exchanged 4. Pre-keys registered ```rust theme={null} // src/pair.rs:188-209 let result = PairUtils::do_pair_crypto(&device_state, &device_identity_bytes); match result { Ok((self_signed_identity_bytes, key_index)) => { // Store device JID, LID, account info client.persistence_manager .process_command(DeviceCommand::SetId(Some(jid.clone()))) .await; client.persistence_manager .process_command(DeviceCommand::SetAccount(Some(signed_identity))) .await; } Err(e) => { // Send error to server } } ``` ### Login counter (`ClientPayload.lc`) Since v0.6 the client persists a `login_counter` on `Device`. Every successful connect increments it and the new value is sent as `ClientPayload.lc` during the Noise handshake. This mirrors WA Web's anti-abuse signal — the server uses the counter to spot replayed or cloned `ClientPayload`s. The counter resets when you call `logout()` or wipe device state. ### One-to-one LID migration state Fixes [#941](https://github.com/oxidezap/whatsapp-rust/issues/941): some accounts are not yet **1:1-LID-migrated** on WhatsApp's servers, and those accounts get every LID-addressed DM rejected with `ack error="400"`. The client tracks this account-level state in a persisted `Device.lid_migrated` flag so it can keep DM wire addressing on PN until the account has actually migrated — see [Signal Protocol — DM wire namespace vs. Signal session addressing](/advanced/signal-protocol#dm-wire-namespace-vs-signal-session-addressing). The flag is set from two sources, both mirroring WA Web's `WAIsAccountLidFieldMigrated` pref: 1. **Pair-success ``.** The primary attaches an optional `` child to `pair-success` carrying `ClientPairingProps.isChatDbLidMigrated`. `PairUtils::extract_pairing_props()` decodes it; a malformed or absent payload is treated the same as "not reported" and never fails the pairing itself. 2. **`lid_migration_mapping_sync_message`.** The primary can later push a `ProtocolMessage.lid_migration_mapping_sync_message` to its own companions (mirroring WA Web's `setLidMigrationMappings`) carrying newly-assigned PN↔LID pairs. The client learns the mappings and, once the `lid_one_on_one_migration_enabled` ab prop allows it, persists the account as migrated. This message is honored **only from `is_from_me` sources** — a peer-sent copy is dropped, since accepting it would let a peer poison the LID-PN cache and flip your own account's wire addressing. ```rust theme={null} // wacore/src/pair.rs — pair-time write decision pub fn lid_migrated_update(props_migrated: bool, account_changed: bool) -> Option { if props_migrated { Some(true) } else if account_changed { // A different account must not inherit the previous account's state. Some(false) } else { // Same-account relink whose pair-success omitted client-props: // preserve whatever is already stored. None } } ``` Once set, the flag never reverts for the same account (like the WA Web pref) — it can only be reset to `false` when a *different* account is paired onto the same store. Mutations go through `DeviceCommand::SetLidMigrated(bool)` (see [State Management](/advanced/state-management#devicecommand-pattern)). ## Concurrent Pairing Both methods can run simultaneously: ```rust theme={null} // Start QR code (automatic on connection) bot.run().await?; // Also start pair code in parallel let code = client.pair_with_code(options).await?; ``` **State Management:** ```rust theme={null} // src/client.rs pub(crate) pairing_cancellation_tx: Mutex>>, pub(crate) pair_code_state: Mutex, ``` **Cancellation:** ```rust theme={null} // src/pair.rs:140-149 async fn handle_pair_success(...) { // Cancel QR code rotation if active if let Some(tx) = client.pairing_cancellation_tx.lock().await.take() { let _ = tx.try_send(()); debug!("Sent QR rotation stop signal"); } else { // is_logged_in guard will stop the task even without the channel debug!("QR rotation channel not yet stored — is_logged_in guard will stop the task"); } // Clear pair code state if active *client.pair_code_state.lock().await = PairCodeState::Completed; } ``` The `is_logged_in()` safety guard in the rotation loop acts as a fallback — even if the cancellation channel hasn't been stored yet (race condition on fast pairing), the rotation task will exit cleanly on its next iteration. ## Success Events ### PairSuccess ```rust theme={null} // wacore/src/types/events.rs #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairSuccess { pub id: Jid, // Device JID (e.g., "15551234567.0:1@s.whatsapp.net") pub lid: Jid, // LID JID (e.g., "100000012345678.0:1@lid") pub business_name: String, // Push name / business name pub platform: String, // Platform identifier } Event::PairSuccess( PairSuccess::builder() .id(id) .lid(lid) .business_name(business_name) .platform(platform) .build(), ) ``` ### PairError ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairError { pub id: Jid, pub lid: Jid, pub business_name: String, pub platform: String, pub error: String, // Error description } Event::PairError(PairError::builder() /* .id(..).lid(..)… */ .build()) ``` Breaking change: `PairSuccess`, `PairError`, and `LoggedOut` (below) are now `#[non_exhaustive]` and sealed with a `bon` builder. A struct literal from outside `wacore`/`whatsapp-rust` no longer compiles — construct via `Type::builder()…build()`, and add a `..` rest to any destructuring pattern. ## Error Handling ### QR code errors QR codes are handled internally and retried automatically. If all refs expire, the client dispatches [`Event::PairingQrCodesExhausted`](/concepts/events#pairingqrcodesexhausted) and disconnects **only when no pair-code flow is outstanding** — see [QR ref exhaustion](#qr-ref-exhaustion). ### Pair code errors `pair_with_code` returns `whatsapp_rust::pair_code::PairError`, which wraps the wacore-side validation/crypto errors (`PairCodeError`) and the IQ transport layer (`IqError`): ```rust theme={null} use whatsapp_rust::pair_code::PairError; use wacore::pair_code::PairCodeError; match client.pair_with_code(options).await { Ok(code) => println!("Code: {}", code), Err(PairError::PairCode(PairCodeError::PhoneNumberRequired)) => { eprintln!("Phone number is required"); } Err(PairError::PairCode(PairCodeError::PhoneNumberTooShort)) => { eprintln!("Phone number must be at least 7 digits"); } Err(PairError::PairCode(PairCodeError::PhoneNumberNotInternational)) => { eprintln!("Phone number must not start with 0 (use international format)"); } Err(PairError::PairCode(PairCodeError::InvalidCustomCode)) => { eprintln!("Custom code must be 8 valid Crockford Base32 characters"); } Err(PairError::PairCode(PairCodeError::CodeAlreadyOutstanding { remaining })) => { // `remaining` reads `0` while a pair-success is pending on an // already-entered code, not "no time left before you may retry". eprintln!("A code is already outstanding ({remaining:?} left in its validity window)"); } Err(PairError::PairCode(PairCodeError::Cancelled)) => { eprintln!("cancel_pair_code() was called while companion_hello was in flight"); } Err(PairError::PairCode(PairCodeError::MissingPairingRef)) => { eprintln!("Server did not return a pairing reference"); } Err(PairError::PairCode(PairCodeError::NotWaiting)) => { eprintln!("No pending pair code request"); } Err(PairError::PairCode(PairCodeError::InvalidWrappedData { expected, got })) => { eprintln!("Invalid wrapped data: expected {} bytes, got {}", expected, got); } // Typed crypto failures preserve their `CurveError`/`CryptoProviderError` source Err(PairError::PairCode(PairCodeError::InvalidPrimaryEphemeralKey(e))) => { eprintln!("Primary device sent an invalid ephemeral key: {e}"); } Err(PairError::PairCode(PairCodeError::InvalidPrimaryIdentityKey(e))) => { eprintln!("Primary device sent an invalid identity key: {e}"); } Err(PairError::PairCode(PairCodeError::EphemeralKeyAgreement(e))) => { eprintln!("Ephemeral DH failed: {e}"); } Err(PairError::PairCode(PairCodeError::IdentityKeyAgreement(e))) => { eprintln!("Identity DH failed: {e}"); } Err(PairError::PairCode(PairCodeError::AdvSecretKeyDerivation)) => { eprintln!("HKDF expand for adv_secret failed"); } Err(PairError::PairCode(PairCodeError::BundleKeyDerivation)) => { eprintln!("HKDF expand for bundle encryption key failed"); } Err(PairError::PairCode(PairCodeError::BundleAead(e))) => { eprintln!("AES-GCM encryption of key bundle failed: {e}"); } Err(PairError::RequestFailed(iq)) => { eprintln!("Pair-code IQ request failed: {iq}"); } // Both `PairError` and `PairCodeError` are `#[non_exhaustive]`, so a // wildcard arm is required from outside the crate — `PairCodeError` // joined the other 3 that were missing the attribute in PR #1100. _ => {} } ``` `CodeAlreadyOutstanding` and `Cancelled` are new — see [One code at a time](#one-code-at-a-time). `CodeAlreadyOutstanding` means `pair_with_code` refused to supersede a code that is still outstanding — either within its validity window, or awaiting a `pair-success` after an accepted `primary_hello`; `Cancelled` means `cancel_pair_code()` withdrew the request while stage 1 (`companion_hello`) was in flight. Prefer [`PairError::rejection()`](#pair-code-failure-events) to matching on `bad-request` by string: the server reuses `PairCodeRejection::BadRequest` (400) for both malformed requests **and** its per-phone-number rate limit, and the two are indistinguishable on the wire. `PairCodeRejection::is_throttled()` covers this — it's `true` for both `BadRequest` and `RateOverlimit` — so back off and retry rather than treating every 400 as fatal. By default, the library canonicalizes `companion_platform_display`'s OS (see [`companion_platform_display`](#companion-platform-display)), so display-shaped rejections are generally ruled out unless you explicitly bypass canonicalization via `PairCodeOptions::display_os`. Any server `backoff` hint is preserved on the wrapped `IqError::ServerError` (see [Error Types](/api/errors#iqerror-base-type)) and surfaced directly via `PairError::backoff()`. `PairError::RequestFailed`'s `Display` now renders exactly what it wraps (`{0}`) instead of the fixed string `"pair-code IQ request failed"` — so a log line that only prints the error (`{e}`) still shows the server's code and text, e.g. `429 (rate-overlimit)`. The previous catch-all `CryptoError(String)` and `RequestFailed(String)` variants have been split into typed variants that preserve their underlying source. Match on `std::error::Error::source()` (or downcast it) to inspect the inner `CurveError`, `CryptoProviderError`, or `IqError`. ## Session Persistence ### After successful pairing **State saved to storage:** * Device JID (Phone Number) * LID (Long-term Identifier) * Identity keys * Noise keys * Registration ID * Push name **Next connection:** ```rust theme={null} // No pairing needed - automatic reconnection let bot = Bot::builder() .with_backend(backend) .with_transport_factory(TokioWebSocketTransportFactory::new()) .with_http_client(UreqHttpClient::new()) .with_runtime(TokioRuntime) .build() .await?; bot.run().await?; // Uses saved session ``` ### Logout ```rust theme={null} // Clear session data. Infallible as of PR #1090 — the deregistration IQ is // best-effort and the local teardown runs either way. client.logout().await; // Event emitted: Event::LoggedOut( LoggedOut::builder() .on_connect(false) .reason(ConnectFailureReason::LoggedOut) .build(), ) ``` ## Best Practices ### Phone number format ```rust ✅ Correct theme={null} let options = PairCodeOptions { phone_number: "15551234567".to_string(), // International format // Non-digits automatically stripped: // phone_number: "+1-555-123-4567".to_string(), ..Default::default() }; ``` ```rust ❌ Wrong theme={null} let options = PairCodeOptions { phone_number: "0551234567".to_string(), // Don't start with 0 phone_number: "5551234567".to_string(), // Missing country code ..Default::default() }; ``` ### Event Handling ```rust theme={null} .on_event(|event, client| async move { match &*event { Event::PairingQrCode(PairingQrCode { code, timeout, .. }) => { // Display QR to user println!("Valid for: {}s", timeout.as_secs()); } Event::PairingCode(PairingCode { code, timeout, .. }) => { // Display code to user println!("Enter {} on your phone", code); } Event::PairingCodeRefresh(PairingCodeRefresh { force_manual, .. }) => { // Previous code is no longer guaranteed valid — request a new one println!("Refresh requested (force_manual={})", force_manual); } Event::PairSuccess(info) => { // Save success notification println!("Paired: {}", info.id); } Event::PairError(err) => { // Handle error eprintln!("Pairing failed: {}", err.error); } _ => {} } }) ``` ### Concurrent Usage ```rust theme={null} // Both methods active - whichever completes first wins tokio::spawn(async move { if let Ok(code) = client.pair_with_code(options).await { println!("Pair code: {}", code); } }); // QR codes automatically generated and rotated bot.run().await?; ``` ## Related Sections Understand the project structure Learn about all event types Explore session persistence Build your first bot # Events Source: https://whatsapp-rust.jlucaso.com/concepts/events Event system, event handlers, and Event enum types in whatsapp-rust ## Overview WhatsApp-Rust uses an event-driven architecture where the client emits events for all WhatsApp protocol interactions. Your application subscribes to these events to handle messages, connection changes, and notifications. ## Event system architecture ### CoreEventBus **Location:** `wacore/src/types/events.rs` ```rust theme={null} #[derive(Default, Clone)] pub struct CoreEventBus { handlers: Arc>>>, } impl CoreEventBus { pub fn dispatch(&self, event: Event) { let handlers = self.handlers.read().expect("...").clone(); if handlers.is_empty() { return; } let event = Arc::new(event); for handler in &handlers { handler.handle_event(Arc::clone(&event)); } } pub fn has_handlers(&self) -> bool { !self.handlers.read().expect("...").is_empty() } } ``` **Features:** * Thread-safe event dispatching via `Arc` — each event is wrapped once and shared across all handlers, eliminating deep clones * Multiple handlers supported * Clone-cheap with `Arc` ### EventHandler Trait ```rust theme={null} pub trait EventHandler: Send + Sync { fn handle_event(&self, event: Arc); /// Which event kinds this handler wants. Defaults to all kinds. /// Override to let the bus skip materializing events you don't want. fn interest(&self) -> EventInterest { EventInterest::ALL } } ``` Handlers receive `Arc` — a shared reference-counted pointer to the event. Since `Arc` implements `Deref`, you can pattern-match on it directly. **Implementation:** ```rust theme={null} use wacore::types::events::{Event, InboundMessage}; use std::sync::Arc; struct MyHandler; impl EventHandler for MyHandler { fn handle_event(&self, event: Arc) { match &*event { Event::Messages(batch) => { for InboundMessage { message: msg, info, .. } in batch.iter() { println!("Message from {}: {:?}", info.source.sender, msg); } } _ => {} } } } client.register_handler(Arc::new(MyHandler)); ``` ### Typed event interest (skip boxing unwanted events) By default a handler receives every event. If you only care about a few kinds, override `interest()` so the event bus skips building and dispatching the kinds nobody wants — for high-throughput events (presence, receipts) this avoids the per-event `Arc` allocation entirely. ```rust theme={null} use wacore::types::events::{EventInterest, EventKind}; impl EventHandler for MyHandler { fn handle_event(&self, event: Arc) { /* … */ } fn interest(&self) -> EventInterest { // Only Messages and Connected events reach this handler. EventInterest::of(&[EventKind::Messages, EventKind::Connected]) } } ``` * **`EventKind`** is a `#[repr(u8)]` discriminant — one variant per `Event` variant (`Messages`, `Connected`, `Receipt`, …). The enum is `#[non_exhaustive]`, so `match` blocks on `EventKind` must include a wildcard arm (`_ => …`); new kinds may be added in minor releases as the library tracks new server events. * **`EventKind::CAPACITY`** is a public `u8` constant (currently `128`) that bounds the number of kinds. It exists because each discriminant is packed as a bit in `EventInterest`'s `u128` mask, and a future variant that would overflow it fails compilation rather than silently corrupting the mask at runtime. Treat it as a read-only ceiling — you don't need to check it at runtime. * **`EventInterest`** is a 128-bit set of kinds. Build it with `EventInterest::of(&[…])`, `EventInterest::ALL` (the default), `EventInterest::none()`, or chain `.with(kind)`. Query it with `.wants(kind)`. * The bus exposes `has_handler_for(kind)` and only produces an event when at least one registered handler wants its kind. `EventInterest` was widened from a `u64` to a `u128` mask (and `EventKind::CAPACITY` from `64` to `128`) as part of the pre-1.0 event-payload API freeze, since the kind count had reached 58/64. The public surface (`EventInterest::of`, `.with(kind)`, `.wants(kind)`, `EventInterest::ALL`) is unchanged — only the internal bit width doubled, giving headroom for future event kinds. With the [`Bot`](/api/bot) builder, the same narrowing is available via `on_event_for`: ```rust theme={null} bot.on_event_for(&[EventKind::Messages], |event, client| async move { // only Messages events }); ``` `on_event` (without kinds) keeps subscribing to everything. ## Event Enum **Location:** `wacore/src/types/events.rs` ```rust theme={null} #[derive(Debug, Clone, Serialize)] #[non_exhaustive] pub enum Event { // Connection Connected(Connected), Disconnected(Disconnected), StreamReplaced(StreamReplaced), StreamError(StreamError), ConnectFailure(ConnectFailure), TemporaryBan(TemporaryBan), // Pairing PairingQrCode(PairingQrCode), PairingCode(PairingCode), PairingCodeRefresh(PairingCodeRefresh), PairingCodeError(PairingCodeError), PairingQrCodesExhausted(PairingQrCodesExhausted), PairSuccess(PairSuccess), PairError(PairError), QrScannedWithoutMultidevice(QrScannedWithoutMultidevice), ClientOutdated(ClientOutdated), LoggedOut(LoggedOut), // Messages Messages(MessageBatch), Receipt(Receipt), UndecryptableMessage(UndecryptableMessage), Notification(Arc), ServerAck(ServerAck), // Presence ChatPresence(ChatPresenceUpdate), Presence(PresenceUpdate), // User Updates PictureUpdate(PictureUpdate), UserAboutUpdate(UserAboutUpdate), RetiredPushNameUpdate(RetiredPushNameUpdate), SelfPushNameUpdated(SelfPushNameUpdated), // Group Updates GroupUpdate(GroupUpdate), // Contact Updates ContactUpdated(ContactUpdated), ContactNumberChanged(ContactNumberChanged), ContactSyncRequested(ContactSyncRequested), ContactUpdate(ContactUpdate), ContactRemoved(ContactRemoved), // Chat State PinUpdate(PinUpdate), MuteUpdate(MuteUpdate), ArchiveUpdate(ArchiveUpdate), StarUpdate(StarUpdate), MarkChatAsReadUpdate(MarkChatAsReadUpdate), DeleteChatUpdate(DeleteChatUpdate), ClearChatUpdate(ClearChatUpdate), UserStatusMuteUpdate(UserStatusMuteUpdate), DeleteMessageForMeUpdate(DeleteMessageForMeUpdate), LabelEditUpdate(LabelEditUpdate), LabelAssociationUpdate(LabelAssociationUpdate), MessageLabelAssociationUpdate(MessageLabelAssociationUpdate), QuickReplyUpdate(QuickReplyUpdate), // History Sync HistorySync(Box), OfflineSyncPreview(OfflineSyncPreview), OfflineSyncCompleted(OfflineSyncCompleted), OfflineSyncInterrupted(OfflineSyncInterrupted), DirtyState(DirtyState), AppStateSyncFailed(AppStateSyncFailed), ClientExpirationChanged(ClientExpirationChanged), // Device Updates DeviceListUpdate(DeviceListUpdate), IdentityChange(IdentityChange), BusinessStatusUpdate(BusinessStatusUpdate), // Newsletter NewsletterLiveUpdate(NewsletterLiveUpdate), // Calls IncomingCall(Box), // Notification Updates DisappearingModeChanged(DisappearingModeChanged), // Privacy DisableLinkPreviewsUpdate(DisableLinkPreviewsUpdate), // Raw stanza (opt-in) RawNode(Arc), // Passkey linking (SHORTCAKE_PASSKEY) PairPasskeyRequest(PairPasskeyRequest), PairPasskeyConfirmation(PairPasskeyConfirmation), PairPasskeyError(PairPasskeyError), // Decrypted payload (opt-in) DecryptedPayload(DecryptedPayload), // Sent frame (opt-in) SentFrame(SentFrame), // Enc decrypt failure (opt-in) EncDecryptFailed(EncDecryptFailed), } ``` The `Event` enum is `#[non_exhaustive]`, so your `match` statements must include a wildcard arm (`_ => {}`). New variants may be added in minor releases without a breaking change. **Payload stability:** every event payload struct is sealed with `#[non_exhaustive]` plus a [`bon`](https://docs.rs/bon) builder for construction (`Type::builder()…build()`), so a payload can gain fields later without breaking consumers. The freeze rolled out in stages. `ServerAck` went first, then the notification/presence/contact/group payloads and the app-state-sync mutation payloads, then the remaining message/newsletter/device/pairing payloads and three more unit-marker events. At the time, each of those three was an empty sealed struct built as `Connected::builder().build()`: `Connected`, `QrScannedWithoutMultidevice`, `StreamReplaced`. The rollout is now complete across the whole `Event` surface. Two unit-marker events have since gained a field and stopped being empty: `ClientOutdated` gained `raw` (see [ClientOutdated](#clientoutdated) below), and `Connected` gained `app_version_fallback` in PR #1360 (see [Connected](#connected) below). `QrScannedWithoutMultidevice` and `StreamReplaced` are the two still empty today. Read the fields you need (e.g. `ack.class`) or keep a `..` rest when destructuring (required for a `#[non_exhaustive]` struct pattern from outside the defining crate — e.g. `InboundMessage { message, info, .. }`), rather than binding every field. A maybe-absent field is always modeled as `Option` (with a `maybe_*` builder setter), never an empty-string or zero sentinel. The library itself constructs every payload via its builder — a struct literal from outside `wacore`/`whatsapp-rust` no longer compiles (`E0639`). ## Connection Events ### Connected **Emitted:** After the session is authenticated and has asked the server to leave passive mode. On a fresh pairing (and on any reconnect before the account's critical app-state collections have synced), the client waits for the critical app-state sync to produce an answer first — see [Critical app-state sync (pairing bootstrap)](/concepts/architecture#critical-app-state-sync-pairing-bootstrap) — so the push name and blocklist are normally in place by the time this fires. As of PR #1291, it waits but does not withhold: a critical collection the server refused or couldn't deliver is reported via [`AppStateSyncFailed`](#appstatesyncfailed) and `Connected` fires regardless, since a session already delivering messages shouldn't leave a consumer believing nothing ever connected. ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct Connected { pub app_version_fallback: Option, } Event::Connected(Connected::builder().build()) ``` **Fields:** * `app_version_fallback` — added in PR #1360; additive, so an existing `Connected::builder().build()` still compiles (`app_version_fallback` defaults to `None`). Present when this connect could not resolve the app version from its source and settled for the version the device already held instead. Absent on every normal connect, so `Some` is the whole signal. In practice this only fires on the `wasm32` target. The native build's source is `web.whatsapp.com/sw.js`. A client that can't reach it treats that as a real break of WhatsApp Web itself, and fails `connect()` with `ConnectError::Version` instead of falling back — see [`ConnectError`](/api/errors#connecterror). The browser build's source is the Facebook JS SDK bundle at `connect.facebook.net`, which sits on common tracker blocklists (uBlock Origin, Brave shields, corporate DNS). A client blocked from reaching it connects anyway, on the version it already has, rather than refusing to connect over what is, for a large share of browser users, an ad blocker doing its job. ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct AppVersionFallback { pub version: (u32, u32, u32), pub compiled_default: bool, pub reason: AppVersionFallbackReason, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[non_exhaustive] pub enum AppVersionFallbackReason { /// The source refused the request or never answered — e.g. a blocked domain. SourceUnreachable, /// The source answered, but the version was no longer where it should be. SourceUnparsable, } ``` * `AppVersionFallback::version` — the version this session actually connected with. * `AppVersionFallback::compiled_default` — `true` when that version is the one compiled into the library. The payload carries no resolution timestamp for it, so treat the release's age as a lower bound on its staleness, not the exact figure — the bundled revision could already have been behind current WhatsApp Web when this release was cut. `false` when the device had already resolved a version on an earlier connect, so its staleness is only however long it's been since that version was last successfully resolved — that's the length of this outage only if the previous connect is what resolved it; a longer-running outage across several connects makes it older still. (A `with_version` call on *this* connect never produces a fallback at all — see the [`with_version` note](/api/bot#with_version) — but a device that carried an override from a past connect and later drops it can still hit this path, and would correctly report `false` here too.) * `AppVersionFallback::reason` — `SourceUnreachable` is the routine case: a blocked or offline source. It often clears on a later connect once the block or outage lifts, though a durable blocklist entry can outlast that — this alone doesn't guarantee recovery. `SourceUnparsable` means the source answered but the bundle no longer carries the field the parser looks for, which points at the source having changed shape rather than a transient condition; a later connect *can* still resolve it, e.g. if the change reverts, but it's the more likely one to need attention (a version pin via `with_version`, or a library update). The server tolerates an app version some days behind current, which is what makes connecting on a fallback a real option rather than a guess. A consumer that can't accept a stale version, or wants its own policy, can check `app_version_fallback` on `Connected` and warn, refuse the session, or pin a version of its own with [`with_version`](/api/bot#with_version). Leaving passive mode is best effort: a failed `set_passive(false)` call is only logged, not retried, and the connection is announced anyway. Treat `Connected` as "the client believes stanzas should be flowing," not a guarantee the server agrees. **Usage:** ```rust theme={null} Event::Connected(connected) => { println!("✅ Connected to WhatsApp"); if let Some(fallback) = &connected.app_version_fallback { eprintln!( "connected on a fallback app version {:?} ({:?})", fallback.version, fallback.reason ); } // Safe to send messages now. The push name and blocklist may still be // syncing in the background — watch for AppStateSyncFailed if that gap // matters to your use case. } ``` ### Disconnected **Emitted:** When the connection ends without the client itself intentionally closing or reconnecting it — covers both a routine server-initiated stream recycle and a genuine transport failure (see `reason` below to tell them apart) ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct Disconnected { pub reason: DisconnectReason, } Event::Disconnected(Disconnected::builder().reason(reason).build()) ``` **Fields:** * `reason: DisconnectReason` — why the transport ended. Check `reason.is_clean_shutdown()` to tell a routine server-initiated stream recycle (WhatsApp's normal reconnect path) apart from a genuine transport failure, without parsing logs. See [`DisconnectReason`](/api/transport#disconnected) for the variants. **Behavior:** Client automatically attempts reconnection Breaking change: `Disconnected` gained the `reason` field (previously a unit struct). `Disconnected` is now `#[non_exhaustive]` too, so a destructuring pattern needs a `..` rest: `Event::Disconnected(Disconnected { reason, .. })`, or just `Event::Disconnected(_)`. ### ConnectFailure **Emitted:** When connection fails with a specific reason ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct ConnectFailure { pub reason: ConnectFailureReason, /// The server's `message` attribute on the `` stanza, when present. pub message: Option, pub raw: Option, } #[derive(Debug, Clone, PartialEq, Eq, Copy, Serialize)] pub enum ConnectFailureReason { Generic, // 400 LoggedOut, // 401 TempBanned, // 402 AccountLocked, // 403 — WA Web REASON_LOCKED (account/device locked) UnknownLogout, // 406 ClientOutdated, // 405 BadUserAgent, // 409 CatExpired, // 413 CatInvalid, // 414 NotFound, // 415 ClientUnknown, // 418 InternalServerError, // 500 Experimental, // 501 ServiceUnavailable, // 503 Unknown(i32), } ``` Breaking change: `ConnectFailure.message` changed from `String` (empty-string sentinel when the server omitted the `message` attribute) to `Option`, matching the "maybe-absent field is always `Option`" convention. `unwrap_or_default()` at a call site becomes `.unwrap_or_default()` on the `Option` (same fallback) or, better, `match`/`if let Some(msg) = &failure.message`. **Helper methods:** ```rust theme={null} if reason.is_logged_out() { // Clear session and re-pair } if reason.should_reconnect() { // Retry connection } ``` The 403 variant was renamed `MainDeviceGone` → `AccountLocked` in v0.6 to match WA Web's `REASON_LOCKED` semantics (the account/device is locked server-side; a manual unlink arrives as a different reason). It still maps from wire code 403 and reports `is_logged_out() == true` with no auto-reconnect. Update any `match` arms referencing the old name. ### TemporaryBan **Emitted:** When account is temporarily banned ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct TemporaryBan { pub code: TempBanReason, /// How long the ban lasts — the wire's `expire` is a duration in seconds, /// not a deadline. pub expire: chrono::Duration, /// The server's `message` attribute, when present. pub message: Option, /// Support/appeal link the official ban screen opens. pub url: Option, /// The whole `` stanza. pub raw: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum TempBanReason { SentToTooManyPeople, // 101 BlockedByUsers, // 102 CreatedTooManyGroups, // 103 SentTooManySameMessage, // 104 BroadcastList, // 106 Unknown(i32), } ``` **Fields:** * `code` — the ban sub-reason from the `code` attribute * `expire` — how long the ban lasts, as a `chrono::Duration` * `message` — the server's free-text detail, when present * `url` — the support/appeal link the official ban screen opens, when the server sent one * `raw` — the whole `` stanza Breaking change: `TemporaryBan` gained `message`, `url` and `raw`. It also gained a stricter emission rule: a `` missing `code` or `expire` — or whose `expire` doesn't fit a `chrono::Duration` — no longer dispatches `TemporaryBan` with an invented zero expiry. Such a stanza now surfaces as `ConnectFailure { reason: ConnectFailureReason::TempBanned, raw: Some(node), .. }` instead, carrying the same raw stanza. A consumer that matched on `Event::TemporaryBan` to detect an incomplete ban stanza will now see `Event::ConnectFailure`. **Usage:** ```rust theme={null} Event::TemporaryBan(ban) => { eprintln!("Banned: {} (expires in {:?})", ban.code, ban.expire); if let Some(url) = &ban.url { eprintln!("Learn more: {url}"); } } ``` ### StreamReplaced **Emitted:** When another device connects with the same credentials (stream error code 409 or ``) ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct StreamReplaced {} ``` **Usage:** ```rust theme={null} Event::StreamReplaced(_) => { println!("⚠️ Another instance connected - disconnecting"); // Auto-reconnect is disabled — reconnecting would displace the other client } ``` **Behavior:** Auto-reconnect is disabled. The client stops permanently. ### LoggedOut **Emitted:** When the session is invalidated by the server (stream error code 401 or 516) or when `client.logout()` is called ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct LoggedOut { pub on_connect: bool, pub reason: ConnectFailureReason, /// Server-supplied logout copy, when it sent any. Present in practice on /// `ConnectFailureReason::AccountLocked`. pub logout_message: Option, /// The stanza that caused the logout, when one was received. pub raw: Option, } /// Localized text the server wants shown when it forces a logout, from /// `logout_message_header` / `logout_message_subtext` on ``. #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct LogoutMessage { pub header: Option, pub subtext: Option, /// e.g. `"pt_BR"`. Compare against the consumer's locale before rendering. pub locale: Option, } ``` **Fields:** * `on_connect` — `true` if the logout happened during a connection attempt (server-initiated), `false` if triggered by `client.logout()` or a stream error while connected * `reason` — The reason for the logout (e.g., `ConnectFailureReason::LoggedOut`) * `logout_message` — server-supplied header/subtext/locale, when the server sent any (in practice, only on an account lock). The official client only renders `header`/`subtext` when `locale` matches the consumer's current locale, and the locale travels with the text so a consumer can apply the same rule. * `raw` — the stanza that caused the logout, when one was received. Two different shapes reach this field, so dispatch on `raw.tag` rather than assuming one: `"failure"` for a server-side connect refusal (`on_connect` is then `true`), and `"stream:error"` for a ``, a 516 device removal, or a 401 stream error while already connected. `None` for a locally initiated `client.logout()` — there's no stanza to report. Breaking change: `LoggedOut` gained `logout_message` and `raw`. An account lock (`reason: ConnectFailureReason::AccountLocked`) carries a server-issued `appeal_token` plus `violation_reason` and `vt` on the `` stanza — WA Web itself ignores these (its appeal flow is native-client only) so they aren't parsed into typed fields, but they now survive on `logout.raw` for an embedder that wants to build its own appeal UI. Read them off `raw.attrs.get("appeal_token")`, etc. **Usage:** ```rust theme={null} Event::LoggedOut(logout) => { eprintln!("Logged out (reason: {:?})", logout.reason); if let Some(msg) = &logout.logout_message { eprintln!("Server message: {:?} / {:?}", msg.header, msg.subtext); } // Session is invalid — you must re-pair the device // Auto-reconnect is disabled } ``` **Behavior:** Auto-reconnect is disabled. The application must re-pair the device to establish a new session. ### StreamError **Emitted:** For unrecognized stream error codes (codes not matching 401, 409, 429, 503, 515, or 516), and — since [#1263](https://github.com/oxidezap/whatsapp-rust/pull/1263) — also for `429` (rate-limited), even though 429 is itself a recognized, explicitly-handled code. WhatsApp Web's own handler has no arm for it either (only `500..600` is special-cased there), so reporting 429 here is an embedder-facing choice rather than a fidelity fix. ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct StreamError { pub code: String, pub raw: Option, } ``` **Usage:** ```rust theme={null} Event::StreamError(err) => { eprintln!("Stream error: {} (raw: {:?})", err.code, err.raw); } ``` Specific stream error codes have the following event behavior: * **401** → `LoggedOut` (session invalidated) * **409** → `StreamReplaced` (another client connected) * **429** → `StreamError` (rate limited; also reconnects with extended backoff) * **503** → No event emitted (reconnects with normal backoff) * **515** → No event emitted (immediate reconnect, e.g., after pairing) * **516** → `LoggedOut` (device removed) ## Pairing Events ### PairingQrCode **Emitted:** For each QR code in rotation ```rust theme={null} /// A QR code the consumer renders during multi-device pairing. #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairingQrCode { /// The QR payload to render — ASCII art QR or data string. pub code: String, /// How long this code stays valid before the next one rotates in (60s first, 20s subsequent). pub timeout: std::time::Duration, } ``` **Example:** ```rust theme={null} Event::PairingQrCode(PairingQrCode { code, timeout, .. }) => { println!("Scan this QR (valid {}s):", timeout.as_secs()); println!("{}", code); } ``` Breaking change: `PairingQrCode` moved from inline fields directly on the `Event::PairingQrCode { code, timeout }` variant to a dedicated sealed struct — `Event::PairingQrCode(PairingQrCode)`. Update destructuring patterns to match through the newtype, with a `..` rest since the inner struct is `#[non_exhaustive]`. ### PairingCode **Emitted:** When pair code is generated ```rust theme={null} /// Generated pair code for phone number linking. /// User should enter this code on their phone in WhatsApp > Linked Devices. #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairingCode { /// The 8-character pairing code to display. pub code: String, /// Approximate validity duration (~180 seconds). pub timeout: std::time::Duration, } ``` `timeout` is the *remaining* validity window, not always the full \~180 seconds: the clock starts before the stage-1 `companion_hello` round-trip, so `timeout` is already reduced by however long that request took. **Example:** ```rust theme={null} Event::PairingCode(PairingCode { code, .. }) => { println!("Enter {} on your phone", code); } ``` Breaking change: `PairingCode` moved from inline fields on `Event::PairingCode { code, timeout }` to a dedicated sealed struct — `Event::PairingCode(PairingCode)`. ### PairingCodeRefresh **Emitted:** When the in-progress phone-number pairing code should be replaced. Covers two triggers (WA Web `Alt/DeviceLinkingApi.js` + `Link/DevicePhoneNumberCodeScreen.react.js`): the server asking for it (`refreshAltLinkingCode` / `forceManualRefresh`, only while a pair-code flow is outstanding and the server's ref matches it — a `refresh_code` notification for a stale or unrelated flow is silently ignored), and a non-refused `companion_finish` — accepted, or its own 30s wait going unanswered — whose `pair-success` then went unanswered for a minute (`PairCodeUtils::primary_hello_pair_success_timeout()`) — a primary that could not open the key bundle just goes quiet at that point, so silence is the only signal there is. A `companion_finish` the server actively *refuses* is a different case and does not fire this event — see [`PairingCodeError`](#pairingcodeerror) below. ```rust theme={null} /// The in-progress phone-number pairing code should be replaced. /// /// Emitted for the two cases WA Web regenerates on: the server asking for it /// (`refreshAltLinkingCode` / `forceManualRefresh`, ref-gated against the /// outstanding flow), and pair-success going unanswered after a /// non-refused `companion_finish` (accepted, or itself unanswered). /// /// The outstanding flow is cleared before this fires, so the consumer can call /// `pair_with_code` straight away. The previous code is no longer valid. #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairingCodeRefresh { /// `true` when the server set `force_manual_refresh` — the code must be /// re-requested explicitly rather than auto-rotated. Always `false` for /// the silent-`pair-success` timeout trigger. pub force_manual: bool, } ``` **Example:** ```rust theme={null} Event::PairingCodeRefresh(PairingCodeRefresh { force_manual, .. }) => { // The previous code is no longer valid, and the flow is already clear — // request a new one, e.g. by calling `client.pair_with_code(options)` again. println!("Pair code needs a refresh (force_manual={force_manual})"); } ``` Breaking change: `PairingCodeRefresh` moved from an inline `Event::PairingCodeRefresh { force_manual }` field to a dedicated sealed struct — `Event::PairingCodeRefresh(PairingCodeRefresh)`. A `matches!` check on the field becomes `matches!(event, Event::PairingCodeRefresh(r) if r.force_manual)`. The silent-`pair-success` timeout trigger (formerly described as the "unanswered-`companion_finish`" trigger) is not new by itself. What changed is narrower, but worth registering a handler for: a `companion_finish` the server actively refuses used to go unanswered like any other stage-2 failure, so it *also* fell into this same one-minute timeout and eventually fired `PairingCodeRefresh`. It no longer does — a refusal now exits immediately as `PairingCodeError` instead (see below) and this event never fires for it. **If your handler only registers `on_pair_code_refresh`** (not `on_pair_code_error`) and relied on it eventually firing for every stage-2 failure, including a refusal, it will now miss that case — the retry will not happen unless you also handle `PairingCodeError`. A handler that only cares about a server-requested refresh, or that already registers both, needs no changes. ### PairingCodeError **Emitted:** When a phone-number pair-code flow fails, so no linking will come of it. For a **stage-1** failure, `Client::pair_with_code` dispatches this in addition to returning `Err` — the event is the *only* surface that reports it when pairing is driven by [`BotBuilder::with_pair_code`](/api/bot#with_pair_code), since that request runs in a detached task and its `Err` reaches no caller. For a **stage-2** `companion_finish` refusal there is no `Err` to receive either way: `pair_with_code`'s call already returned `Ok(code)` once stage 1 succeeded, long before the notification that drives stage 2 arrives — this event is the only surface for that failure, for a direct caller and a `with_pair_code` consumer alike. ```rust theme={null} /// A phone-number pair-code flow failed, so no linking will come of it. /// /// Fires for every stage-1 failure, including local validation (a too-short /// phone number never reaches the server), and — since the `companion_finish` /// round trip started waiting for its answer — for a stage-2 refusal too. Both /// round trips report through the same event because the consumer's move is /// the same either way: this code is finished, request another or fall back to /// the QR. Silence at stage 2 is not this event, because nothing was refused; /// it surfaces as `PairingCodeRefresh` once the one-minute `pair-success` timer /// runs out. #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairingCodeError { /// The server's refusal, when it answered with one. `None` when the /// failure was local (validation, no connection) or the request went /// unanswered (timeout) — nothing was refused, so there is no status to /// report. pub rejection: Option, /// How long the server asked the client to wait, from the `backoff` /// attribute. Usually absent. pub backoff: Option, /// The failure rendered for logs. Do not branch on it; use `rejection`. pub error: String, } ``` **Example:** ```rust theme={null} Event::PairingCodeError(PairingCodeError { rejection, backoff, error, .. }) => { eprintln!("Pair code request failed: {error}"); if rejection.is_some_and(PairCodeRejection::is_throttled) { // Back off, then retry — using the server's own delay when it named one. } } ``` Two failures never reach this event, because for them a code may still be on its way and the event would say the opposite: `PairCodeError::CodeAlreadyOutstanding` (an earlier code is still live — the consumer already has it from the `PairingCode` that minted it) and `PairCodeError::Cancelled` (the caller withdrew this request, and a replacement may already own the slot by the time it resolves). Both are consequences of something the caller did, so neither is news to them, and a direct caller still receives the `Err` either way. See [Pair code failure events](/concepts/authentication#pair-code-failure-events) for `PairCodeRejection`'s variants and the full breakdown, and [`BotBuilder::on_pair_code_error`](/api/bot#on_pair_code_error) to register a handler. New: this event now also fires for a *refused* `companion_finish` (stage 2), reported the moment the server answers rather than after the one-minute `pair-success` silence timer. No shape change — `PairingCodeError`'s fields are the same — so a handler already matching on this event needs no code changes, only the awareness that it can now fire earlier and for a second reason. `rejection` follows `companion_finish`'s own narrower set of refusals; see [`PairCodeRejection`](/concepts/authentication#paircoderejection). ### PairingQrCodesExhausted **Emitted:** When the server's `` refs are used up — there is no QR left to render until the connection is re-established. WA Web's rotation timer (`Handle/PairDevice.js`) reports `UNPAIRED_IDLE` here and stops; it does not close the socket unconditionally, because a phone-number (pair-code) flow may still be riding the same connection. ```rust theme={null} /// The server's `` refs are used up: there is no QR left to /// render until the connection is re-established. #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairingQrCodesExhausted { /// `true` when the client closed the connection itself, which it only does /// with no pair-code flow outstanding. `false` means the socket was left /// up and reconnecting is the consumer's call. pub disconnected: bool, } ``` **Example:** ```rust theme={null} Event::PairingQrCodesExhausted(PairingQrCodesExhausted { disconnected, .. }) => { if disconnected { // No pair-code flow was outstanding, so the client is tearing its own // socket down and won't auto-reconnect. As of PR #1258, disconnect() // is final on this Client instance — build a fresh one against the // same persistence_manager and call connect() on it for a fresh // batch of refs. println!("QR refs exhausted, disconnecting — build a fresh Client to retry"); } else { // A pair-code flow is still outstanding, so the socket is being left // up to keep carrying it. There is simply no QR to show right now. println!("QR refs exhausted, but a pair code is still in progress"); } } ``` See [QR ref exhaustion](/concepts/authentication#qr-ref-exhaustion) for why this no longer disconnects unconditionally. `disconnected: true` reports intent, not a completed action: the client dispatches this event *before* awaiting `disconnect()`, so the socket may still be open at the moment a handler observes it. A synchronous `EventHandler` runs inline ahead of the disconnect; a `Bot`/`on_event` closure runs off a channel on its own task and can race it either way. **No [`Event::Disconnected`](#disconnected) follows**, though: `disconnect()` sets `expected_disconnect`, and `Disconnected` is scoped to disconnects the client did not itself intend, so waiting on it here hangs forever. `disconnect()` also disables auto-reconnect and, as of PR #1258, is final for this `Client` instance — it fires the same sticky shutdown signal [`connect()`](/api/client#connect) now checks, so a later `connect()` on the same instance returns `ConnectError::Shutdown` rather than eventually succeeding. Build a fresh `Client` against the same `persistence_manager` and call `connect()` on that one instead. This is a new event and a new `EventKind` variant, added at the **end** of `EventKind` (after `ServerAck`) rather than next to `Event::PairingQrCodesExhausted` — new kinds always go at the end, since the discriminant is what a consumer persists or transmits and inserting in the middle would renumber every kind after it. ### PairSuccess **Emitted:** When pairing completes successfully ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairSuccess { pub id: Jid, pub lid: Jid, pub business_name: String, pub platform: String, } ``` **Example:** ```rust theme={null} Event::PairSuccess(info) => { println!("✅ Paired as {}", info.id); println!("LID: {}", info.lid); println!("Name: {}", info.business_name); } ``` ### PairError **Emitted:** When pairing fails ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairError { pub id: Jid, pub lid: Jid, pub business_name: String, pub platform: String, pub error: String, } ``` ### PairPasskeyRequest **Requires the `passkey` feature** (opt-in, off by default as of the next release after 0.7.0). Without it, `PairPasskeyRequest`, `PairPasskeyConfirmation`, and `PairPasskeyError` below are never emitted — a `passkey_prologue_request` notification instead reaches your handler as `Event::Notification`. See [Feature flags](/installation#feature-flags) and [Authentication — Passkey linking](/concepts/authentication#passkey-linking-shortcake_passkey). **Emitted:** During [passkey (SHORTCAKE\_PASSKEY) linking](/concepts/authentication#passkey-linking-shortcake_passkey), when the server asks for a WebAuthn assertion to gate the link ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairPasskeyRequest { pub request_options_json: String, // verbatim PublicKeyCredentialRequestOptions JSON } ``` If a `PasskeyAuthenticator` is registered via `Client::set_passkey_authenticator`, the client obtains and sends the assertion automatically; this event is for hosts that drive the WebAuthn ceremony manually. **Example:** ```rust theme={null} Event::PairPasskeyRequest(req) => { let assertion = my_get_assertion_from_json(&req.request_options_json).await?; client.send_passkey_response(assertion).await?; } ``` ### PairPasskeyConfirmation **Emitted:** When the passkey link reaches the verification stage ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairPasskeyConfirmation { pub code: String, // 8-char "XXXX-XXXX" verification code pub skip_handoff_ux: bool, // true on a proven re-link: continuity means the code need not be shown } ``` **Example:** ```rust theme={null} Event::PairPasskeyConfirmation(conf) => { if conf.skip_handoff_ux { client.send_passkey_confirmation().await?; } else { println!("Confirm {} matches on your phone", conf.code); } } ``` ### PairPasskeyError **Emitted:** When a passkey link attempt fails ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairPasskeyError { pub error: String, pub continuation: bool, // false = failed during the initial request, true = during continuation/verification } ``` ### QrScannedWithoutMultidevice **Emitted:** When a QR code is scanned by a device that does not support multi-device ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct QrScannedWithoutMultidevice {} ``` **Usage:** ```rust theme={null} Event::QrScannedWithoutMultidevice(_) => { println!("QR scanned but device does not support multi-device"); // Prompt the user to update their WhatsApp app } ``` ### ClientOutdated **Emitted:** When the server rejects the connection because the client version is too old (connect failure code 405) ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct ClientOutdated { /// The whole `` stanza, so no attribute is lost to a log line. pub raw: Option, } ``` **Fields:** * `raw` — the `` stanza the server sent Breaking change: `ClientOutdated` gained `raw`, carrying the full `` stanza instead of discarding it. **Usage:** ```rust theme={null} Event::ClientOutdated(_) => { eprintln!("Client version is outdated — update whatsapp-rust"); // Auto-reconnect is disabled } ``` **Behavior:** Auto-reconnect is disabled. You must update to a newer version of the library. ## Message Events ### Messages **Emitted:** For all incoming messages (text, media, etc.), one event per durable commit. ```rust theme={null} Event::Messages(MessageBatch) #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct InboundMessage { pub message: Arc, pub info: Arc, /// Disappearing-message timer of the chat this message arrived in, in /// seconds, when the stanza carried one. pub ephemeral_expiration: Option, /// For a decrypted newsletter comment, the key of the post it replies to. /// `None` for every other message. pub comment_target: Option>, } pub enum BatchOrigin { Live, // delivered immediately, batch of one OfflineDrain, // accumulated batch from the offline drain } #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct MessageBatch { pub messages: Arc<[InboundMessage]>, pub origin: BatchOrigin, #[builder(default)] pub hook_committed: bool, } ``` Live traffic dispatches a batch of one, so per-message latency is unchanged from the previous single-message event. During the offline drain the client accumulates decrypted messages and dispatches one `Event::Messages` per durable commit (size/byte/timeout triggers, matching WhatsApp Web's `MessageProcessorCache` — see [Inbound Durability](/advanced/inbound-durability#batching)). `MessageBatch` behaves as a collection: `batch.iter()`, `batch.len()`, `batch.is_empty()`, `batch.first()`, and `for msg in &batch` all work directly. `Event::as_messages()` returns `Option<&MessageBatch>`, and `Event::messages()` returns an iterator over the batch's `InboundMessage`s (empty for any other event kind) — use it to scan a mixed event stream without matching on `Event::Messages` first. `hook_committed` is `true` when a registered [inbound durability hook](/advanced/inbound-durability) already committed this batch before it was dispatched. It defaults to `false` via the builder, so existing `.build()` calls compile unchanged. It's a signal for another consumer of the same event stream — not an instruction to skip anything on its own, since a hook that persists elsewhere would leave that consumer as the only materializer. An application-level store that also materializes this same event stream can use it to avoid double-processing a hook-fed batch. Breaking change: `InboundMessage` and `MessageBatch` are now `#[non_exhaustive]`, sealed with a `bon` builder. A `for InboundMessage { message, info } in batch.iter()` destructuring pattern needs a `..` rest: `for InboundMessage { message, info, .. } in batch.iter()`. Breaking change: `ephemeral_expiration` and `comment_target` moved from `MessageInfo` onto `InboundMessage`. Some code samples below may still show `info.ephemeral_expiration` / `info.comment_target` from before this change. Writing these fields into the shared `MessageInfo` needed an `Arc::make_mut` copy of the whole struct on every message in a disappearing chat. Moving them onto the event, which is already built fresh per dispatch, avoids that copy and lets `info` stay a cheap shared `Arc`. Read them by destructuring the field directly off `InboundMessage` instead of off `info`: `for InboundMessage { comment_target, .. } in batch.iter()`. [`MessageContext`](/api/bot#messagecontext) carries both fields too. `MessageContext::from_inbound` — what `Bot::on_message` uses internally — copies them from the event, so a bot handler can read `ctx.ephemeral_expiration` / `ctx.comment_target` directly. Both the message body and `MessageInfo` are `Arc`-wrapped inside `InboundMessage`. The same `Arc` slice handed to a registered [durability hook](/advanced/inbound-durability) is what this event carries — no deep clone, and a consumer never sees a message the hook did not commit (newsletter messages and PDO placeholder recoveries are the two exceptions: they dispatch event-only, bypassing the hook). Before v0.6 the body was `Box`; the public guarantee changed from "owned, freely mutable" to "shared, immutable read access" — call `Arc::make_mut` (or clone the inner `wa::Message`) only if you genuinely need to mutate. **Resend collapse.** A sender whose network is bad can retry its own outbox, resending one message re-encrypted under a new sender-key iteration — the Signal ratchet decrypts it cleanly, since it isn't byte-identical to the first delivery, so nothing at that layer catches it. Since [#1352](https://github.com/oxidezap/whatsapp-rust/pull/1352), such a resend collapses to a single `Event::Messages` dispatch, keyed by chat, message id and sender (device dropped), for as long as the `dispatched_messages` cache window holds (default 5-minute TTL, 1,000 entries — see [cache configuration reference](/api/bot#cache-configuration-reference)). `stats().messages_suppressed_duplicate` counts what this collapses. The gate is in-memory only and does not survive a restart of your process, so a hook or consumer performing side effects still needs its own idempotency — see [`Event::Messages` is at-least-once too](/advanced/inbound-durability#caveats). It also does not cover a message delivered as several `msmsg` parts under one stanza id (e.g. a bot streaming a multi-part reply): each part still dispatches, since collapsing there could drop a part the consumer never got. **MessageInfo structure:** ```rust theme={null} #[derive(Debug, Clone, Default, Serialize)] pub struct MessageInfo { pub source: MessageSource, pub id: MessageId, pub server_id: MessageServerId, pub r#type: Option, pub push_name: String, pub timestamp: DateTime, pub server_timestamp_us: Option, pub category: MessageCategory, pub multicast: bool, pub media_type: Option, pub edit: EditAttribute, pub bot_info: Option, pub meta_info: MsgMetaInfo, pub verified_name: Option>, pub verified_level: Option, pub verified_name_serial: Option, pub device_sent_meta: Option, pub is_offline: bool, pub peer_recipient_pn: Option, pub unavailable_request_id: Option, } #[derive(Debug, Clone, Default, Serialize)] pub struct MessageSource { pub chat: Jid, // Where it was sent (group or DM) pub sender: Jid, // Who sent the message pub is_from_me: bool, pub is_group: bool, pub addressing_mode: Option, pub sender_alt: Option, pub recipient_alt: Option, pub broadcast_list_owner: Option, pub recipient: Option, } ``` `sender_alt` carries the LID/PN counterpart of `sender` whenever the stanza exposes one — including `status@broadcast` messages, which always include `participant_lid` (or `participant_pn` for LID-addressed status). The library reads it unconditionally so the [LID-PN cache](/concepts/storage#lidpncache) can re-warm from the message itself, matching WA Web's `WAWebMsgParser`. Breaking change: `MessageInfo::r#type` changed from `String` to `Option`, and `MessageInfo::media_type` changed from `String` to `Option`. Migrate a comparison like `info.r#type == "text"` to `info.r#type == Some(StanzaMessageType::Text)`, or keep comparing strings with `info.r#type.as_ref().map(StanzaMessageType::as_str) == Some("text")` — same shape for `media_type` with `EncMediaType`. Code that previously treated `""` as "absent" now matches `None` instead. **StanzaMessageType:** The `` envelope attribute, parsed before any `` is decrypted. `None` when the stanza carried no `type` attribute at all; an unrecognized value is preserved as `Unknown` rather than dropped. ```rust theme={null} pub enum StanzaMessageType { Text, // "text" Media, // "media" MediaNotify, // "medianotify" Pay, // "pay" Poll, // "poll" Reaction, // "reaction" Event, // "event" Unknown(String), // Forward-compatible fallback, holds the raw wire value } ``` `StanzaMessageType` says nothing about the decrypted content — it is the envelope's own claim, and nothing verifies it against the `Message` that comes out of the ciphertext. **EncMediaType:** The `mediatype` attribute of an `` node — a hint about the payload the ciphertext carries, available before the decryption that would reveal it. `MessageInfo::media_type` aggregates this across the stanza's `` nodes: the first one that declares a value wins (direct children first, then this device's own `` under ``); a later disagreeing value is dropped. `None` when no `` carried the attribute. ```rust theme={null} pub enum EncMediaType { Image, Video, Ptv, Audio, Ptt, Location, Vcard, Document, Url, Call, Gif, Future, ContactArray, LiveLocation, ProfilePic, Sticker, StickerPack, Hsm, ProductImage, Template, MdAppState, MdHistorySync, List, ListResponse, Button, ButtonResponse, Order, Product, NativeFlowResponse, GroupHistory, Unknown(String), // Forward-compatible fallback, holds the raw wire value } ``` It is the sender's claim and nothing checks it against the decrypted `Message`, so it is useful for routing and telemetry, not for deciding what a message is. `MessageInfo::media_type` is aggregated to one value per message — a fan-out stanza's per-device `` copies all repeat the same attribute in practice, so the aggregation only fills the field when the direct children carry nothing. **MessageCategory:** ```rust theme={null} pub enum MessageCategory { Empty, // Default (empty string on the wire) Peer, // Self-synced message from primary phone Other(String), // Unknown category (forward compatibility) } ``` **EditAttribute:** Indicates the type of edit or revocation applied to a message. Values correspond to the wire-format `edit` attribute on message stanzas. ```rust theme={null} pub enum EditAttribute { Empty, // "" — no edit (default) MessageEdit, // "1" — message text was edited PinInChat, // "2" — message was pinned/unpinned AdminEdit, // "3" — admin edited the message SenderRevoke, // "7" — sender deleted for everyone AdminRevoke, // "8" — admin deleted for everyone Unknown(String), // Forward-compatible fallback } ``` **MsgBotInfo:** Present when the message originates from a WhatsApp bot (AI-generated responses). Contains streaming edit metadata. ```rust theme={null} pub struct MsgBotInfo { pub edit_type: Option, pub edit_target_id: Option, pub edit_sender_timestamp_ms: Option>, } pub enum BotEditType { First, // Initial bot response Inner, // Streaming update (intermediate chunk) Last, // Final streaming update } ``` **MsgMetaInfo:** Additional metadata for message threading, targeting, and abuse reporting. ```rust theme={null} pub type ReportingBytes = SmallVec<[u8; 20]>; pub struct MsgMetaInfo { pub target_id: Option, pub target_sender: Option, pub thread_message_id: Option, pub thread_message_sender_jid: Option, pub poll_type: Option, pub content_type: Option, pub appdata: Option, pub reporting_tag: Option, pub reporting_token: Option, pub reporting_token_version: Option, } ``` Breaking change ([whatsapp-rust#1343](https://github.com/oxidezap/whatsapp-rust/pull/1343)): `target_id` and `thread_message_id` changed from `Option` to `Option`. `content_type` and `appdata` changed from `Option` to `Option`. `CompactString` (`wacore_binary::CompactString`) stores up to 24 bytes inline on 64-bit targets, with a smaller inline limit on 32-bit/wasm32 — a message id and these short keywords fit inline on 64-bit. `reporting_tag` and `reporting_token` changed from `Option>` to `Option`, a new `SmallVec<[u8; 20]>` alias. It keeps a 16-or-20-byte tag or a 16-byte token inline; a payload wider than 20 bytes still spills to the heap. Read-only access — comparisons, `.as_deref()`, formatting — is unaffected, since both types deref to `&str`/`&[u8]`. A call site that assigns these fields from a `String`/`Vec` literal, or moves a value out into one, needs one `.into()` per site (`.to_vec()` for `ReportingBytes` → `Vec`). | Field | Description | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `target_id` | ID of the message being targeted (for edits/revokes) | | `target_sender` | Sender of the targeted message | | `thread_message_id` | ID of the message this one threads under (``) | | `thread_message_sender_jid` | Who authored `thread_message_id`; absent whenever that is | | `poll_type` | Which stage of a poll's lifecycle the envelope carries (``); see `PollType` below | | `content_type` | High-level content classification carried by the server (e.g. `"image"`, `"document"`) so consumers can filter without decoding the message body | | `appdata` | Opaque per-message app data used by the abuse-report flow | | `reporting_tag` | Tag the server attaches so the recipient can submit an abuse report tied to this message | | `reporting_token` | Raw bytes of the abuse-report token paired with `reporting_tag` | | `reporting_token_version` | Version of the reporting-token scheme used by the server | **PollType:** Read only when the envelope's `r#type` is `StanzaMessageType::Poll`, mirroring the official WhatsApp Web parser, which scopes the `polltype` attribute to poll envelopes. Closed on purpose — a `polltype` value outside this list parses as `None` rather than being preserved as an `Unknown` variant, since upstream treats it the same way. ```rust theme={null} pub enum PollType { Creation, // "creation" QuizCreation, // "quiz_creation" Vote, // "vote" ResultSnapshot, // "result_snapshot" Edit, // "edit" } ``` `server_timestamp_us`, `verified_level`, `verified_name_serial`, `peer_recipient_pn`, plus `target_id`, `target_sender`, `thread_message_id`, `thread_message_sender_jid`, `content_type`, `appdata`, `reporting_tag`, `reporting_token`, and `reporting_token_version` on `MsgMetaInfo` were added in v0.6 as part of aligning inbound parsing with WA Web. They are populated only when the server includes the corresponding stanza attribute, so existing consumers that ignore them keep working. Breaking change: `MsgMetaInfo::deprecated_lid_session` was removed. It was declared but never assigned by any parser, and no wire attribute maps to it — nothing to migrate to, callers should simply drop the field access. `thread_message_id` and `thread_message_sender_jid` were already declared before this change but always empty; they are now actually populated, sourced from the poll envelope's `thread_msg_id` / `thread_msg_sender_jid` attributes. `poll_type` is new, sourced from `` and scoped to `StanzaMessageType::Poll` envelopes only. Breaking change: `verified_name` changed from the raw, never-populated `wa::VerifiedNameCertificate` to `Option>` — the decoded business display name (see [`VerifiedName`](/api/contacts#is_on_whatsapp) for its `name`/`serial`/`issuer`/`certificate` fields). Business senders attach this cert to the `` stanza's `` child; it's now decoded the same way the usync and business-notification parsers already did, so a WABA sender's display name (e.g. "HDFC Bank Ltd") reaches this field instead of being silently dropped. It's boxed since most messages carry none. Undecodable cert bytes don't fail message parsing — the field is just `None` in that case. `verified_name_serial` is unrelated to and unchanged by this: it's parsed from the envelope's own `verified_name` **attribute** (an integer), while `verified_name.serial` is decoded from the child cert's `Details.serial` field (a string). Both name the same certificate serial in a well-formed stanza, but they're read from two different places on the wire and can be populated independently — a stanza could in principle carry one without the other. Every `Option` field on `MessageInfo`, `MessageSource`, `MsgBotInfo`, and `MsgMetaInfo` is annotated `#[serde(skip_serializing_if = "Option::is_none")]`. A JSON serialization of these structs (e.g. via `serde_json::to_value`, for structured logging or observability dumps) omits an absent field entirely instead of emitting it as `null`. This applies uniformly across all optional fields as of the allocation/serialization cleanup in [whatsapp-rust#1059](https://github.com/oxidezap/whatsapp-rust/pull/1059) — earlier releases only omitted the four v0.6-era fields above, and serialized the remaining optional fields as explicit `null` when absent. Present field values are unchanged; only the JSON output shape for absent fields differs. **DeviceSentMeta:** Present on device-synced messages (messages you sent from another device). ```rust theme={null} pub struct DeviceSentMeta { pub destination_jid: String, pub phash: String, } ``` **Example:** ```rust theme={null} use waproto::whatsapp as wa; Event::Messages(batch) => { for InboundMessage { message: msg, info, .. } in batch.iter() { println!("From: {} in {}", info.source.sender, info.source.chat); // Text message if let Some(text) = &msg.conversation { println!("Text: {}", text); } // Extended text (with link preview, quoted message, etc.) if let Some(ext) = &msg.extended_text_message { println!("Text: {}", ext.text.as_deref().unwrap_or("")); if let Some(context) = &ext.context_info { if let Some(quoted) = &context.quoted_message { println!("Quoted: {:?}", quoted); } } } // Image message if let Some(img) = &msg.image_message { println!("Image: {} ({}x{})", img.caption.as_deref().unwrap_or(""), img.width.unwrap_or(0), img.height.unwrap_or(0) ); } // Video, audio, document, sticker, etc. // See waproto::whatsapp::Message for all types } } ``` ### Receipt **Emitted:** For delivery/read/played receipts ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct Receipt { pub source: MessageSource, pub message_ids: Vec, pub timestamp: DateTime, pub r#type: ReceiptType, /// `true` when the receipt was drained from the server's offline queue on /// reconnect (carried the `offline` attribute) rather than delivered live. pub offline: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[non_exhaustive] pub enum ReceiptType { Delivered, Sender, Retry, EncRekeyRetry, Read, ReadSelf, Played, PlayedSelf, ServerError, Inactive, PeerMsg, HistorySync, Other(String), } ``` `ReceiptType` is `#[non_exhaustive]`. Server-driven sets like this grow over time (recent additions include `EncRekeyRetry`, `ReadSelf`, `PlayedSelf`, `PeerMsg`, and `HistorySync`), so your `match` arms must always include a wildcard (`_ => …`). New variants can be added in minor releases without a breaking change. **Example:** ```rust theme={null} Event::Receipt(receipt) => { match receipt.r#type { ReceiptType::Read => { println!("✓✓ Read by {}", receipt.source.sender); } ReceiptType::Delivered => { println!("✓ Delivered to {}", receipt.source.sender); } _ => {} } } ``` ### UndecryptableMessage **Emitted:** When a message cannot be decrypted or is unavailable. This includes: * Decryption failures (no session, invalid keys, MAC errors) * Group messages that fail with `NoSenderKeyState` (missing sender key) — dispatched before the retry receipt is sent * Messages with an `` node — view-once already viewed, hosted content, bot fanouts, or other server-side unavailability. For `ViewOnce`/`Hosted`/`Bot`, the phone never shares that content with a companion device, so the client acks the stanza directly instead of requesting it. Only the `Unknown` case goes through [PDO recovery](/guides/receiving-messages#unavailable-message-recovery-via-pdo) When `is_unavailable` is `true`, the message had no encrypted content in the stanza. For `UnavailableType::Unknown`, the client sends a PDO request to your primary phone, and if the phone responds successfully, a follow-up `Event::Messages` is dispatched with the recovered content (event-only — a PDO recovery bypasses the durability hook and the offline-drain batcher, dispatching immediately with `BatchOrigin::Live`). For `ViewOnce`, `Hosted`, and `Bot`, no PDO request is sent — that content is unrecoverable by design, so no follow-up `Event::Messages` should be expected. ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct UndecryptableMessage { pub info: Arc, pub is_unavailable: bool, pub unavailable_type: UnavailableType, pub decrypt_fail_mode: DecryptFailMode, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum UnavailableType { Unknown, ViewOnce, // View-once media already viewed Hosted, // Hosted content the phone won't fan out to companions Bot, // AI bot message fanout } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum DecryptFailMode { Show, // Show placeholder in chat Hide, // Hide from chat } ``` The client deduplicates dispatch per `(chat, id, sender)`, not `(chat, id)` alone. A stanza id comes from the sending client. That id is only unique within a `(chat, sender)` pair. So two different senders can reuse the same id in one chat. The client treats those as two distinct messages and dispatches an event for each, instead of folding the second into the first. The client keys the sender on its wire-form JID. It deliberately leaves that JID unresolved to a LID/PN identity, because resolving it would let the key move as the client learns that mapping at runtime. This choice has one accepted cost: if a redelivery's sender switches PN/LID namespace mid-flight, the client treats it as a new message and dispatches a second placeholder. The client accepts that cost because a duplicate placeholder is visible and recoverable, while a silently dropped message is not. The dedup cache holds each key for up to 5 minutes (a TTL measured from first dispatch), or until it evicts the key to stay under its 1,000-entry capacity — whichever comes first. Once a key leaves the cache, a later redelivery of that same message dispatches the event again. ### Notification **Emitted:** For raw notification stanzas that are not handled by a more specific event type ```rust theme={null} Event::Notification(Arc) ``` This is a passthrough event that gives you access to the raw node for notification types that the library does not parse into dedicated event structs. The `OwnedNodeRef` provides zero-copy access to the decoded stanza — call `.get()` to obtain a `NodeRef` for inspecting the tag, attributes, and children. **Example:** ```rust theme={null} Event::Notification(node) => { let node_ref = node.get(); println!("Raw notification tag: {}", node_ref.tag); } ``` Most notifications are already parsed into specific event types (e.g., `GroupUpdate`, `DeviceListUpdate`, `ContactUpdated`), and this event otherwise fires only for unhandled notification types. The one exception is [`groups_dirty`](#stale-group-metadata-groups_dirty): the library both acts on it and forwards it here. **`DecryptFailMode`** is determined by the `decrypt-fail` attribute on incoming `` nodes. If any `` node has `decrypt-fail="hide"`, the entire message uses `Hide` mode. * **`Show`** — Default. The application should display a "waiting for this message" placeholder. Used for regular user-visible messages. * **`Hide`** — The application should silently discard the failure. Used for infrastructure messages (reactions, poll votes, pin changes, edit messages, event responses, message history notices, secret encrypted event/poll edits, certain protocol messages, and SKDM stanzas) that don't need user-visible placeholders. See [Decrypt-fail suppression](/api/send#decrypt-fail-suppression) for the full list of message types that set this attribute on outgoing stanzas. **Example:** ```rust theme={null} Event::UndecryptableMessage(undec) => { match undec.decrypt_fail_mode { DecryptFailMode::Hide => { // Silently ignore — infrastructure message (reaction, poll vote, etc.) } DecryptFailMode::Show => { match undec.unavailable_type { UnavailableType::ViewOnce => println!("View-once message already consumed"), UnavailableType::Hosted => println!("Hosted content unavailable"), UnavailableType::Bot => println!("Bot message unavailable"), UnavailableType::Unknown => { eprintln!("Failed to decrypt message from {}", undec.info.source.sender); } } } } } ``` ### ServerAck **Emitted:** Observe-only, for every server `` stanza that carries an id — dispatched independently of the internal send-waiter resolution, so registering a handler never interacts with the send/phash flow. ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct ServerAck { pub id: String, pub class: Option, pub from: Option, pub timestamp: Option>, pub error: Option, } ``` | Field | Description | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | Id of the acked stanza (for a sent message, its message id). | | `class` | Stanza class the ack refers to (`"message"`, `"receipt"`, `"notification"`, `"call"`, …). `None` when the server omits it. | | `from` | Chat/entity the ack refers to, when present and parseable. | | `timestamp` | Server timestamp from the ack's `t` attribute, when present. For a message ack this is the authoritative send timestamp — the same attribute the [whatsmeow](https://github.com/tulir/whatsmeow) Go library reads into its own `SendResponse.Timestamp`. | | `error` | Nack code (e.g. `"479"`), when the ack is actually a nack; `None` for a plain ack. | Server acks cover every outgoing stanza class, not just messages — filter on `class` rather than correlating ids blind. Dispatch is gated on a registered handler existing for this event kind, so the hot ack path allocates nothing when no consumer subscribes. `ServerAck` was the first payload sealed under the stability policy above: it's `#[non_exhaustive]` and constructed via a generated `bon` builder (`ServerAck::builder().id(...).maybe_class(...)…build()`). This only affects code that *constructs* a `ServerAck` (the client itself) or uses exhaustive struct-pattern destructuring (which is already disallowed by the `..` guidance above); accessing fields by name with dot notation (`ack.id`, `ack.class`), as in the example below, is unaffected. **Example:** ```rust theme={null} Event::ServerAck(ack) => { if ack.class.as_deref() == Some("message") { if let Some(err) = &ack.error { eprintln!("Message {} nacked: {err}", ack.id); } else { println!("Message {} accepted by the server", ack.id); } } } ``` ## Presence Events ### ChatPresence **Emitted:** For typing indicators and recording states ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct ChatPresenceUpdate { pub source: MessageSource, pub state: ChatPresence, pub media: ChatPresenceMedia, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum ChatPresence { Composing, Paused, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum ChatPresenceMedia { Text, Audio, } ``` **Example:** ```rust theme={null} Event::ChatPresence(update) => { match (update.state, update.media) { (ChatPresence::Composing, ChatPresenceMedia::Text) => { println!("{} is typing...", update.source.sender); } (ChatPresence::Composing, ChatPresenceMedia::Audio) => { println!("{} is recording audio...", update.source.sender); } (ChatPresence::Paused, _) => { println!("{} stopped typing", update.source.sender); } } } ``` ### Presence **Emitted:** For online/offline status and last seen ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PresenceUpdate { pub from: Jid, pub unavailable: bool, pub last_seen: Option>, } ``` **Example:** ```rust theme={null} Event::Presence(update) => { if update.unavailable { println!("{} is offline", update.from); if let Some(last_seen) = update.last_seen { println!("Last seen: {}", last_seen); } } else { println!("{} is online", update.from); } } ``` ## User update events ### PictureUpdate **Emitted:** When a user changes their profile picture ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PictureUpdate { pub jid: Jid, pub author: Option, pub timestamp: DateTime, pub removed: bool, pub picture_id: Option, } ``` **Fields:** * `jid` - The JID whose picture changed (user or group) * `author` - The user who made the change. Present for group picture changes (the admin who changed it). `None` for personal picture updates. * `removed` - Whether the picture was removed (`true`) or set/updated (`false`) * `picture_id` - The server-assigned picture ID. `None` for deletions. ### UserAboutUpdate **Emitted:** When a user changes their status/about ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct UserAboutUpdate { pub jid: Jid, pub status: String, pub timestamp: DateTime, } ``` ### `RetiredPushNameUpdate` ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct RetiredPushNameUpdate {} ``` Breaking change (PR #1310): this event is retired. Nothing constructs or dispatches it, and nothing ever will. The payload promised an `old_push_name`/`new_push_name` comparison, but this library holds no contact store to source the previous name from. `EventKind::PushNameUpdate` is renamed `EventKind::RetiredPushNameUpdate` the same way. Both the `EventKind` and `Event` variants keep their slot instead of being deleted. `EventKind`'s discriminant is an `EventInterest` bit index a consumer may persist. `Event` derives `Serialize` into index-keyed formats (bincode, postcard) that key a variant by position. Removing either would renumber every variant after it and change how already-stored data decodes. The payload struct is emptied instead, so the slot is held without claiming anything. A handler matching `Event::PushNameUpdate(..)` should drop the arm — it can never fire — or rename it to `Event::RetiredPushNameUpdate(..)`. To track a contact's push name, read [`MessageInfo::push_name`](#messages) on the message events you already handle. It comes from the stanza's `notify` attribute, and a stanza without that attribute (e.g. newsletter traffic) leaves it as an empty string. Treat an empty `push_name` as "not present," not as a rename to `""`, and diff only non-empty values against your own contact store if you need change detection. ### SelfPushNameUpdated **Emitted:** When your own push name is updated ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct SelfPushNameUpdated { pub from_server: bool, pub old_name: String, pub new_name: String, } ``` ## Group Events ### GroupUpdate **Emitted:** For each action in a group notification (subject changes, participant changes, settings updates, etc.). A single notification may produce multiple `GroupUpdate` events. ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct GroupUpdate { pub group_jid: Jid, pub participant: Option, pub participant_pn: Option, pub timestamp: DateTime, pub is_lid_addressing_mode: bool, pub action: Box, } ``` **Fields:** * `group_jid` - The group this update applies to * `participant` - The admin/user who triggered the change * `participant_pn` - Phone number JID of the participant (for LID-addressed groups) * `is_lid_addressing_mode` - Whether the group uses LID addressing mode * `action` - The specific group notification action (subject change, participant add/remove/promote/demote, description change, etc.). It is boxed, like the `action` field of every other sync-action event payload (`ContactUpdate`, `PinUpdate`, `MuteUpdate`, …). It was the largest variant of `Event`, and every dispatched event pays for the size of the largest one. **Example:** ```rust theme={null} Event::GroupUpdate(update) => { println!("Group {} updated by {:?}: {:?}", update.group_jid, update.participant, update.action); } ``` ### GroupNotificationAction The `action` field on `GroupUpdate` is a `GroupNotificationAction` enum with the following variants: | Variant | Wire tag | Description | | -------------------------------------------------------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Add { participants, reason }` | `` | Members added to group | | `Remove { participants, reason }` | `` | Members removed from group | | `Promote { participants }` | `` | Members promoted to admin | | `Demote { participants }` | `` | Members demoted from admin | | `Modify { participants }` | `` | Member changed phone number | | `ChangeNumber { new_owner, sub_group_suggestions }` | `` | A participant changed their phone number. `new_owner` is read from the `jid` attribute of `` and `sub_group_suggestions` is collected from any `` children. The previous JID is carried by `GroupUpdate::participant`. | | `Subject { subject, subject_owner, subject_owner_pn, subject_owner_username, subject_time }` | `` | Group name changed | | `Description { id, description }` | `` | Group description changed or deleted | | `Locked { threshold }` | `` | Only admins can edit group info | | `Unlocked` | `` | All members can edit group info | | `Announce` | `` | Only admins can send messages | | `NotAnnounce` | `` | All members can send messages | | `Ephemeral { expiration, trigger }` | `` | Disappearing messages setting changed | | `MembershipApprovalMode { enabled }` | `` | Join approval toggled | | `MembershipApprovalRequest { request_method, parent_group_jid }` | `` | A user requested to join the group | | `CreatedMembershipRequests { request_method, parent_group_jid, requests }` | `` | Admin-side: new join requests appeared | | `RevokedMembershipRequests { participants }` | `` | Membership requests rejected or cancelled | | `MemberAddMode { mode }` | `` | Who can add members (`admin_add` or `all_member_add`) | | `NoFrequentlyForwarded` | `` | Forwarding restricted | | `FrequentlyForwardedOk` | `` | Forwarding allowed | | `Invite { code }` | `` | Member joined via invite link | | `RevokeInvite` | `` | Invite link revoked | | `GrowthLocked { expiration, lock_type }` | `` | Invite links unavailable | | `GrowthUnlocked` | `` | Invite links available again | | `Create { raw }` | `` | Group created | | `Delete { reason }` | `` | Group deleted | | `Link { link_type, raw }` | `` | Subgroup linked (community) | | `Unlink { unlink_type, unlink_reason, raw }` | `` | Subgroup unlinked (community) | | `Unknown { tag }` | varies | Unknown notification tag (forward compatibility) | Participant-related variants include a `participants` field of type `Vec`: ```rust theme={null} pub struct GroupParticipantInfo { pub jid: Jid, pub phone_number: Option, pub display_name: Option, } ``` `display_name` carries the server-provided label for a participant — for non-contacts this is typically the masked phone number (`"+55•••••••••79"`). It is populated only when the participant appears as a `` child of a group notification; entries that arrive via `` (membership requests) leave it `None`. `Subject::subject_owner` follows the group's addressing mode, so in a LID-addressed group it is a `@lid` JID; the renamer's phone-number JID arrives separately as `subject_owner_pn` (from `s_o_pn`), with `subject_owner_username` (from `s_o_username`) when present — the same split `GroupUpdate::participant` / `participant_pn` uses. Membership request variants include a `request_method` field of type `MembershipRequestMethod`: ```rust theme={null} pub enum MembershipRequestMethod { InviteLink, // User clicked an invite link LinkedGroupJoin, // User joined via a linked community subgroup NonAdminAdd, // A non-admin member tried to add the user } ``` * `MembershipApprovalRequest` — emitted when a user requests to join a group. The requester is identified by the parent `GroupUpdate::participant` field. * `CreatedMembershipRequests` — admin-side notification: new join requests appeared. The `requests` field contains the requesting users (as `Vec`). * `RevokedMembershipRequests` — emitted when membership requests are rejected by an admin or cancelled by the requester. The `participants` field contains the affected JIDs. Both `MembershipApprovalRequest` and `CreatedMembershipRequests` include an optional `parent_group_jid` field for community-linked joins. **Example: handling specific group actions:** ```rust theme={null} use wacore::stanza::groups::GroupNotificationAction; Event::GroupUpdate(update) => { match &*update.action { GroupNotificationAction::Add { participants, .. } => { for p in participants { println!("{} was added to {}", p.jid, update.group_jid); } } GroupNotificationAction::Subject { subject, .. } => { println!("Group {} renamed to {}", update.group_jid, subject); } GroupNotificationAction::Ephemeral { expiration, .. } => { if *expiration == 0 { println!("Disappearing messages disabled in {}", update.group_jid); } else { println!("Disappearing messages set to {}s in {}", expiration, update.group_jid); } } GroupNotificationAction::MembershipApprovalRequest { request_method, parent_group_jid } => { println!("Join request in {} via {:?}", update.group_jid, request_method); if let Some(parent) = parent_group_jid { println!("From community: {}", parent); } } GroupNotificationAction::CreatedMembershipRequests { requests, request_method, .. } => { println!("{} new join requests in {} via {:?}", requests.len(), update.group_jid, request_method); } GroupNotificationAction::RevokedMembershipRequests { participants } => { println!("{} membership requests revoked in {}", participants.len(), update.group_jid); } _ => {} } } ``` [PR #1402](https://github.com/oxidezap/whatsapp-rust/pull/1402) changed `GroupUpdate::action` to `Box`. At 288 bytes it made `GroupUpdate` the largest variant of `Event`, and `Event`'s size is what every dispatched event's `Arc` allocation pays for, group update or not. Field access (`update.action.foo`) is unchanged. A `match` on `update.action` that names a `GroupNotificationAction` variant now needs `match &*update.action { ... }`, as in the example below. A single group notification from the server can contain multiple actions. The library dispatches a separate `GroupUpdate` event for each action, so your handler may receive multiple events from one notification. ### Stale group metadata (`groups_dirty`) The server can also send `` wrapping a `` child, naming one or more groups whose cached metadata is now stale (for example, after a bulk membership change made elsewhere). This is not an ordinary group notification — its `from` is the group server, not a group JID — so it produces no `GroupUpdate` event. Instead, the library evicts the cached snapshot for each named group and still dispatches the raw stanza as [`Event::Notification`](#notification) — an exception to that event's usual "unhandled types only" rule, since the library both acts on `groups_dirty` (the cache eviction) and forwards it. The next `query_info` (or a send) for an affected group transparently re-fetches its metadata from the server, since `query_info`'s default `Freshness::CachePreferred` behavior only serves the cache when it is populated. You do not need to call `query_info_with_freshness(&jid, Freshness::Refresh)` yourself in response to this notification — see [Cache group information](/guides/group-management#cache-group-information). ## Contact notification events Most events in this section are emitted from `` stanzas sent by the server. Two are not: `ContactUpdate` and `ContactRemoved` come from app-state sync mutations instead, grouped here with their notification-based siblings because they are all contact-related — each one's own description below states its actual source. ### ContactUpdated **Emitted:** When a contact's profile changes (server notification) ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct ContactUpdated { pub jid: Jid, pub timestamp: DateTime, } ``` **Wire format:** `` When you receive this event, you should invalidate any cached presence or profile picture data for the contact. WhatsApp Web resets its `PresenceCollection` and refreshes the profile picture thumbnail on this event. **Example:** ```rust theme={null} Event::ContactUpdated(update) => { println!("Contact {} profile changed at {}", update.jid, update.timestamp); // Invalidate cached presence/profile data // Re-fetch profile picture if needed } ``` ### ContactNumberChanged **Emitted:** When a contact changes their phone number ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct ContactNumberChanged { /// Old phone number JID. pub old_jid: Jid, /// New phone number JID. pub new_jid: Jid, /// Old LID (if provided by server). pub old_lid: Option, /// New LID (if provided by server). pub new_lid: Option, pub timestamp: DateTime, } ``` **Wire format:** `` The library automatically creates LID-PN mappings when LID attributes are present (`old_lid→old_jid` and `new_lid→new_jid`). WhatsApp Web generates a system notification message in both the old and new chats. **Example:** ```rust theme={null} Event::ContactNumberChanged(change) => { println!("Contact changed number: {} -> {}", change.old_jid, change.new_jid); if let Some(new_lid) = &change.new_lid { println!("New LID: {}", new_lid); } // Update your contact records to use the new JID } ``` ### ContactSyncRequested **Emitted:** When the server requests a full contact re-sync ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct ContactSyncRequested { /// If present, only sync contacts modified after this timestamp. pub after: Option>, pub timestamp: DateTime, } ``` **Wire format:** `` **Example:** ```rust theme={null} Event::ContactSyncRequested(sync) => { if let Some(after) = sync.after { println!("Server requests contact sync for changes after {}", after); } else { println!("Server requests full contact sync"); } } ``` ### ContactUpdate **Emitted:** When a contact's information changes via app-state sync (e.g., first name, last name set in your address book) ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct ContactUpdate { pub jid: Jid, pub timestamp: DateTime, pub action: Box, pub from_full_sync: bool, } ``` **Fields:** * `jid` - The contact whose information changed * `timestamp` - When the change occurred * `action` - The contact action from app-state sync, containing fields like `full_name` and `first_name` * `from_full_sync` - Whether this came from a full app-state sync (initial load) or an incremental update **Example:** ```rust theme={null} Event::ContactUpdate(update) => { println!("Contact {} updated at {}", update.jid, update.timestamp); if let Some(name) = &update.action.full_name { println!("Full name: {}", name); } if update.from_full_sync { println!("(from full sync)"); } } ``` `ContactUpdate` comes from app-state sync mutations and is distinct from `ContactUpdated`, which comes from server-side `` stanzas. The server may also send `` and `` child actions in contacts notifications for lightweight roster changes. These are acknowledged automatically and do not emit events. ### ContactRemoved **Emitted:** When a saved contact is deleted via app-state sync on a linked device ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct ContactRemoved { pub jid: Jid, pub timestamp: DateTime, pub from_full_sync: bool, } ``` **Fields:** * `jid` - The contact that was removed * `timestamp` - When the removal occurred * `from_full_sync` - Whether this came from a full app-state sync (initial load) or an incremental update **Example:** ```rust theme={null} Event::ContactRemoved(update) => { println!("Contact {} removed", update.jid); } ``` `ContactRemoved` is distinct from `ContactUpdate`, and the event itself carries no action payload. The underlying wire mutation is different: it is a syncd `Remove` with an all-default `ContactAction` value, since WhatsApp Web builds that value before choosing the operation. WhatsApp Web ignores the value on the `Remove` branch and simply drops the contact from the address book — but if you call [`remove_app_state_action`](/api/chat-actions#remove_app_state_action) directly for an action like this one, you still need to pass a `value`. See [Chat actions — Save and remove contacts](/api/chat-actions#save-and-remove-contacts) for the outbound API (`remove_contact`) that emits this event on other devices. ## Chat state events ### PinUpdate **Emitted:** When a chat is pinned/unpinned ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PinUpdate { pub jid: Jid, pub timestamp: DateTime, pub action: Box, pub from_full_sync: bool, } ``` ### MuteUpdate **Emitted:** When a chat is muted/unmuted ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct MuteUpdate { pub jid: Jid, pub timestamp: DateTime, pub action: Box, pub from_full_sync: bool, } ``` ### ArchiveUpdate **Emitted:** When a chat is archived/unarchived ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct ArchiveUpdate { pub jid: Jid, pub timestamp: DateTime, pub action: Box, pub from_full_sync: bool, } ``` ### StarUpdate **Emitted:** When a message is starred or unstarred ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct StarUpdate { pub chat_jid: Jid, pub participant_jid: Option, pub message_id: String, pub from_me: bool, pub timestamp: DateTime, pub action: Box, pub from_full_sync: bool, } ``` **Fields:** * `chat_jid` - The chat containing the starred message * `participant_jid` - The sender of the message (only for group messages from others; `None` for self-authored or 1-on-1 messages) * `message_id` - The ID of the starred/unstarred message * `from_me` - Whether the starred message was sent by you **Example:** ```rust theme={null} Event::StarUpdate(update) => { println!("Message {} in {} was {}starred", update.message_id, update.chat_jid, if update.action.starred.unwrap_or(false) { "" } else { "un" } ); } ``` ### MarkChatAsReadUpdate **Emitted:** When a chat is marked as read or unread across linked devices ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct MarkChatAsReadUpdate { pub jid: Jid, pub timestamp: DateTime, pub action: Box, pub from_full_sync: bool, } ``` **Example:** ```rust theme={null} Event::MarkChatAsReadUpdate(update) => { let read = update.action.read.unwrap_or(false); println!("Chat {} marked as {}", update.jid, if read { "read" } else { "unread" }); } ``` ### DeleteChatUpdate **Emitted:** When a chat is deleted across linked devices ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct DeleteChatUpdate { pub jid: Jid, /// From the index, not the proto — DeleteChatAction only has messageRange. pub delete_media: bool, pub timestamp: DateTime, pub action: Box, pub from_full_sync: bool, } ``` **Fields:** * `jid` - The JID of the deleted chat * `delete_media` - Whether media files were also deleted * `action` - The underlying protobuf action containing the optional `message_range` **Example:** ```rust theme={null} Event::DeleteChatUpdate(update) => { println!("Chat {} deleted (media deleted: {})", update.jid, update.delete_media); } ``` ### ClearChatUpdate **Emitted:** When a chat's messages are cleared (but the chat is kept) on a linked device ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct ClearChatUpdate { pub jid: Jid, /// From the index, not the proto — ClearChatAction only has messageRange. pub delete_starred: bool, /// From the index, not the proto. pub delete_media: bool, pub timestamp: DateTime, pub action: Box, pub from_full_sync: bool, } ``` **Fields:** * `jid` - The chat that was cleared * `delete_starred` - Whether starred messages were also removed * `delete_media` - Whether downloaded media was also removed * `from_full_sync` - `true` while replaying the initial app state full sync **Example:** ```rust theme={null} Event::ClearChatUpdate(update) => { println!("Chat {} cleared (starred: {}, media: {})", update.jid, update.delete_starred, update.delete_media); } ``` See [`clear_chat`](/api/chat-actions#clear_chat) for the outbound API that emits this on other devices. ### UserStatusMuteUpdate **Emitted:** When a contact/group/channel's status updates are muted or unmuted on a linked device ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct UserStatusMuteUpdate { pub jid: Jid, /// `true` = status muted, `false` = unmuted. pub muted: bool, pub timestamp: DateTime, pub action: Box, pub from_full_sync: bool, } ``` **Fields:** * `jid` - The entity whose status updates were (un)muted * `muted` - `true` when status was muted, `false` when unmuted * `from_full_sync` - `true` while replaying the initial app state full sync **Example:** ```rust theme={null} Event::UserStatusMuteUpdate(update) => { println!("Status of {} {}", update.jid, if update.muted { "muted" } else { "unmuted" }); } ``` See [`set_user_status_mute`](/api/chat-actions#set_user_status_mute) for the outbound API. ### DeleteMessageForMeUpdate **Emitted:** When a message is deleted locally (not for everyone) across linked devices ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct DeleteMessageForMeUpdate { pub chat_jid: Jid, pub participant_jid: Option, pub message_id: String, pub from_me: bool, pub timestamp: DateTime, pub action: Box, pub from_full_sync: bool, } ``` **Fields:** * `chat_jid` - The chat containing the deleted message * `participant_jid` - The sender of the message (only for group messages from others; `None` for self-authored or 1-on-1 messages) * `message_id` - The ID of the deleted message * `from_me` - Whether the deleted message was sent by you * `action` - The underlying protobuf action containing `delete_media` and optional `message_timestamp` **Example:** ```rust theme={null} Event::DeleteMessageForMeUpdate(update) => { println!("Message {} in {} deleted for me", update.message_id, update.chat_jid); } ``` ### LabelEditUpdate **Emitted:** When a chat label is created, renamed, recolored, or deleted on a linked device ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct LabelEditUpdate { pub label_id: String, pub timestamp: DateTime, pub action: Box, pub from_full_sync: bool, } ``` **Fields:** * `label_id` — Stable label identifier * `action.name` — New display name (`None` when only the deleted flag changes) * `action.color` — WhatsApp color index for the swatch * `action.deleted` — `Some(true)` when the label was removed * `from_full_sync` — `true` while replaying the initial app state full sync **Example:** ```rust theme={null} Event::LabelEditUpdate(update) => { if update.action.deleted == Some(true) { println!("Label {} deleted", update.label_id); } else { println!("Label {} renamed to {:?}", update.label_id, update.action.name); } } ``` ### LabelAssociationUpdate **Emitted:** When a label is added to or removed from a chat on a linked device ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct LabelAssociationUpdate { pub label_id: String, pub chat_jid: Jid, pub timestamp: DateTime, pub action: Box, pub from_full_sync: bool, } ``` **Fields:** * `label_id` — Identifier of the label being attached or detached * `chat_jid` — Chat whose label set changed * `action.labeled` — `Some(true)` when the label was added, `Some(false)` when removed * `from_full_sync` — `true` while replaying the initial app state full sync **Example:** ```rust theme={null} Event::LabelAssociationUpdate(update) => { let attached = update.action.labeled == Some(true); println!( "Label {} {} chat {}", update.label_id, if attached { "added to" } else { "removed from" }, update.chat_jid, ); } ``` See [Labels](/api/labels) for the outbound API that emits these events on other devices. ### MessageLabelAssociationUpdate **Emitted:** When a label is added to or removed from a single message on a linked device ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct MessageLabelAssociationUpdate { pub label_id: String, pub chat_jid: Jid, pub message_id: String, pub timestamp: DateTime, pub action: Box, pub from_full_sync: bool, } ``` **Fields:** * `label_id` — Identifier of the label being attached or detached * `chat_jid` — Chat containing the labeled message * `message_id` — Message whose label set changed * `timestamp` — When the association change occurred * `action.labeled` — `Some(true)` when the label was added, `Some(false)` when removed * `from_full_sync` — `true` while replaying the initial app state full sync **Example:** ```rust theme={null} Event::MessageLabelAssociationUpdate(update) => { let attached = update.action.labeled == Some(true); println!( "Label {} {} message {} in chat {}", update.label_id, if attached { "added to" } else { "removed from" }, update.message_id, update.chat_jid, ); } ``` Distinct from `LabelAssociationUpdate` above — that event is a whole-chat association, this one is scoped to a single message. See [Labels — Associate a label with a message](/api/labels#associate-a-label-with-a-message) for the outbound API. ### QuickReplyUpdate **Emitted:** When a quick reply is created, edited, or deleted on a linked device ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct QuickReplyUpdate { pub id: String, pub timestamp: DateTime, pub action: Box, pub from_full_sync: bool, } ``` **Fields:** * `id` — Stable quick reply identifier * `timestamp` — When the change occurred * `action.shortcut` — The `/`-typed trigger text * `action.message` — The expanded message text * `action.deleted` — `Some(true)` when the quick reply was deleted * `from_full_sync` — `true` while replaying the initial app state full sync **Example:** ```rust theme={null} Event::QuickReplyUpdate(update) => { if update.action.deleted == Some(true) { println!("Quick reply {} deleted", update.id); } else { println!("Quick reply {} = {:?}", update.id, update.action.shortcut); } } ``` Deletion is the same mutation with `action.deleted == Some(true)`, not a syncd `Remove` — check that flag rather than assume the event always describes a live quick reply. See [Quick Replies](/api/quick-replies) for the outbound API that emits this event on other devices. ## History sync events ### HistorySync **Emitted:** For chat history synchronization ```rust theme={null} Event::HistorySync(Box) ``` **LazyHistorySync** holds the original compressed zlib payload and only decodes the full `wa::HistorySync` proto on demand. Cheap metadata (`sync_type`, `chunk_order`, `progress`) is available without decoding. Queued events are \~10× smaller than the decompressed form — a typical `InitialBootstrap` chunk is 5–20 MB inflated, \~1–2 MB compressed. ```rust theme={null} pub struct LazyHistorySync { compressed: Bytes, // original zlib payload (reference-counted) decompressed_size: usize, // exact inflated byte count sync_type: i32, chunk_order: Option, progress: Option, peer_data_request_session_id: Option, parsed: OnceLock>>, } impl LazyHistorySync { /// History sync type (e.g. InitialBootstrap, Recent, PushName). /// Available without decoding the proto. pub fn sync_type(&self) -> i32; /// Chunk ordering for multi-chunk transfers. pub fn chunk_order(&self) -> Option; /// Sync progress (0-100). pub fn progress(&self) -> Option; /// Session ID set only on ON_DEMAND syncs. Use it to correlate the /// blob with the original `fetchMessageHistory` / /// `requestPlaceholderResend` request. Server-pushed syncs return /// `None`. pub fn peer_data_request_session_id(&self) -> Option<&str>; /// Exact inflated byte count (known without decompressing). pub fn decompressed_size(&self) -> usize; /// The stored compressed bytes (reference-counted, zero-copy). pub fn compressed_bytes(&self) -> &Bytes; /// Inflate the payload into a fresh buffer on each call (no caching). /// Use `get()` for repeated full-proto access. pub fn decompress(&self) -> std::io::Result; /// Streaming reader: yields one conversation at a time with bounded /// peak memory. See the `HistorySyncStream` section below. pub fn stream(&self) -> HistorySyncStream<'_>; /// Full decode of the history sync proto, cached via OnceLock. /// Returns `None` if decoding fails. The compressed payload stays /// accessible after this call — `compressed_bytes()`, `decompress()`, /// and `stream()` all keep working. pub fn get(&self) -> Option<&wa::HistorySync>; } ``` **Key characteristics:** * **Metadata without decoding** — `sync_type()`, `chunk_order()`, `progress()`, and `peer_data_request_session_id()` are extracted during the streaming phase and available immediately * **Parse-once semantics** — `get()` decodes the full proto on first call and caches the result via `OnceLock`. With `Arc` dispatch, all handlers share the same `LazyHistorySync` instance. The compressed payload is never consumed — `get()` can be called multiple times and other accessors still work afterward * **Cheap clone** — `Clone` is a refcount bump on the compressed buffer; no decode cache is carried over, so each cloned instance re-inflates independently on demand * **Decompress on demand** — `decompress()` re-inflates into a fresh buffer on every call (no caching). Use `get()` for repeated full-proto access, or `stream()` for memory-bounded incremental access * **Streaming** — `stream()` yields one conversation at a time via `HistorySyncStream`, keeping peak memory near the largest single conversation rather than the full decompressed size * **Serialization** — Only metadata (sync\_type, chunk\_order, progress, peer\_data\_request\_session\_id) is serialized, not the blob * **On-demand correlation** — `peer_data_request_session_id()` is set only on syncs the server pushes in response to `fetchMessageHistory` / `requestPlaceholderResend`. Server-initiated syncs (initial bootstrap, recent, push-name) return `None`. Use it to route the blob back to the request that triggered it. For large `InitialBootstrap` blobs, prefer `stream()` for incremental processing or `decompress()` for one-shot custom decoding. When `decompressed_size()` is large (e.g. > 256 KB), wrap the call in `tokio::task::spawn_blocking` (cloning `compressed_bytes()` into the closure) to avoid blocking the async runtime. **Example — full decode:** ```rust theme={null} Event::HistorySync(lazy_sync) => { println!("History sync type: {}, progress: {:?}", lazy_sync.sync_type(), lazy_sync.progress()); // Full decode (cached after first call) if let Some(history) = lazy_sync.get() { for conv in &history.conversations { println!("Conversation: {:?}", conv.id); } } } ``` **Example — streaming (memory-bounded):** ```rust theme={null} Event::HistorySync(lazy_sync) => { let mut stream = lazy_sync.stream(); while let Some(conv) = stream.next_conversation()? { println!("Conversation: {:?}", conv.id); } // Decode everything that is not a conversation (pushnames, mappings, …) let remainder = stream.remainder()?; println!("Pushnames: {}", remainder.pushnames.len()); } ``` **Example — decompress for custom parsing:** ```rust theme={null} Event::HistorySync(lazy_sync) => { let bytes = lazy_sync.decompress()?; let history = wa::HistorySync::decode(bytes.as_ref())?; } ``` The blob is only retained in memory if event handlers are registered. If no handlers are listening, the history sync pipeline extracts internal data (pushname, NCT salt, TC tokens) and discards the blob without allocating it for event dispatch. ### HistorySyncStream `wacore::history_sync::HistorySyncStream` iterates a compressed blob with bounded memory. At any point, only the current inflate window plus the largest single serialized conversation is resident — the full decompressed blob is never materialized. ```rust theme={null} pub struct HistorySyncStream<'a> { /* … */ } impl<'a> HistorySyncStream<'a> { /// Wrap a compressed blob with a decompressed-size cap. /// Use `MAX_DECOMPRESSED` (64 MB) as the default cap, or /// `LazyHistorySync::decompressed_size()` for an exact-size bound. pub fn new(compressed: &'a [u8], max_decompressed: u64) -> Self; /// Advance to the next conversation, returning its raw bytes. /// Returns `Ok(None)` when all conversations are exhausted. pub fn next_conversation_bytes(&mut self) -> Result, HistorySyncError>; /// Advance to the next conversation, decoding it via buffa. /// Corrupt entries are skipped and counted via `skipped_conversations()`. /// Allocates a fresh `Conversation` per call — a drain loop over /// thousands of entries should prefer `next_conversation_into`. pub fn next_conversation(&mut self) -> Result, HistorySyncError>; /// In-place variant of `next_conversation`: decodes into `conversation`, /// returning `Ok(false)` at clean EOF. buffa's `clear()` retains `Vec` /// capacity, so reusing one struct across a drain loop skips the /// per-conversation message-spine reallocations. Corrupt entries are /// skipped and counted via `skipped_conversations()`, matching the /// behaviour of `next_conversation`. After `Ok(false)` (or an error) /// the struct's contents are unspecified. pub fn next_conversation_into(&mut self, conversation: &mut wa::Conversation) -> Result; /// Number of conversations skipped due to decode errors. pub fn skipped_conversations(&self) -> usize; /// Decode all non-conversation fields (pushnames, mappings, nctSalt, …) /// regardless of wire order. /// /// Must be called after exhausting conversations. Calling it early /// (while unread conversations remain) returns /// `Err(HistorySyncError::UnreadConversations)`. pub fn remainder(self) -> Result; } /// Default decompressed-size cap for `HistorySyncStream::new`. pub const MAX_DECOMPRESSED: u64 = 64 * 1024 * 1024; ``` **Error variants relevant to streaming:** | Variant | When | | --------------------------------------- | -------------------------------------------------------- | | `HistorySyncError::UnreadConversations` | `remainder()` called before conversations were exhausted | | Other I/O variants | Truncated zlib stream, malformed length-delimited field | ### OfflineSyncPreview **Emitted:** Preview of pending offline sync data when reconnecting ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct OfflineSyncPreview { pub total: i32, pub app_data_changes: i32, pub messages: i32, pub notifications: i32, pub receipts: i32, pub calls: i32, pub statuses: i32, } ``` `total` is authoritative — the `` stanza's own `count` attribute — and the per-kind counts are not guaranteed to sum to it. `calls` and `statuses` count the server's `call` and `status` backlog attributes; a server that predates these fields leaves both at `0`. **Example:** ```rust theme={null} Event::OfflineSyncPreview(preview) => { println!("Syncing {} items ({} messages, {} statuses, {} notifications)", preview.total, preview.messages, preview.statuses, preview.notifications); } ``` ### OfflineSyncCompleted **Emitted:** When offline sync completes after reconnection ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct OfflineSyncCompleted { pub count: i32, } ``` **Example:** ```rust theme={null} Event::OfflineSyncCompleted(sync) => { println!("Offline sync completed: {} items processed", sync.count); } ``` ### OfflineSyncInterrupted **Emitted:** When an offline backlog drain ends because its connection was lost, before the `` end marker arrived (added in PR #1380) ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct OfflineSyncInterrupted { pub total: i32, pub delivered: i32, } ``` `total` is what the preceding [`OfflineSyncPreview`](#offlinesyncpreview) announced for this drain; `delivered` is how many offline stanzas were processed before the connection ended. The server owns both numbers, so treat the pair as a progress report rather than an invariant — `delivered` is never larger than `total` in practice, but that isn't guaranteed. This is the counterpart to `OfflineSyncCompleted`, not a variant of it: the drain did not finish, the client is not caught up, and the rest of the backlog is still queued server-side. `delivered` counts stanzas *processed*, not stanzas guaranteed never to come back — what the next connection's `OfflineSyncPreview` actually redelivers follows the pre-existing commit-batch ack contract, unchanged by this event: see [Inbound Durability → Batching](/advanced/inbound-durability#batching) for exactly which batch a mid-drain disconnect does and doesn't redeliver. A consumer that gates "caught up" UI or startup work on `OfflineSyncCompleted` should treat this event as "not caught up yet, wait for the next preview" rather than as completion. Exactly one of `OfflineSyncCompleted` / `OfflineSyncInterrupted` is emitted per resume — never both, and never neither. **Example:** ```rust theme={null} Event::OfflineSyncInterrupted(e) => { println!("Offline resume interrupted: {} of {} items delivered before disconnect", e.delivered, e.total); } ``` Offline sync happens automatically when the client reconnects after being disconnected. The client tracks progress internally and emits these events to notify your application of sync status. If the server stops sending offline stanzas before the end marker arrives, but the connection itself stays up, an inactivity watchdog completes the drain — mirroring WhatsApp Web's own stall timer — and `OfflineSyncCompleted` still fires, with the count of items processed so far. The watchdog re-arms on every stanza, so it fires somewhere between 60 and 120 seconds after the last one rather than at a fixed 60 seconds. If instead the connection ends before the drain finishes, the resume is never left silent: it is reported as `OfflineSyncInterrupted` rather than `OfflineSyncCompleted` (added in PR #1380). This event only reports that the resume ended abnormally — what the next connection's `OfflineSyncPreview` actually redelivers still follows the pre-existing commit-batch ack contract; see [Inbound Durability → Batching](/advanced/inbound-durability#batching). ### DirtyState **Emitted:** When the server sends an `` marker, telling the client one of its cached protocol domains is stale server-side. ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct DirtyState { pub dirty_type: DirtyType, pub timestamp: Option, } ``` **Fields:** * `dirty_type` - The stale domain, mirroring `wacore::iq::dirty::DirtyType`: `AccountSync`, `Groups`, `SyncdAppState`, `NewsletterMetadata`, or `Other(String)` for a wire value the client doesn't otherwise recognize. * `timestamp` - `Option`, `None` if the `` stanza omitted the `timestamp` attribute. This is a pure observability hook — it does not replace or gate the client's built-in handling. The client always sends the matching `` IQ (throttled behind offline-sync completion for `Groups`/`NewsletterMetadata`, per `WAWebHandleDirtyBits`) and, for `SyncdAppState`, re-syncs all app-state collections, exactly as it did before this event existed. `DirtyState` fires first, right before that built-in work starts, so a handler can refresh its own domain-specific derived state (e.g. invalidate a local groups cache) without parsing raw `` stanzas via [`RawNode`](#raw-stanza-events) or racing the client's own resync. **Example:** ```rust theme={null} use wacore::iq::dirty::DirtyType; Event::DirtyState(DirtyState { dirty_type, timestamp, .. }) => { match dirty_type { DirtyType::Groups => println!("groups cache is stale (as of {timestamp:?})"), DirtyType::SyncdAppState => println!("app-state re-sync incoming"), other => println!("dirty: {other:?} at {timestamp:?}"), } } ``` ### AppStateSyncFailed **Emitted:** When a batched app-state sync (fetching `syncd` collections like `critical_block`, `regular_high`) finishes without leaving every requested collection synced. ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct AppStateSyncFailed { pub fatal: Vec, pub retryable: Vec, pub skipped: Vec, pub connected: bool, } ``` **Fields:** * `fatal` - Collections the server refused outright (the syncd IQ came back with an IQ-level error code such as `400`/`404`, not an HTTP status). Terminal for the current connection — repeating the request on it gets the same answer — but not permanent: a fresh connection gets to try again. * `retryable` - Collections that didn't sync but a later attempt can. The client retries these itself, backing off from 1 second and doubling (a theoretical one-hour cap that its round limit never lets it reach), for up to `APP_STATE_RETRY_MAX_ROUNDS` (8) rounds — about four minutes — before giving up and reporting one more `AppStateSyncFailed` for whatever is still unsynced. A handler doesn't need to trigger a retry itself. * `skipped` - Collections another in-flight sync or patch send already held, so this particular sync did nothing for them. Not an error, and not itself re-queued into the retry schedule above — the equivalent work is happening elsewhere and will report its own outcome. * `connected` - Whether the client was already connected (or went on to dispatch [`Event::Connected`](#connected)) when this event fired, rather than an indicator of which bucket is non-empty. As of PR #1291, during the initial bootstrap it's `true` for every outcome that reaches an answer — synced-with-gaps, fatal, retryable, or skipped, including a batch that failed transport-side before producing any per-collection buckets. It's `false` when the client was paused, asked to disconnect, or the server rejected the session (429/503), between the sync finishing and the announcement: `Connected` is withheld but this event still fires, and the leftover collections still go to the background sync either way. If the connection's generation was retired instead — a replacement connection already took over — neither event fires for this one at all; the replacement reports for itself once its own sync finishes. Outside the bootstrap, background syncs always run on an already-connected client, so `connected` is `true` there regardless of which buckets are populated. Collections are identified by their wire name (`critical_block`, `critical_unblock_low`, `regular_high`, `regular_low`, `regular`) rather than an enum, so this payload stays stable if the set of collections changes. `fatal` is the bucket a consumer usually has to act on. WhatsApp Web treats a fatal `critical_block` failure as grounds to notify the primary device and log out; this library does not end a session on its own. Instead, during the initial connection bootstrap, a fatal `critical_block` outcome makes the client stop waiting on it, dispatch `Event::Connected` anyway, and then dispatch this event with `connected: true` — the account is usable but missing whatever that collection carries. A `fatal` outcome does not retry on its own connection (WhatsApp Web's `COMPANION_SYNCD_SNAPSHOT_FATAL_RECOVERY` path is explicitly gated off for `critical_block`, and this client implements no alternative for it); the collection gets another chance only on the account's next fresh connection. `critical_block` includes the `setting_pushName` mutation, so unless the push name was already known some other way (e.g. `Bot::with_push_name`, or a name learned earlier via history sync or a prior session), presence stays unavailable until that next connection syncs it. See [Critical app-state sync (pairing bootstrap)](/concepts/architecture#critical-app-state-sync-pairing-bootstrap) for the full bootstrap flow. As of PR #1291, a `retryable` or `skipped` outcome during the bootstrap is no longer silent either: the client dispatches `Connected` and this event (`connected: true`) the same way it does for `fatal`, then hands the leftovers to the background sync that follows. Both buckets get one more attempt as part of that sync's own batched request; whatever is still `retryable` after that enters the bounded backoff described above, and a `skipped` collection is handled the same way the `skipped` field description above does outside the bootstrap too. A handler that only reacted to `fatal` before should now also watch `retryable`/`skipped` if it needs to know the account is running in a degraded state, since both can now arrive alongside a `Connected` that announced a session missing its push name or blocklist. A background sync outside the bootstrap path (the `ib` dirty-resync handler, group `server_sync` notifications, periodic app-state resync) reports through this same event when it leaves collections unsynced, always with `connected: true` since the client was already connected before that sync ran. Only its `retryable` collections back off and retry automatically; any `fatal` collection it reports is subject to the same next-connection-only recovery as the bootstrap path. **Example:** ```rust theme={null} Event::AppStateSyncFailed(AppStateSyncFailed { fatal, retryable, connected, .. }) => { if !fatal.is_empty() { eprintln!("app-state collections unsynced on this connection: {fatal:?}"); } if !retryable.is_empty() { println!("app-state collections retrying in background: {retryable:?}"); } if connected { println!("connected anyway, degraded until the above resolves"); } } ``` ### ClientExpirationChanged **Emitted:** When the server sends (or withdraws) an `` marker — the date it expects to stop accepting the client build currently running. Fires only when the *stored* `expires_at` resolves to a different value than what was already held; a `t` the server resends that resolves to the same stored deadline is silent, so a handler wired to alert on this event doesn't fire on every reconnect just because the server repeats itself. That's not the same as "the same `t` is always silent" — see the note below. ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct ClientExpirationChanged { pub expires_at: Option, pub version: (u32, u32, u32), pub withdrawn: bool, } ``` **Fields:** * `expires_at` - Unix seconds after which the server expects to stop accepting this build. `None` when the deadline was withdrawn (`withdrawn: true`). * `version` - The `(primary, secondary, tertiary)` build the deadline was issued against — a deadline issued for one build says nothing about the next, so this is how a consumer checks the announcement still applies to the running binary. * `withdrawn` - `true` when the server retracted a deadline it had previously set (the `` child arrived with no `t` attribute). This is **notice, not an instruction**. The client keeps connecting until the server actually refuses it — the stanza is about the *build*, not the current connection — so whether to ship a newer version or alert an operator is the consumer's call; this library never disconnects on its own because of it. The recorded deadline is never sooner than **three days** out, even when the server's own answer is more abrupt (`t` at or before now) — a server that says "now" still has to leave a window in which the build can be replaced. The raw `t` is only acted on when it's sooner than the `expires_at` already stored — a `t` at or after that is treated as a stale retransmit or a host that hasn't caught up, and is ignored rather than granted as an extension. That gate gets re-evaluated against "now" each time, though, so it does *not* mean the stored deadline can only move earlier over time: a server that keeps resending the same already-elapsed `t` keeps clearing the gate (it stays "sooner than stored") and each acceptance re-floors three days out from the new "now" — pushing the stored deadline later each time even though `t` itself never changed. A dated deadline in the future, by contrast, settles once stored: restating it no longer clears the gate, so repeats change nothing and dispatch nothing. See [`ServerClientExpiration`](/concepts/storage#serverclientexpiration) for the persisted record and the full decision rule. **Example:** ```rust theme={null} Event::ClientExpirationChanged(ClientExpirationChanged { expires_at, version, withdrawn, .. }) => { if withdrawn { println!("client expiration deadline withdrawn for build {version:?}"); } else if let Some(expires_at) = expires_at { println!("build {version:?} will stop being accepted at {expires_at}"); } } ``` ## Device Events ### DeviceListUpdate **Emitted:** When a user's device list changes (a companion device is added, removed, or updated) ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct DeviceListUpdate { pub user: Jid, pub lid_user: Option, pub update_type: DeviceListUpdateType, pub devices: Vec, pub key_index: Option, pub contact_hash: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum DeviceListUpdateType { Add, Remove, Update, } #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct DeviceNotificationInfo { pub device_id: u32, pub key_index: Option, } ``` **Fields:** | Field | Description | | -------------- | ------------------------------------------------------------------------- | | `user` | The user whose device list changed (PN JID) | | `lid_user` | The user's LID JID, if known from the notification | | `update_type` | Whether a device was added, removed, or updated | | `devices` | List of affected devices with their IDs and key indexes | | `key_index` | ADV key index info for device identity verification (add operations only) | | `contact_hash` | Server-side contact hash for update operations | This event is dispatched after the client has already patched its internal device registry cache. You can use it to track when contacts pair or unpair companion devices. The client also uses device list changes internally to manage [unknown device detection](/advanced/signal-protocol#unknown-device-detection), Signal session cleanup, and sender key cache invalidation — when a device is added or removed, the sender key device cache is invalidated so SKDM is redistributed on the next group message. ### IdentityChange **Emitted:** When a contact reinstalls WhatsApp (their identity key changed). The event fires from two paths: an explicit server `` notification, or a locally-detected change discovered while decrypting an incoming message. The `implicit` field distinguishes them. ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct IdentityChange { /// The user whose identity changed pub user: Jid, /// Optional LID for the user pub lid_user: Option, /// `true` when detected locally during decrypt (mirrors WA Web /// `saveIdentity` -> `handleNewIdentity`), `false` when triggered by the /// server's `` notification. pub implicit: bool, } ``` **Fields:** * `user` — The phone number JID of the user whose identity changed * `lid_user` — The user's LID JID, if provided in the notification * `implicit` — `false` for server-pushed `` notifications (full cleanup performed); `true` for locally-detected changes during decrypt (lighter cleanup, see below) This event corresponds to WhatsApp Web's `WAWebHandleIdentityChange` flow. When the server sends an `` notification inside a `type="encrypt"` stanza, the client: 1. Clears the device record for the user (deletes Signal sessions for all non-primary devices). Per-device sender key tracking is **not** wiped here — matching WhatsApp Web's `WAWebUpdateLocalSignalSession`, SKDM redistribution is driven per-group/per-device by retry receipts (`markForgetSenderKey`), so a global wipe would empty the tracker too aggressively. 2. Deletes the primary device session and identity key so a fresh session can be established (matching WhatsApp Web's `deleteRemoteInfo`) 3. Deletes the `status@broadcast` sender key for forward secrecy on the next status send (matching WhatsApp Web's `markStatusSenderKeyRotate`) 4. Invalidates the device registry cache so the next send triggers a fresh device list sync 5. Dispatches this event so your application can show a "security code changed" notice 6. Spawns a background `ensure_e2e_sessions` task to proactively re-establish the session (self-defers when the client is offline) Additionally, when a message triggers an `UntrustedIdentity` error during decryption (indicating the sender reinstalled WhatsApp), the client: 7. Clears the old identity key and retries decryption with the new identity, preserving the old session for in-flight messages 8. Handles `InvalidPreKeyId` errors in the retry path by sending a retry receipt so the sender can establish a new session 9. Re-issues [TC tokens](/api/tctoken) for the sender in the background (matching WhatsApp Web's `sendTcTokenWhenDeviceIdentityChange` behavior) so the contact retains a valid privacy token The notification is processed immediately even when received during offline sync, because all cleanup operations are local-only. The background session re-establishment self-defers via `wait_for_offline_delivery_end` when the client is offline. #### Implicit (locally-detected) identity changes The client also fires `IdentityChange` with `implicit: true` when decrypting a peer's message replaces an existing identity key with a different one — for example, when a contact's reinstall reaches you through an incoming message before the server `` push arrives. This mirrors WhatsApp Web's `saveIdentity` → `handleNewIdentity` flow. The implicit path is deliberately lighter than the server push: * Clears the device record (non-primary sessions + per-device sender key tracking) * Invalidates the device registry cache so the next send re-runs usync * Re-issues an active [TC token](/api/tctoken) if one exists It does **not** delete the primary session, rotate the `status@broadcast` sender key, or proactively re-establish sessions — the in-flight message is already establishing a new session, and the heavier reset is handled when the server `` push reliably follows. Identity change notifications from companion devices (device ID != 0) and from your own JID are ignored on both paths — only primary device identity changes for other users are processed. **Example:** ```rust theme={null} Event::IdentityChange(change) => { if change.implicit { // Detected while decrypting an incoming message — the server // push will usually follow with the full reset. println!("Local identity change for {}", change.user); } else { println!("Security code changed for user {}", change.user); } if let Some(lid) = &change.lid_user { println!("LID: {}", lid); } // Show a "security code changed" notice in the chat } ``` ### BusinessStatusUpdate **Emitted:** When a business account status changes ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct BusinessStatusUpdate { pub jid: Jid, pub update_type: BusinessUpdateType, pub timestamp: DateTime, pub target_jid: Option, pub hash: Option, pub verified_name: Option, pub product_ids: Vec, pub collection_ids: Vec, pub subscriptions: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum BusinessUpdateType { RemovedAsBusiness, VerifiedNameChanged, ProfileUpdated, ProductsUpdated, CollectionsUpdated, SubscriptionsUpdated, Unknown, } ``` ## Newsletter Events ### NewsletterLiveUpdate **Emitted:** When reaction counts change or messages are updated on a newsletter you're subscribed to (via `subscribe_live_updates`). ```rust theme={null} #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct NewsletterLiveUpdate { pub newsletter_jid: Jid, pub messages: Vec, } #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct NewsletterLiveUpdateMessage { pub server_id: u64, pub reactions: Vec, } #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct NewsletterLiveUpdateReaction { pub code: String, pub count: u64, } ``` **Fields:** * `newsletter_jid` — The newsletter channel this update is for * `messages` — List of messages with updated reaction counts * `server_id` — Server-assigned message ID * `reactions` — Current reaction counts (emoji code and count) **Example:** ```rust theme={null} Event::NewsletterLiveUpdate(update) => { println!("Newsletter {} updated:", update.newsletter_jid); for msg in &update.messages { for r in &msg.reactions { println!(" Message {}: {} x{}", msg.server_id, r.code, r.count); } } } ``` You must call `client.newsletter().subscribe_live_updates(&jid)` to receive these events. The subscription has a limited duration (typically 300 seconds) and must be renewed periodically. ## Call Events ### IncomingCall **Emitted:** When the server delivers a `` stanza — voice or video, 1-on-1 or group. Mirrors WhatsApp Web's inbound call signaling. ```rust theme={null} #[derive(Debug, Clone, Serialize)] pub struct IncomingCall { pub from: Jid, /// Stanza id; distinct from `CallAction::call_id`. pub stanza_id: String, pub notify: Option, pub platform: Option, pub version: Option, #[serde(with = "chrono::serde::ts_seconds")] pub timestamp: DateTime, pub offline: bool, pub action: CallAction, /// The rotation the sending device announced on this stanza's `