> ## Documentation Index
> Fetch the complete documentation index at: https://personal-9eca1d6c-claude-nifty-bohr-567oke.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Receiving Messages

> Learn how to handle incoming messages, decrypt content, and send receipts in whatsapp-rust

## Overview

This guide covers message event handling, decryption, and receipt management in whatsapp-rust.

## Event System

### Subscribing to Events

Use the typed registrars on `BotBuilder` — they extract the relevant payload before calling your handler, so you never pattern-match on `Arc<Event>` for the common cases:

```rust theme={null}
use whatsapp_rust::prelude::*;

let bot = Bot::builder()
    .with_backend(SqliteStore::new("whatsapp.db").await?)
    .on_qr_code(|code, _timeout| async move {
        println!("Scan to pair:\n{code}");
    })
    .on_message(|ctx| async move {
        println!("📨 Message from: {}", ctx.info.source.sender);
        println!("💬 Text: {:?}", ctx.message.text_content());
    })
    .on_connected(|_client| async {
        println!("✅ Connected!");
    })
    .on_logged_out(|_info| async {
        eprintln!("Logged out!");
    })
    .build()
    .await?;
```

Available typed registrars: `on_message`, `on_qr_code`, `on_pair_code`, `on_connected`, `on_logged_out`. All handlers accumulate — registering a second one no longer silently replaces the first.

For events without a typed registrar, use the catch-all `on_event` / `on_event_for`:

```rust theme={null}
.on_event(|event, client| async move {
    match &*event {
        Event::Receipt(receipt) => {
            println!("Receipt for: {:?}", receipt.message_ids);
        }
        _ => {}
    }
})
```

For stateful handlers that hold shared state in `&self`, register a struct implementing `EventHandler` directly:

```rust theme={null}
.with_event_handler(my_stateful_handler)
```

See [Bot API reference](/api/bot#event-handling) for full details.

### Available Events

```rust theme={null}
pub enum Event {
    /// One or more successfully decrypted messages (batch of one on live
    /// traffic, an accumulated batch during the offline drain)
    Messages(MessageBatch),
    
    /// Message that couldn't be decrypted
    UndecryptableMessage(UndecryptableMessage),
    
    /// Client connected to WhatsApp
    Connected(Connected),
    
    /// Client disconnected
    Disconnected(Disconnected),
    
    /// Logged out (session invalidated)
    LoggedOut(LoggedOut),
    
    /// Pairing QR code for scanning
    PairingQrCode { code: String, timeout: Duration },
    
    /// Receipt (delivery, read, played)
    Receipt(Receipt),
    
    // ... other events
}
```

## Message Structure

### MessageInfo

Every message event includes metadata:

```rust theme={null}
use crate::types::message::MessageInfo;

// Access message metadata
println!("Message ID: {}", info.id);
println!("Timestamp: {}", info.timestamp);
println!("Chat: {}", info.source.chat);
println!("Sender: {}", info.source.sender);
println!("From me: {}", info.source.is_from_me);

// Check if it's a group message
if info.source.chat.is_group() {
    println!("Group message from participant: {}", info.source.sender);
}

// Check ephemeral (disappearing) message duration
if let Some(expiration) = info.ephemeral_expiration {
    println!("Disappearing message timer: {}s", expiration);
}

// Check if this message was recovered via PDO
if let Some(request_id) = &info.unavailable_request_id {
    println!("Recovered via PDO request: {}", request_id);
}

// Check if this is a decrypted channel comment (CAG threaded reply)
if let Some(parent_key) = &info.comment_target {
    println!("Comment on post: {:?}", parent_key.id);
}
```

The `ephemeral_expiration` field contains the disappearing messages timer in seconds, extracted from the message's `contextInfo.expiration`. This tells you how long the message will be visible before it auto-deletes. Use this value when sending replies to the same chat via [`SendOptions.ephemeral_expiration`](/api/send#sendoptions).

The `unavailable_request_id` field is set when a message was recovered via PDO rather than normal decryption. It contains the PDO request message ID, which you can use to correlate recovered messages with the original `UndecryptableMessage` event.

The `comment_target` field is set when the dispatched message is a decrypted CAG channel comment. It contains the `MessageKey` of the parent post. The inner `Message` proto has no slot for the threading link, so it surfaces here instead. See [Channel comments](#channel-comments) below.

### Message content extraction

Use the `MessageExt` trait to extract content:

```rust theme={null}
use wacore::proto_helpers::MessageExt;

// Get text content (conversation or extended_text_message)
if let Some(text) = message.text_content() {
    println!("Text: {}", text);
}

// Get media caption
if let Some(caption) = message.get_caption() {
    println!("Caption: {}", caption);
}

// Get base message (unwrap ephemeral/view-once/edited wrappers)
let base = message.get_base_message();

// Check wrapper types
if message.is_ephemeral() {
    println!("This is a disappearing message");
}
if message.is_view_once() {
    println!("This is a view-once message");
}
// `is_view_once()` returns `true` for any of the following:
// - Legacy wrappers: `view_once_message`, `view_once_message_v2`,
//   `view_once_message_v2_extension` (in any nesting order under
//   `device_sent_message` or `ephemeral_message`)
// - Modern inline `view_once` flag on `image_message`, `video_message`,
//   `audio_message`, or `extended_text_message`

// Get the ephemeral timer from the message itself
if let Some(exp) = message.get_ephemeral_expiration() {
    println!("Disappears after {}s", exp);
}

// Access message context info (thread metadata, bot info, etc.)
if let Some(ctx_info) = &message.message_context_info {
    if let Some(thread_id) = &ctx_info.thread_id {
        println!("Thread: {}", thread_id);
    }
}
```

See [WAProto API reference](/api/waproto) for the full message type hierarchy.

## Message Types

### Text Messages

```rust theme={null}
match event {
    Event::Messages(batch) => {
        for InboundMessage { message, info } in batch.iter() {
            // Simple text
            if let Some(text) = &message.conversation {
                println!("Text: {}", text);
            }

            // Extended text (with links, formatting)
            if let Some(ext) = &message.extended_text_message {
                if let Some(text) = &ext.text {
                    println!("Extended text: {}", text);
                }
                if let Some(url) = &ext.matched_text {
                    println!("Contains link: {}", url);
                }
            }
        }
    }
    _ => {}
}
```

### Media Messages

```rust theme={null}
if let Some(img) = &message.image_message {
    println!("📷 Image message");
    if let Some(caption) = &img.caption {
        println!("Caption: {}", caption);
    }
    
    // Download the image
    let data = client.download(img.as_ref()).await?;
    std::fs::write("image.jpg", data)?;
}

if let Some(video) = &message.video_message {
    println!("🎥 Video message");
}

if let Some(audio) = &message.audio_message {
    println!("🎵 Audio message");
    if audio.ptt() {
        println!("This is a voice message");
    }
}

if let Some(doc) = &message.document_message {
    println!("📄 Document: {}", doc.file_name.as_deref().unwrap_or("unknown"));
}

if let Some(sticker) = &message.sticker_message {
    println!("🎨 Sticker");
}
```

See [Media Handling Guide](/guides/media-handling) for download details.

### Reactions

Incoming reactions — including encrypted CAG reactions — are dispatched in the same `reaction_message` shape. Encrypted reactions from Community Announcement Groups are decrypted transparently on the receive path; the `key` field is filled from the envelope's `target_message_key` before dispatch.

```rust theme={null}
if let Some(reaction) = &message.reaction_message {
    if let Some(emoji) = &reaction.text {
        if emoji.is_empty() {
            println!("Reaction removed");
        } else {
            println!("👍 Reaction: {}", emoji);
        }
    }
    
    if let Some(key) = &reaction.key {
        println!("Reacted to message: {:?}", key.id);
    }
}
```

### Channel Comments

Encrypted channel comments from Community Announcement Groups are decrypted transparently and dispatched as `Event::Messages` carrying the comment body. The parent post key surfaces on `MessageInfo::comment_target` (the inner `Message` proto has no slot for the threading link):

```rust theme={null}
Event::Messages(batch) => {
    for InboundMessage { message: msg, info } in batch.iter() {
        if let Some(parent_key) = &info.comment_target {
            // This item is a decrypted CAG channel comment.
            println!("Comment on post: {:?}", parent_key.id);
            println!("Post author:     {:?}", parent_key.participant);

            if let Some(text) = msg.text_content() {
                println!("Comment text: {}", text);
            }
        }
    }
}
```

The comment's own `messageSecret` (carried in the outer envelope) is persisted under the comment's id and sender, so that future encrypted reactions targeting the comment can be decrypted. `comment_target` is `None` for all other message types.

### Quoted Messages

```rust theme={null}
if let Some(ext) = &message.extended_text_message {
    if let Some(context) = &ext.context_info {
        if let Some(quoted) = &context.quoted_message {
            println!("💬 This is a reply");
            println!("Original message ID: {:?}", context.stanza_id);
            println!("Original sender: {:?}", context.participant);
            
            // Access quoted content
            if let Some(quoted_text) = quoted.text_content() {
                println!("Replying to: {}", quoted_text);
            }
        }
    }
}
```

## Message Unwrapping

### DeviceSentMessage handling

When you send a message from one device, other devices receive it as a `DeviceSentMessage` wrapper. The library automatically unwraps this and merges `messageContextInfo` from both the outer envelope and inner message:

```rust theme={null}
// The library handles this automatically - you receive the inner message directly
match event {
    Event::Messages(batch) => {
        for InboundMessage { message, info } in batch.iter() {
            // If this was originally a DeviceSentMessage, the library has:
            // 1. Extracted the inner message content
            // 2. Merged message_context_info from outer + inner
            //    - message_secret: inner value, fallback to outer
            //    - limit_sharing_v2: always from outer
            //    - thread_id: inner if non-empty, otherwise outer
            //    - bot_metadata: inner value, fallback to outer

            // You can safely access the merged context
            if let Some(ctx) = &message.message_context_info {
                println!("Thread: {:?}", ctx.thread_id);
            }
        }
    }
    _ => {}
}
```

<Note>
  Self-sent messages synced from your primary device are automatically unwrapped. The `messageContextInfo` is merged following WhatsApp Web's logic, ensuring metadata like thread IDs and bot metadata are preserved correctly.
</Note>

## Decryption

### Automatic decryption

Messages are automatically decrypted by the client:

```rust theme={null}
// The Event::Messages batch already contains decrypted content
match event {
    Event::Messages(batch) => {
        for InboundMessage { message, .. } in batch.iter() {
            // Message is already decrypted and ready to use
            println!("Decrypted: {:?}", message.conversation);
        }
    }
    _ => {}
}
```

### Undecryptable Messages

When decryption fails, you receive an `UndecryptableMessage` event:

```rust theme={null}
match event {
    Event::UndecryptableMessage(undecryptable) => {
        println!("❌ Could not decrypt message");
        println!("Message ID: {}", undecryptable.info.id);
        println!("From: {}", undecryptable.info.source.sender);
        
        // The client automatically sends retry receipts
        // No manual action needed
    }
    _ => {}
}
```

<Note>
  The client automatically handles decryption retries using the retry receipt mechanism. Failed messages trigger `Event::UndecryptableMessage`, and the client will request re-encryption from the sender.
</Note>

See [Signal Protocol](/advanced/signal-protocol) and [Events reference](/concepts/events) for more details.

### Two-pass decryption model

Group messages arrive with two types of `<enc>` nodes in a single stanza:

1. **Session messages** (`pkmsg`/`msg`) — carry the Sender Key Distribution Message (SKDM) via a pairwise Signal session
2. **Group messages** (`skmsg`) — carry the actual message content, encrypted with the sender key

The client decrypts these in two passes:

1. **Pass 1:** Process session `<enc>` nodes to extract the SKDM, which establishes the sender key for the group.
2. **Pass 2:** Process group `<enc>` nodes using the sender key from Pass 1.

If session messages fail to decrypt, the SKDM they carried is lost. In this case, the client skips `skmsg` decryption entirely (since it would always fail with `NoSenderKey`) and dispatches an `UndecryptableMessage` event. The retry receipt for the session message causes the sender to resend the entire message including the SKDM.

Before looking up or storing sender keys, the client normalizes the sender JID to its bare form (stripping the device component via `to_non_ad()`). This is necessary because WhatsApp delivers `pkmsg` stanzas (carrying SKDM) with a device-qualified participant JID, while `skmsg` stanzas use a bare participant JID. Without normalization, the sender key stored during SKDM processing would not match the key looked up during `skmsg` decryption. See [Sender key address normalization](/advanced/signal-protocol#sender-key-address-normalization) for details.

When a group `skmsg` decryption fails with `NoSenderKeyState` (the sender key is missing or was never received), the client dispatches an `UndecryptableMessage` event **before** spawning the retry receipt. This ensures your application is immediately notified that the message is pending decryption, matching the behavior of the session-based decrypt path.

Exceptions where `skmsg` is still processed even without successful session decryption:

* **No session messages present** — the sender key was already established from a prior message
* **Duplicate session messages** — the SKDM was already processed in a previous delivery

<Note>
  This matches WhatsApp Web's `canDecryptNext` pattern. It prevents unnecessary retry receipts for `skmsg` nodes that can never succeed without the SKDM.

  Beyond `NoSenderKeyState`, a `skmsg` decrypt can also fail with `SignatureValidationFailed`, `InvalidSenderKeySession`, `UnrecognizedMessageVersion`, or `InvalidMessage` (a distinct error variant, separate from `NoSenderKeyState` and `DuplicatedMessage` — a MAC or format failure on an otherwise-recognized sender-key ciphertext) — all recoverable sender-key desyncs (a participant rotated their sender key or re-registered), not corrupt messages. These are classified the same way as `NoSenderKeyState`: the client dispatches `UndecryptableMessage` and sends a retry receipt, which prompts the sender to redistribute the SKDM. Only a genuinely non-Signal error falls through to a terminal NACK, which tells the server to stop retransmitting the stanza. This mirrors WhatsApp Web, which treats every `SignalDecryptionError` on the group path as retryable — the 1:1 decrypt path already applied the same recoverable/terminal split.
</Note>

<Warning>
  Because `pkmsg` messages carry SKDM, silently dropping a `pkmsg` during processing causes all subsequent `skmsg` messages from that sender to fail with `NoSenderKeyState`. The client uses a generation-checked re-acquire loop during the offline-to-online semaphore transition to ensure `pkmsg` messages are never dropped. See [Concurrency gating](/concepts/architecture#offline-sync) for details on how this works.
</Warning>

### Decrypt-fail mode

Each incoming message has a `decrypt_fail_mode` attribute parsed from the `<enc>` nodes:

* **`DecryptFailMode::Show`** — the recipient should show a "waiting for this message" placeholder in the chat
* **`DecryptFailMode::Hide`** — the message should be silently hidden on failure (used for infrastructure messages like reactions, poll votes, pin changes, secret encrypted event/poll edits, message history notices, and certain protocol messages)

If any `<enc>` node in the stanza has `decrypt-fail="hide"`, the entire message uses `Hide` mode. See [Decrypt-fail suppression](/api/send#decrypt-fail-suppression) for which outgoing message types set this attribute.

### Decryption retry mechanism

The library automatically:

1. Detects decryption failures (no session, invalid keys, MAC errors)
2. Sends retry receipts with fresh prekeys
3. Tracks retry count (max 5 attempts)
4. Sends a parallel PDO (Peer Data Operation) request on the first retry
5. Falls back to immediate PDO as last resort when retries are exhausted

```rust theme={null}
// Retry reasons (internal, handled automatically)
enum RetryReason {
    NoSession = 1,        // No session exists
    InvalidKey = 2,       // Invalid key
    InvalidKeyId = 3,     // PreKey ID not found
    InvalidMessage = 4,   // Invalid format or MAC
    // ... other reasons
}
```

### Unavailable message recovery via PDO

When the server delivers a message with an `<unavailable>` child node instead of `<enc>` nodes, the message content is not present in the stanza. The client classifies the `<unavailable>` node into an `UnavailableType`:

* **`ViewOnce`** — a view-once message already viewed on another device (`<unavailable type="view_once">`)
* **`Hosted`** — hosted content the phone does not fan out to companion devices (`<unavailable hosted="true">`, wire-boolean, also matches `hosted="1"`)
* **`Bot`** — an AI bot message fanout, signaled by a sibling `<bot>` child on the stanza
* **`Unknown`** — a plain fanout with none of the above markers; the server just could not deliver the encrypted payload for some other reason

Classification follows WhatsApp Web's own precedence (bot > hosted > view\_once) via `UnavailableType::from_fanout_flags`.

`ViewOnce`, `Hosted`, and `Bot` are exactly the three subtypes WhatsApp Web itself never placeholder-resends (`WAWebNonMessageDataRequestPlaceholderMessageResendUtils` excludes them). The phone won't share that content with a companion device, so a PDO request for them would always come back empty — and would additionally surface a spurious "Finished syncing with WhatsApp on \<device>" notification on the phone for no benefit. The client short-circuits these three: it skips the PDO entirely and acks the stanza directly so the offline queue still drains. `UnavailableType::is_unrecoverable_fanout()` reports `true` for all three.

Only a plain (`Unknown`) fanout is recovered via PDO. The flow for that case is:

1. The client detects the `<unavailable>` node and classifies it as `Unknown`
2. An `UndecryptableMessage` event is dispatched immediately with `is_unavailable: true`
3. A PDO request (`PlaceholderMessageResend`) is sent to your own bare JID (server routes to all devices including device 0)
4. The phone responds with the full `WebMessageInfo` containing the decrypted message
5. The client validates the response came from device 0 (primary phone) and dispatches the recovered message as a normal `Event::Messages` — event-only, bypassing the durability hook and the offline-drain batcher (delivered immediately, `BatchOrigin::Live`)

For `ViewOnce`, `Hosted`, and `Bot`, only steps 1–2 happen: the client dispatches `UndecryptableMessage` and acks immediately. There is no PDO round-trip and no follow-up `Event::Messages` to wait for.

The recovered `MessageInfo` (for the PDO-recovered `Unknown` case) includes `unavailable_request_id` — the PDO request message ID — so you can correlate recovered messages with the original `UndecryptableMessage` event.

```rust theme={null}
Event::UndecryptableMessage(undec) => {
    if undec.is_unavailable {
        match undec.unavailable_type {
            UnavailableType::Unknown => {
                // Content is being requested from your phone via PDO.
                // You'll receive an Event::Messages when the phone responds.
                println!("Unavailable message — requesting from phone");
            }
            UnavailableType::ViewOnce => {
                println!("View-once message already viewed elsewhere — unrecoverable");
            }
            UnavailableType::Hosted => {
                println!("Hosted content unavailable — unrecoverable");
            }
            UnavailableType::Bot => {
                println!("Bot message unavailable — unrecoverable");
            }
        }
    }
}

// When the phone responds to a PDO request (Unknown fanouts only), the
// recovered message includes the request ID:
Event::Messages(batch) => {
    for InboundMessage { info, .. } in batch.iter() {
        if let Some(request_id) = &info.unavailable_request_id {
            println!("Recovered via PDO (request: {})", request_id);
        }
    }
}
```

PDO is also used alongside retry receipts for normal decryption failures. On the first retry attempt, a parallel PDO request is sent with a 500ms delay to give the retry receipt time to resolve first. If all 5 retry attempts are exhausted, an immediate PDO request is sent as a last resort.

<Note>
  PDO requests are deduplicated — if a request is already pending for a given message, subsequent requests are skipped. Pending requests expire after 30 seconds. The deduplication cache uses phone-number JIDs as keys (not LID JIDs) to ensure the cache key matches the JID format in the phone's response.
</Note>

### Sent message retry (outbound)

When a recipient's device cannot decrypt your message, it sends a retry receipt. The client handles this automatically using DB-backed sent message storage:

1. Every `send_message()` persists the serialized message payload to the `sent_messages` database table
2. On retry receipt, the client retrieves the original payload, re-encrypts it for the requesting device, and resends
3. The payload is consumed (deleted) on retrieval to prevent double-retry
4. Expired entries are periodically cleaned up based on `sent_message_ttl_secs` (default: 5 minutes)

This matches WhatsApp Web's `getMessageTable` pattern of reading from persistent storage on retry receipt.

<Note>
  An optional in-memory L1 cache (`recent_messages` in `CacheConfig`) can be enabled for faster retry lookups. When disabled (default, capacity 0), all retry lookups go directly to the database. See [Bot - Cache Configuration Reference](/api/bot#cache-configuration-reference) for details.
</Note>

## Receipts

### Automatic delivery receipts

The client automatically sends delivery receipts for successfully decrypted messages:

```rust theme={null}
// Happens automatically after message decryption
// No manual action needed
```

See [Receipt API reference](/api/receipt) for full details.

### Sending read receipts

```rust theme={null}
// Mark a single message as read (DM)
client.mark_as_read(
    &chat_jid,
    None, // No sender for DMs
    &["msg1"],
).await?;

// Mark multiple messages as read (group)
client.mark_as_read(
    &group_jid,
    Some(&sender_jid), // Must specify sender in groups
    &["msg1", "msg2", "msg3"],
).await?;
```

### Receipt Events

Handle receipt updates from other participants:

```rust theme={null}
match event {
    Event::Receipt(receipt) => {
        println!("📬 Receipt for: {:?}", receipt.message_ids);
        match receipt.r#type {
            ReceiptType::Delivered => println!("Delivered"),
            ReceiptType::Read => println!("Read"),
            ReceiptType::ReadSelf => println!("Read on another device"),
            ReceiptType::Played => println!("Played (voice/video)"),
            ReceiptType::EncRekeyRetry => println!("VoIP call re-keying retry"),
            _ => {}
        }
    }
    _ => {}
}
```

## Advanced Usage

### At-Least-Once Delivery

By default, the client acknowledges a message to the server as soon as it is decrypted. If your process crashes before you persist the message, it is lost — the server will not redeliver it.

Register an `InboundDurabilityHook` to defer the ack until your consumer durably commits the message(s). Live traffic calls the hook with a batch of one; an offline drain hands over an accumulated batch (WhatsApp Web's `MessageProcessorCache` granularity), so the durability cost amortizes over the batch instead of paying a round-trip per message:

```rust theme={null}
use std::sync::Arc;
use whatsapp_rust::prelude::*;
use whatsapp_rust::InboundDurabilityHook;
use async_trait::async_trait;

struct MyStore;

#[async_trait]
impl InboundDurabilityHook for MyStore {
    async fn on_messages(
        &self,
        _client: Arc<Client>,
        batch: &[InboundMessage],
    ) -> anyhow::Result<()> {
        // This loop commits each item independently, so a later item's `?`
        // can leave earlier items already committed while the SDK still
        // redelivers the whole batch on Err. `my_db_insert` MUST be an
        // idempotent upsert (e.g. `INSERT ... ON CONFLICT DO NOTHING`), or
        // wrap the loop in one transaction — see the note below.
        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?)
    .with_inbound_durability_hook(MyStore)
    .build()
    .await?;
```

The hook **must be idempotent** — deduplicate by `(info.source.chat, info.source.sender, info.id)` since a crash after the consumer commits but before the ack lands will replay the message, and a failed batch is redelivered whole.

See [Inbound Durability Hook](/advanced/inbound-durability) for the full contract, batching triggers, caveats, and a worked example.

### Custom encryption handlers

For custom encryption types (e.g., `pkmsg`, `msg`, `skmsg`):

```rust theme={null}
use whatsapp_rust::types::enc_handler::EncHandler;
use async_trait::async_trait;
use std::sync::Arc;

#[derive(Clone)]
struct CustomEncHandler;

#[async_trait]
impl EncHandler for CustomEncHandler {
    async fn handle(
        &self,
        client: Arc<Client>,
        node: &Node,
        info: &Arc<MessageInfo>,
    ) -> Result<(), anyhow::Error> {
        // Custom decryption logic
        println!("Custom encryption type: {:?}", node.attrs().optional_string("type"));
        Ok(())
    }
}

// Register the handler via BotBuilder
let bot = Bot::builder()
    .with_enc_handler("custom_type", CustomEncHandler)
    // ... other configuration
    .build()
    .await?;
```

See [Client API reference](/api/client) for handler registration details.

### Filtering Messages

Use the type-safe JID methods (`is_group()`, `is_broadcast_list()`, `is_status_broadcast()`) to classify messages by chat type. With `on_message`, the `MessageContext` is already available:

```rust theme={null}
use whatsapp_rust::prelude::*;

.on_message(|ctx| async move {
    // Ignore own messages
    if ctx.info.source.is_from_me {
        return;
    }

    // Only handle group messages
    if !ctx.info.source.chat.is_group() {
        return;
    }

    // Only handle text messages
    if let Some(text) = ctx.message.text_content() {
        println!("Group text: {text}");
    }
})
```

### Session and key management

The library automatically manages Signal Protocol sessions:

```rust theme={null}
// Sessions are established automatically when:
// - Receiving PreKeySignalMessage (pkmsg)
// - Receiving SignalMessage (msg) for existing sessions
// - Receiving SenderKeyDistributionMessage for groups

// No manual session management needed!
```

For advanced cases (identity changes, session cleanup):

```rust theme={null}
// The library handles identity changes automatically:
// - Detects UntrustedIdentity errors during decryption
// - Clears the old identity key (but preserves the session for in-flight messages)
// - Retries decryption with the new identity
// - On InvalidPreKeyId, attempts PN→LID session migration before requesting a retry
// - Re-issues TC tokens so the contact retains a valid privacy token
```

When a contact reinstalls WhatsApp, you'll receive an [`IdentityChange`](/concepts/events#identitychange) event after the client has completed all session cleanup. The client also re-issues [TC tokens](/api/tctoken#identity-change-reissuance) in the background to maintain privacy token continuity.

See [Signal Protocol](/advanced/signal-protocol) for more on session management.

## Error Handling

```rust theme={null}
.on_event(|event, client| async move {
    match &*event {
        Event::Messages(batch) => {
            // Process each message in the batch
            for InboundMessage { message, info } in batch.iter() {
                if let Err(e) = process_message(message, info, client.clone()).await {
                    eprintln!("Error processing message {}: {:?}", info.id, e);
                }
            }
        }
        Event::UndecryptableMessage(undecryptable) => {
            eprintln!("⚠️  Undecryptable message from {}", 
                undecryptable.info.source.sender);
            // Client automatically handles retries
        }
        _ => {}
    }
})

async fn process_message(
    message: &wa::Message,
    info: &Arc<MessageInfo>,
    client: Arc<Client>,
) -> Result<()> {
    // Your message processing logic
    Ok(())
}
```

## Best Practices

<Steps>
  ### Use the bot API for event handling

  The Bot API provides a clean interface for message handling:

  ```rust theme={null}
  let bot = Bot::builder()
      .on_event(|event, client| async move {
          // Handle events here
      })
      .build()
      .await?;
  ```

  ### Extract content with helper methods

  ```rust theme={null}
  use wacore::proto_helpers::MessageExt;

  // Use helper methods instead of manual field access
  let text = message.text_content();
  let caption = message.get_caption();
  let base = message.get_base_message();
  ```

  ### Handle all event types

  Always handle critical events:

  ```rust theme={null}
  match &*event {
      Event::Messages(batch) => { /* ... */ }
      Event::Connected(_) => { /* Initialize */ }
      Event::Disconnected(_) => { /* Cleanup */ }
      Event::LoggedOut(_) => { /* Re-authenticate */ }
      _ => {}
  }
  ```

  ### Don't block event handlers

  Spawn tasks for long-running operations:

  ```rust theme={null}
  .on_event(|event, client| async move {
      if matches!(&*event, Event::Messages(_)) {
          // Spawn task for heavy processing — Arc clone is O(1)
          let client = client.clone();
          let event = event.clone();
          tokio::spawn(async move {
              for InboundMessage { message, info } in event.messages() {
                  process_heavy_task(message, info, client.clone()).await;
              }
          });
      }
  })
  ```
</Steps>

## Next Steps

* [Sending Messages](/guides/sending-messages) - Send text, reactions, channel comments, and replies
* [Media Handling](/guides/media-handling) - Download and process media
* [Group Management](/guides/group-management) - Handle group events
* [Community management](/guides/communities) - CAG reactions and channel comments
* [Inbound Durability Hook](/advanced/inbound-durability) - At-least-once message delivery
