Skip to content
173 changes: 111 additions & 62 deletions advanced/inbound-durability.mdx

Large diffs are not rendered by default.

70 changes: 40 additions & 30 deletions api/bot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The Bot is the **recommended way** to use whatsapp-rust. It provides sensible de
```rust
use whatsapp_rust::bot::Bot;
use whatsapp_rust::TokioRuntime;
use wacore::types::events::Event;
use wacore::types::events::{Event, InboundMessage};

let mut bot = Bot::builder()
.with_backend(backend)
Expand All @@ -34,8 +34,10 @@ let mut bot = Bot::builder()
.with_runtime(TokioRuntime)
.on_event(|event, client| async move {
match &*event {
Event::Message(msg, info) => {
println!("Message from {}: {:?}", info.source.sender, msg);
Event::Messages(batch) => {
for InboundMessage { message: msg, info } in batch.iter() {
println!("Message from {}: {:?}", info.source.sender, msg);
}
}
Event::Connected(_) => {
println!("Connected to WhatsApp!");
Expand Down Expand Up @@ -230,13 +232,15 @@ use waproto::whatsapp as wa;
Bot::builder()
.on_event(|event, client| async move {
match &*event {
Event::Message(msg, info) => {
Event::Messages(batch) => {
// Reply to messages
let reply = wa::Message {
conversation: Some("Hello back!".to_string()),
..Default::default()
};
let _ = client.send_message(info.source.chat.clone(), reply).await;
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!");
Expand Down Expand Up @@ -264,9 +268,9 @@ Like `on_event`, but registers the handler with a narrowed [`EventInterest`](/co
use wacore::types::events::EventKind;

Bot::builder()
.on_event_for(&[EventKind::Message, EventKind::Connected], |event, client| async move {
.on_event_for(&[EventKind::Messages, EventKind::Connected], |event, client| async move {
match &*event {
Event::Message(msg, info) => { /* … */ }
Event::Messages(batch) => { /* … */ }
Event::Connected(_) => println!("online"),
_ => {}
}
Expand All @@ -279,7 +283,7 @@ Bot::builder()
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
use wacore::types::events::{ChannelEventHandler, Event};
use wacore::types::events::{ChannelEventHandler, Event, InboundMessage};

let mut bot = Bot::builder()
.with_backend(backend)
Expand All @@ -299,8 +303,10 @@ let handle = bot.run().await?;
while let Ok(event) = event_rx.recv().await {
match &*event {
Event::Connected(_) => println!("Connected!"),
Event::Message(msg, info) => {
println!("Message from {}", info.source.sender);
Event::Messages(batch) => {
for InboundMessage { info, .. } in batch.iter() {
println!("Message from {}", info.source.sender);
}
}
_ => {}
}
Expand Down Expand Up @@ -672,7 +678,7 @@ If a [`with_task_instrument`](#with_task_instrument) hook is configured, `run()`

## MessageContext

A convenience helper for message handling. You can construct it from the `Event::Message` components:
A convenience helper for message handling. You can construct it from an `InboundMessage` — the item type carried by `Event::Messages`' `MessageBatch`:

```rust
pub struct MessageContext {
Expand All @@ -683,7 +689,7 @@ pub struct MessageContext {
```

<Note>
Since v0.6 `message` is `Arc<wa::Message>` (was `Box<wa::Message>`). This matches the `Event::Message` payload and lets `from_event` / `from_arc` reuse the bus-dispatched `Arc` with zero deep clones.
Since v0.6 `message` is `Arc<wa::Message>` (was `Box<wa::Message>`). This matches the `InboundMessage` payload and lets `from_inbound` / `from_arc` reuse the bus-dispatched `Arc` with zero deep clones.
</Note>

### from_parts
Expand All @@ -700,15 +706,15 @@ Constructs a `MessageContext` from individual message components. Internally clo
pub fn from_arc(message: Arc<wa::Message>, info: &MessageInfo, client: Arc<Client>) -> Self
```

Constructs a `MessageContext` from an existing `Arc<wa::Message>` without copying the body — pair this with the `Arc` you receive from `Event::Message` to keep dispatch zero-clone.
Constructs a `MessageContext` from an existing `Arc<wa::Message>` without copying the body — pair this with the `Arc` you receive from an `InboundMessage` to keep dispatch zero-clone.

### from_event
### from_inbound

```rust
pub fn from_event(event: &Event, client: Arc<Client>) -> Option<Self>
pub fn from_inbound(inbound: &InboundMessage, client: Arc<Client>) -> Self
```

Extracts a `MessageContext` from an `Event`. Returns `None` if the event is not an `Event::Message`. Reuses the existing `Arc<wa::Message>` rather than cloning the body.
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<wa::Message>` rather than cloning the body. 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

Expand Down Expand Up @@ -737,7 +743,8 @@ use waproto::whatsapp as wa;
use whatsapp_rust::bot::MessageContext;

.on_event(|event, client| async move {
if let Some(ctx) = MessageContext::from_event(&event, client) {
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()),
Expand Down Expand Up @@ -790,8 +797,9 @@ Internally this calls [`Client::send_reaction`](/api/send#send_reaction) with `s
use whatsapp_rust::bot::MessageContext;

.on_event(|event, client| async move {
if let Some(ctx) = MessageContext::from_event(&event, client) {
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;
}
})
Expand All @@ -809,7 +817,7 @@ Newsletter (channel) messages don't flow through `MessageContext::react`. Use [`
use whatsapp_rust::bot::Bot;
use whatsapp_rust::TokioRuntime;
use whatsapp_rust::store::SqliteStore;
use wacore::types::events::Event;
use wacore::types::events::{Event, InboundMessage};
use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory;
use whatsapp_rust_ureq_http_client::UreqHttpClient;
use waproto::whatsapp as wa;
Expand All @@ -829,14 +837,16 @@ async fn main() -> anyhow::Result<()> {
.skip_history_sync() // Bot only needs new messages
.on_event(|event, client| async move {
match &*event {
Event::Message(msg, info) => {
Event::Messages(batch) => {
// Echo messages back
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;
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(_) => {
Expand Down
8 changes: 6 additions & 2 deletions api/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1609,15 +1609,19 @@ Registers an event handler for protocol events.

**Example:**
```rust
use wacore::types::events::{Event, EventHandler};
use wacore::types::events::{Event, EventHandler, InboundMessage};
use std::sync::Arc;

struct MyHandler;

impl EventHandler for MyHandler {
fn handle_event(&self, event: Arc<Event>) {
match &*event {
Event::Message(msg, info) => println!("New message from {}: {:?}", info.source.sender, msg),
Event::Messages(batch) => {
for InboundMessage { message: msg, info } in batch.iter() {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
println!("New message from {}: {:?}", info.source.sender, msg);
}
}
Event::Connected(_) => println!("Connected!"),
_ => {}
}
Expand Down
2 changes: 1 addition & 1 deletion api/polls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ The `wacore::poll` module exposes the cryptographic primitives used internally:
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.

<Note>
**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::Message`](/concepts/events#message) 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.
**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.
</Note>

```rust
Expand Down
2 changes: 1 addition & 1 deletion api/signal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ When you message Meta AI or another `@bot` account, the bot's replies arrive as
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::Message`](/concepts/events#message) — 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.
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 `<ack class="message">` 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.
Expand Down
9 changes: 7 additions & 2 deletions concepts/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,8 @@ Incoming stanzas are decoded as `Arc<OwnedNodeRef>` (zero-copy from the network
pub async fn handle_message(client: &Arc<Client>, node: &Arc<OwnedNodeRef>) {
// 1. Extract encrypted message from NodeRef
// 2. Decrypt via Signal Protocol
// 3. Dispatch Event::Message
// 3. Commit (or accumulate into a batch during the offline drain)
// 4. Dispatch Event::Messages
}
```

Expand Down Expand Up @@ -421,6 +422,10 @@ pub(crate) struct OfflineSyncMetrics {

**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.

<Note>
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.
</Note>

**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
Expand All @@ -435,7 +440,7 @@ This prevents a critical issue where `pkmsg` messages (which carry Sender Key Di
**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 `<enc type>`, known-but-empty `<enc>` 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 `<stream:error>` 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 `<stream:error>` on the ack.

<Note>
The Meta AI bot's `msmsg` (`<enc type="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::Message`s; the drain still covers genuinely unrecognized or undecryptable enc types.
The Meta AI bot's `msmsg` (`<enc type="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.
</Note>

**Nack on terminal decrypt failure:** When a ciphertext exhausts retries (`max-retry` reached in the PDO recovery state machine), the client now emits a `<nack reason="…">` 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).
Expand Down
Loading