> ## Documentation Index
> Fetch the complete documentation index at: https://whatsapp-rust.jlucaso.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sending Messages

> Learn how to send text messages, reactions, channel comments, quoted replies, album messages, sticker packs, and edit messages in whatsapp-rust

## Overview

This guide covers sending messages, including text, reactions, channel comments, quotes, album messages (grouped media), sticker packs, and message editing operations using the whatsapp-rust library.

## Text message shortcuts

For the common case of sending or replying with plain text, prefer the high-level helpers:

### `MessageContext` reply helpers (inside event handlers)

When you're already inside an `on_message` handler:

```rust theme={null}
.on_message(|ctx| async move {
    // Reply without quoting
    if let Err(e) = ctx.reply("pong").await {
        eprintln!("Failed to reply: {e}");
    }

    // Reply quoting the received message
    if let Err(e) = ctx.reply_quoting("pong").await {
        eprintln!("Failed to reply: {e}");
    }
})
```

### `Client::send_text` (from any `Client` reference)

```rust theme={null}
client.send_text(&chat_jid, "Hello from whatsapp-rust!").await?;
```

### `wa::Message::text` / `wa::Message::text_with_context`

Build a text message without hand-assembling the protobuf struct:

```rust theme={null}
use whatsapp_rust::prelude::*; // re-exports MessageBuilderExt

// Plain text — WA Web sends this as `conversation`
let msg = wa::Message::text("Hello!");

// Text with a quote/context — switches to `extendedTextMessage` internally
let msg_with_quote = wa::Message::text_with_context("Replying!", ctx.build_quote_context());
```

These are equivalent to building `wa::Message { conversation: Some(...) }` and `wa::Message { extended_text_message: buffa::MessageField::some(...) }` by hand, but less verbose.

***

## Sending text messages

### Simple text message

Use the `conversation` field for plain text messages, or `wa::Message::text("...")` for shorter syntax:

```rust theme={null}
use waproto::whatsapp as wa;
use wacore_binary::jid::Jid;

let to: Jid = "1234567890@s.whatsapp.net".parse()?;

let message = wa::Message {
    conversation: Some("Hello, WhatsApp!".to_string()),
    ..Default::default()
};

let result = client.send_message(&to, message).await?;
println!("Message sent with ID: {}", result.message_id);
```

`send_message` returns a `SendResult` containing the `message_id` and recipient `to` JID. Use `result.message_key()` to get a `wa::MessageKey` for follow-up operations like album child linking.

### Extended text message

For messages with formatting, links, or context (replies/quotes):

```rust theme={null}
use waproto::whatsapp::message::ExtendedTextMessage;

let message = wa::Message {
    extended_text_message: buffa::MessageField::some(ExtendedTextMessage {
        text: Some("Check out this link: https://example.com".to_string()),
        matched_text: Some("https://example.com".to_string()),
        title: Some("Example Site".to_string()),
        description: Some("A sample website".to_string()),
        ..Default::default()
    }),
    ..Default::default()
};

let result = client.send_message(to, message).await?;
```

## Quoted Replies

### Replying to a message

Use `build_quote_context` to create a basic reply:

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

// Assume you received the original message
let original_message: wa::Message = /* ... */;
let original_message_id = "3EB0ABC123";
let original_sender = "1234567890@s.whatsapp.net";

// Build quote context
let context = build_quote_context(
    original_message_id,
    original_sender,
    &original_message,
);

// Create reply message
let reply = wa::Message {
    extended_text_message: buffa::MessageField::some(ExtendedTextMessage {
        text: Some("This is my reply".to_string()),
        context_info: buffa::MessageField::some(context),
        ..Default::default()
    }),
    ..Default::default()
};

let result = client.send_message(to, reply).await?;
```

### Cross-platform quoted replies with remoteJid

For quoted replies that display correctly on all platforms (including iOS), use `build_quote_context_with_info`. It matches WhatsApp Web's behavior: it sets `participant` correctly (channel JID for newsletters, sender for everything else) and emits `remote_jid` only for cross-chat quotes — when the quoted message lives in a different chat than the one you're sending to.

The function takes both the quoted message's chat (`quoted_chat_jid`) and the chat you're sending into (`target_chat_jid`). For an in-place reply these are the same JID, and `remote_jid` is omitted; for a cross-chat quote (for example, quoting a status update into a DM) they differ, and `remote_jid` is set to the quoted chat:

```rust theme={null}
use wacore::proto_helpers::{build_quote_context_with_info, MessageExt};
use wacore_binary::jid::Jid;

let original_message: wa::Message = /* ... */;
let original_message_id = "3EB0ABC123";
let original_sender: Jid = "1234567890@s.whatsapp.net".parse()?;
let chat_jid: Jid = "120363040237990503@g.us".parse()?; // group or DM JID

// Same-chat reply: quoted_chat and target_chat are identical, so remote_jid is omitted.
let context = build_quote_context_with_info(
    original_message_id,
    &original_sender,
    &chat_jid, // quoted_chat_jid
    &chat_jid, // target_chat_jid
    &original_message,
);

let reply = wa::Message {
    extended_text_message: buffa::MessageField::some(ExtendedTextMessage {
        text: Some("This is my reply".to_string()),
        context_info: buffa::MessageField::some(context),
        ..Default::default()
    }),
    ..Default::default()
};

let result = client.send_message(chat_jid, reply).await?;
```

The `build_quote_context_with_info` function handles two important details:

* **`remote_jid`** is set only when `quoted_chat_jid` and `target_chat_jid` refer to different chats (cross-chat quote). For same-chat replies it is omitted, matching WhatsApp Web.
* **`participant`** is set to the sender JID for normal chats, or the newsletter JID for newsletter quotes.

<Note>
  Pass the same JID for both `quoted_chat_jid` and `target_chat_jid` when replying in-place. Use different JIDs only when forwarding a quote across chats (for example, quoting a status into a DM).
</Note>

### Setting context on media messages

You can add quote context to any message type using `set_context_info`:

```rust theme={null}
use waproto::whatsapp::message::ImageMessage;

let mut reply = wa::Message {
    image_message: buffa::MessageField::some(ImageMessage {
        url: Some("https://mmg.whatsapp.net/...".to_string()),
        mimetype: Some("image/jpeg".to_string()),
        caption: Some("Here's an image reply".to_string()),
        // ... other image fields
        ..Default::default()
    }),
    ..Default::default()
};

// Build and set context (use build_quote_context_with_info for cross-platform support)
let context = build_quote_context_with_info(
    original_message_id,
    &original_sender,
    &chat_jid, // quoted_chat_jid
    &chat_jid, // target_chat_jid
    &original_message,
);

reply.set_context_info(context);

let result = client.send_message(chat_jid, reply).await?;
```

## Reactions

### Sending a reaction

Use `client.send_reaction()` to react to a DM, group, or `status@broadcast` message. The helper builds the `ReactionMessage` payload, stamps `sender_timestamp_ms`, and routes the stanza through the standard send path.

```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(message_id_to_react_to.to_string()),
    // Required for groups and status@broadcast; leave None for DMs.
    participant: Some(sender_jid.to_string()),
};

let result = client.send_reaction(&chat_jid, target_key, "👍").await?;
```

For groups and `status@broadcast`, `target_key.participant` must point to the original sender so the receipt can be attributed. In DMs, leave `participant` as `None`.

If you're already inside an event handler, [`MessageContext::react`](/api/bot#react) fills in `chat`, `target_key`, and `participant` from the incoming message automatically:

```rust theme={null}
use whatsapp_rust::bot::MessageContext;

for inbound in event.messages() {
    let ctx = MessageContext::from_inbound(inbound, client.clone());
    ctx.react("❤️").await?;
}
```

### Reactions in Community Announcement Groups

Community Announcement Groups (CAGs) — the default announcement subgroup of a community — require encrypted reactions. `send_reaction` handles this transparently: it detects CAG chats automatically and sends the reaction as an encrypted `enc_reaction_message` envelope instead of a plaintext stanza.

No change to your call is needed. The only requirement is that the target post's `messageSecret` was captured when the post was received. If it was not captured (for example, `msg_secret_policy` is disabled without a resolver, or the post arrived before the current session), the call returns an error rather than emitting a plaintext reaction the channel would reject.

```rust theme={null}
// Works for DMs, regular groups, and CAGs — no API difference.
client.send_reaction(&cag_jid, target_key, "🔥").await?;
```

See [Community management — CAG reactions](/guides/communities#community-announcement-group-cag-reactions) for details.

### Removing a reaction

Pass an empty `emoji` to revoke a previous reaction (matches WhatsApp Web's empty-text-as-revoke semantics):

```rust theme={null}
client.send_reaction(&chat_jid, target_key, "").await?;
```

<Note>
  Newsletter (channel) reactions use a different plaintext stanza format. Use [`client.newsletter().send_reaction()`](/api/newsletter#send_reaction) for newsletters instead.
</Note>

## Channel Comments

Channel comments are encrypted threaded replies under a Community Announcement Group (CAG) post. Use `client.comments()` to send them:

```rust theme={null}
use whatsapp_rust::features::Comments;

let parent_key = wa::MessageKey {
    remote_jid: Some(cag_jid.to_string()),
    from_me: Some(false),
    id: Some("3EB0POSTID".to_string()),
    // participant must be the post author.
    participant: Some(post_author_jid.to_string()),
};

// Text comment:
let result = client.comments()
    .send_text(&cag_jid, parent_key, "Great post!")
    .await?;
println!("Comment sent: {}", result.message_id);
```

For arbitrary message bodies:

```rust theme={null}
let body = wa::Message {
    extended_text_message: buffa::MessageField::some(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 `parent_key.participant` field must identify the post author so receivers can derive the HKDF decryption key from the envelope. When `from_me` is `true` and `participant` is absent the library resolves the author to your own identity.

Incoming encrypted comments are decrypted transparently. The comment body is dispatched as part of an `Event::Messages` batch and the parent post key is available on `MessageInfo::comment_target`:

```rust theme={null}
Event::Messages(batch) => {
    for InboundMessage { message: msg, info, .. } in batch.iter() {
        if let Some(parent_key) = &info.comment_target {
            println!("Comment on post: {:?}", parent_key.id);
            if let Some(text) = msg.text_content() {
                println!("Text: {}", text);
            }
        }
    }
}
```

See [Community management — Channel comments](/guides/communities#channel-comments) for full details.

## Editing messages

Use [`client.edit_message`](/api/send#edit_message) to replace the content of a message you previously sent. Pass the chat JID, the original message ID, and the new content as a plain `wa::Message` — the client builds the correct wire envelope, resolves the participant JID (LID or PN) for groups, and sends the edit with a fresh stanza ID so the server does not deduplicate it against the original.

```rust theme={null}
let new_content = wa::Message {
    conversation: Some("This is the edited text".to_string()),
    ..Default::default()
};

let edit_id = client.edit_message(
    chat_jid,
    &result.message_id,
    new_content,
).await?;
```

<Note>
  Message editing only works for text messages (`conversation` or `extended_text_message`) sent by you within the last 15 minutes.
</Note>

<Warning>
  Do not hand-roll the edit envelope by wrapping a `ProtocolMessage` inside `Message.edited_message` (a `FutureProofMessage`) and passing it to `send_message`. That shape is the history/storage form; on the wire WhatsApp expects a top-level `protocolMessage` with `type = MESSAGE_EDIT`. `client.edit_message` produces the correct shape — manual envelopes will be silently dropped by the server.
</Warning>

### Pinning the outer stanza id

`client.edit_message` always mints a fresh outer stanza id — that's what stops the server from deduplicating the edit against the original message. If you need to control that id yourself (for example, to collide it with an existing message so clients re-render that slot), use [`client.edit_message_with_options`](/api/send#edit_message_with_options) with [`EditOptions`](/api/send#editoptions):

```rust theme={null}
use waproto::whatsapp as wa;
use whatsapp_rust::send::EditOptions;

let edit_id = client.edit_message_with_options(
    &chat_jid,
    &original_id,
    wa::Message {
        conversation: Some("This is the edited text".to_string()),
        ..Default::default()
    },
    EditOptions::default().with_stanza_id(existing_message_id.clone()),
).await?;
```

<Note>
  Pinning `stanza_id` to an id borrowed from another message is best-effort: the server and recipient clients decide whether the collision is honored. The library does not persist retry-cache or outbound-secret state under the borrowed id, so the original message's retry content and secret are left intact. Leave `stanza_id` as `None` (or use plain `edit_message`) unless you specifically need this.
</Note>

## Deleting messages (revoke)

### Delete your own message

```rust theme={null}
use whatsapp_rust::send::RevokeType;

client.revoke_message(
    chat_jid,
    message_id,
    RevokeType::Sender,
).await?;
```

See [Send API reference](/api/send#revoke_message) for full details.

### Admin delete (group only)

Group admins can delete messages from other participants:

```rust theme={null}
let original_sender: Jid = "1234567890@s.whatsapp.net".parse()?;

client.revoke_message(
    group_jid,
    message_id,
    RevokeType::Admin { original_sender },
).await?;
```

<Warning>
  Admin revoke only works in group chats. The `original_sender` must match the JID format (LID or phone number) of the message being deleted.
</Warning>

## Sending to newsletters

Newsletter messages are sent through the same `client.send_message()` method. The library automatically detects newsletter JIDs and sends messages as plaintext (no Signal encryption):

```rust theme={null}
use waproto::whatsapp as wa;
use wacore_binary::jid::Jid;

let newsletter_jid: Jid = "120363999999999999@newsletter".parse()?;

let message = wa::Message {
    conversation: Some("Hello subscribers!".to_string()),
    ..Default::default()
};

let result = client.send_message(newsletter_jid, message).await?;
```

The correct stanza `type` (text, media, reaction, poll), `mediatype` attributes, and `<meta>` nodes (for polls, events, etc.) are inferred automatically from the message content. See the [Newsletters guide](/guides/newsletters) for more details.

<Note>
  Newsletter reactions use a different protocol format. Use `client.newsletter().send_reaction()` for reactions instead of `send_message()`.
</Note>

## Album messages

Album messages let you send multiple images and/or videos as a grouped media album — the collapsed album bubble that WhatsApp displays when someone sends several photos at once.

An album consists of:

1. A **parent `AlbumMessage`** declaring the expected image and video counts
2. Multiple **child messages** (individual media messages) wrapped with `wrap_as_album_child` and linked to the parent

### Sending an album

```rust theme={null}
use waproto::whatsapp as wa;
use whatsapp_rust::proto_helpers::wrap_as_album_child;

let chat_jid: Jid = "1234567890@s.whatsapp.net".parse()?;

// Step 1: Send the parent album message
let album_parent = wa::Message {
    album_message: buffa::MessageField::some(wa::message::AlbumMessage {
        expected_image_count: Some(3),
        expected_video_count: Some(0),
        ..Default::default()
    }),
    ..Default::default()
};

let parent_result = client.send_message(&chat_jid, album_parent).await?;
let parent_key = parent_result.message_key();

// Step 2: Upload and send each child image
for image_data in [image1_bytes, image2_bytes, image3_bytes] {
    let upload = client.upload(image_data, MediaType::Image, Default::default()).await?;

    let image_msg = 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_sha256: Some(upload.file_sha256_vec()),
            file_enc_sha256: Some(upload.file_enc_sha256_vec()),
            file_length: Some(upload.file_length),
            media_key_timestamp: Some(upload.media_key_timestamp),
            mimetype: Some("image/jpeg".to_string()),
            ..Default::default()
        }),
        ..Default::default()
    };

    // Wrap as album child and send
    let wrapped = wrap_as_album_child(image_msg, parent_key.clone());
    client.send_message(&chat_jid, wrapped).await?;
}
```

### How it works

The `wrap_as_album_child` function (from `whatsapp_rust::proto_helpers`) takes a media `wa::Message` and a parent `wa::MessageKey`, then:

1. Wraps the inner message in an `associated_child_message` (`FutureProofMessage` envelope)
2. Attaches a `MessageAssociation` with type `MediaAlbum` pointing to the parent
3. Lifts any existing `message_context_info` from the inner message to the outer wrapper

<Note>
  The parent `AlbumMessage` declares the total expected counts so WhatsApp clients know how many media items to group together. Each child is sent as a separate message linked back to the parent via `MessageAssociation`.
</Note>

### Mixed albums (images and videos)

You can mix images and videos in the same album:

```rust theme={null}
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();

// Send image children
let image_wrapped = wrap_as_album_child(image_msg, parent_key.clone());
client.send_message(&chat_jid, image_wrapped).await?;

// Send video children
let video_wrapped = wrap_as_album_child(video_msg, parent_key.clone());
client.send_message(&chat_jid, video_wrapped).await?;
```

## Sticker packs

Sticker packs let you send a collection of stickers as a single message — the inline sticker pack bubble that WhatsApp displays with a tray icon, pack name, and publisher info.

A sticker pack requires:

1. **Sticker images** — 512x512 WebP files
2. **Cover image** — WebP file used as the tray icon
3. **Thumbnail** — JPEG uploaded separately with the same `media_key` as the ZIP
4. **Metadata** — pack ID, name, and publisher

### Sending a sticker pack

```rust theme={null}
use wacore::sticker_pack::{
    StickerInput, StickerPackMetadata,
    create_sticker_pack_zip, build_sticker_pack_message,
};
use wacore::download::MediaType;
use whatsapp_rust::upload::UploadOptions;

let chat_jid: Jid = "1234567890@s.whatsapp.net".parse()?;

// Step 1: Create sticker inputs with optional emojis
let stickers = vec![
    StickerInput::new(&webp_bytes_1).with_emojis(vec!["😀".into()]),
    StickerInput::new(&webp_bytes_2).with_emojis(vec!["🎉".into()]),
    StickerInput::new(&webp_bytes_3),
];

// Step 2: Bundle stickers into a ZIP with the cover image
let zip_result = create_sticker_pack_zip("my-pack-id", &stickers, &cover_webp)?;

// Step 3: Upload the ZIP
let zip_upload = client.upload(
    zip_result.zip_bytes.clone(),
    MediaType::StickerPack,
    UploadOptions::default(),
).await?;

// Step 4: Upload the thumbnail JPEG with the same media_key
let thumb_upload = client.upload(
    thumbnail_jpeg,
    MediaType::StickerPackThumbnail,
    UploadOptions::new().with_media_key(zip_upload.media_key),
).await?;

// Step 5: Build and send the message
let metadata = StickerPackMetadata::new(
    "my-pack-id".into(),
    "My Sticker Pack".into(),
    "My Name".into(),
);

let msg = build_sticker_pack_message(
    &zip_result,
    &zip_upload.into(),
    &thumb_upload.into(),
    metadata,
);

client.send_message(chat_jid, msg).await?;
```

### How it works

The sticker pack flow uses two helper functions from `wacore::sticker_pack`:

* **`create_sticker_pack_zip`** — bundles stickers and a cover image into a ZIP file. Filenames use `base64url(sha256).webp`, and identical stickers are deduplicated. Returns a `StickerPackZipResult` containing the ZIP bytes and proto metadata.
* **`build_sticker_pack_message`** — constructs a `wa::Message` with a `StickerPackMessage` from the ZIP result and upload responses.

The thumbnail must be uploaded with `MediaType::StickerPackThumbnail` and the same `media_key` as the ZIP upload. The `UploadResponse` implements `Into<MediaUploadInfo>` for convenience.

### Sticker format requirements

| Field          | Requirement                                                   |
| -------------- | ------------------------------------------------------------- |
| Sticker images | 512x512 WebP                                                  |
| Cover image    | WebP, stored in ZIP as `{pack_id}.webp`                       |
| Thumbnail      | JPEG, uploaded separately with same `media_key`               |
| Pack ID        | Non-empty, max 128 bytes, no path separators or control chars |
| Sticker count  | 1 to 60 per pack                                              |

### Sticker metadata

Each sticker supports optional metadata:

```rust theme={null}
StickerInput::new(&webp_bytes)
    .with_emojis(vec!["😀".into(), "🎉".into()])
    .with_accessibility_label("happy face".into())
```

Pack-level metadata supports optional description and caption:

```rust theme={null}
let metadata = StickerPackMetadata::new(
    "pack-id".into(),
    "Pack Name".into(),
    "Publisher".into(),
)
.with_description("A fun sticker pack".into())
.with_caption("Check out my stickers!".into());
```

<Note>
  Animated stickers are automatically detected from the WebP data. The `is_animated` field on each sticker proto entry is set based on whether the WebP file contains animation frames. You can also use [`whatsapp_rust::webp::is_animated()`](/api/wacore#is_animated) directly to check WebP files before processing.
</Note>

## Forwarding messages

Use `forward_message` to forward any received message to a chat. It produces the same on-wire result as tapping **Forward** in the official clients:

```rust theme={null}
// `received` is a `wa::Message` from an incoming event.
client.forward_message(destination_jid, &received).await?;
```

The helper takes care of the WhatsApp forwarding rules so you don't have to rebuild the message by hand:

* Sets `context_info.is_forwarded = true` so recipients see the **Forwarded** label.
* Bumps the forwarding score. Once the score reaches 5, it jumps to the `127` sentinel that clients render as **Forwarded many times**.
* Strips the reply/quote chain and mentions from the source.
* Drops the source `message_secret` so the send path mints a fresh one.
* Unwraps ephemeral and view-once wrappers before sending the inner content.
* Promotes a bare `conversation` to `extended_text_message` so the forward marker can attach.

Media is relayed from the same CDN blob, so forwarding an image, video, document, audio, or sticker is instant regardless of file size — nothing is downloaded or re-uploaded.

### Manually forwarding media

If you need to customize fields (for example, change a caption) before forwarding, you can still build the `wa::Message` yourself and pass it to `send_message`. Reusing the original CDN fields keeps the send instant:

```rust theme={null}
use waproto::whatsapp as wa;

// Clone the image message, only change the caption
if let Some(img) = received_message.image_message.as_option() {
    let forwarded = wa::Message {
        image_message: buffa::MessageField::some(wa::message::ImageMessage {
            caption: Some("Forwarded!".to_string()),
            ..img.clone()
        }),
        ..Default::default()
    };

    client.send_message(destination_jid, forwarded).await?;
}
```

Note that this path does not apply the **Forwarded** marker or the forwarding-score bump. Use `forward_message` whenever you want the recipient to see the standard forward indicator. See [Media handling — CDN reuse](/guides/media-handling#forwarding-media-via-cdn-reuse) for details on supported media types and when to fall back to download + re-upload.

## Ephemeral (disappearing) messages

WhatsApp supports disappearing messages that automatically delete after a set duration. When a chat has disappearing messages enabled, you should set the ephemeral expiration on outgoing messages so recipients see the correct countdown timer.

### Sending a disappearing message

Use `send_message_with_options` with `ephemeral_expiration` set to the chat's timer value:

```rust theme={null}
use whatsapp_rust::send::SendOptions;
use waproto::whatsapp as wa;

let message = wa::Message {
    conversation: Some("This message will disappear!".to_string()),
    ..Default::default()
};

let options = SendOptions::default().with_ephemeral_expiration(604800); // 7 days, matching the chat's timer

let result = client.send_message_with_options(chat_jid, message, options).await?;
```

This sets `contextInfo.expiration` on the protobuf message, which tells WhatsApp clients to display the disappearing countdown.

### Common timer values

| Duration | Value (seconds) |
| -------- | --------------- |
| 24 hours | `86400`         |
| 7 days   | `604800`        |
| 90 days  | `7776000`       |
| Disabled | `0`             |

### Getting the chat's ephemeral timer

For groups, read the timer from group metadata:

```rust theme={null}
let metadata = client.groups().get_metadata(&group_jid).await?;
let expiration = metadata.ephemeral.as_ref().and_then(|e| e.expiration).unwrap_or(0);
```

For incoming messages, read it from `MessageInfo`:

```rust theme={null}
Event::Messages(batch) => {
    for InboundMessage { info, .. } in batch.iter() {
        if let Some(expiration) = info.ephemeral_expiration {
            println!("Chat has {}s disappearing timer", expiration);
        }
    }
}
```

### Configuring disappearing messages

**Per-group:** Use `set_ephemeral` to enable or disable disappearing messages on a group:

```rust theme={null}
// Enable 7-day disappearing messages
client.groups().set_ephemeral(&group_jid, 604800).await?;

// Disable disappearing messages
client.groups().set_ephemeral(&group_jid, 0).await?;
```

**Account-level default:** Use `set_default_disappearing_mode` to set the default for all new 1-on-1 chats:

```rust theme={null}
// Enable 24-hour default for new chats
client.set_default_disappearing_mode(86400).await?;

// Disable default disappearing messages
client.set_default_disappearing_mode(0).await?;
```

<Note>
  The account-level default only applies to **new** chats. Existing chats keep their current setting. To change a specific group's timer, use [`set_ephemeral`](/api/groups#set_ephemeral).
</Note>

### Listening for timer changes

When a contact changes their default disappearing messages setting, you receive a `DisappearingModeChanged` event:

```rust theme={null}
Event::DisappearingModeChanged(change) => {
    println!("Contact {} set disappearing to {}s", change.from, change.duration);
}
```

When a group's ephemeral setting changes, you receive a `GroupUpdate` event with a `GroupNotificationAction::Ephemeral` action containing the new `expiration` value.

See [Events reference](/concepts/events#disappearingmodechanged) for details.

### Creating groups with disappearing messages

You can enable disappearing messages at group creation time:

```rust theme={null}
use whatsapp_rust::features::groups::{GroupCreateOptions, GroupParticipantOptions};

let options = GroupCreateOptions::builder()
    .subject("Ephemeral Group")
    .participants(vec![
        GroupParticipantOptions::new(participant_jid),
    ])
    .ephemeral_expiration(604800) // 7-day timer
    .build();

let result = client.groups().create_group(options).await?;
```

See [Send API reference](/api/send#sendoptions) for the full `SendOptions` type.

## Send Options

### Specifying a custom message ID

You can override the auto-generated message ID by setting `message_id` via `SendOptions`. This is useful for resending a failed message with the same ID or ensuring idempotency:

```rust theme={null}
use whatsapp_rust::send::SendOptions;

let options = SendOptions::default().with_message_id("3EB0ABC123");

let result = client.send_message_with_options(
    to,
    message,
    options,
).await?;

// result.message_id will be "3EB0ABC123"
```

### Overriding the stanza type

The library infers the `<message type="...">` attribute from the protobuf content of every send. If you're sending a message variant the classifier can't recognize, set `stanza_type_override` to force a specific wire type:

```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?;
```

Leave this as `None` for every supported message type — the classifier already picks the right value, and the override is not preserved across the retry path. See [Stanza types](/api/send#stanza-types) for the available variants.

### Adding extra stanza nodes

For advanced use cases, you can include custom XML 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")
        .attr("key", "value")
        .build(),
]);

let result = client.send_message_with_options(
    to,
    message,
    options,
).await?;
```

See [Send API reference](/api/send#sendoptions) for full details.

<Note>
  `SendOptions` and `EditOptions` are `#[non_exhaustive]` — build them by chaining the `with_*` setters off `::default()` rather than a struct literal (even with `..Default::default()`), since new fields can be added in future releases without breaking existing call sites.
</Note>

## Message preparation helpers

### Preparing messages for quoting

The `prepare_for_quote` method strips nested context info:

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

let quoted_message = original_message.prepare_for_quote();

let context = wa::ContextInfo {
    stanza_id: Some(message_id.clone()),
    participant: Some(sender_jid.to_string()),
    quoted_message: buffa::MessageField::from_box(quoted_message),
    ..Default::default()
};
```

This ensures:

* Nested mentions are stripped
* Quote chains are broken (except for bot messages)
* Content fields (text, caption, media) are preserved

### Preparing messages for forwarding

`prepare_for_forward` is the lower-level helper that powers [`forward_message`](#forwarding-messages). It returns a forward-ready `wa::Message` (forward marker set, score bumped, quote chain stripped, source `message_secret` dropped) without sending anything. Reach for it when you need to attach extra fields — for example, a custom caption or stanza nodes — before calling `send_message_with_options`:

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

let mut forward = *received_message.prepare_for_forward();
// Tweak the prepared message here if needed, for example overriding a caption.

client.send_message(destination_jid, forward).await?;
```

For the common case, prefer `client.forward_message(to, &message)` — it unwraps wrapper bodies (ephemeral, view-once) and sends in one call.

See [WAProto API reference](/api/waproto) for message type details.

## Error Handling

All send-path methods return `Result<T, SendError>`. Callers using `?` into an `anyhow` context compile unchanged — `SendError` implements `Into<anyhow::Error>`.

```rust theme={null}
use anyhow::Result;
use whatsapp_rust::SendError;

async fn send_and_return_id(
    client: &Client,
    jid: Jid,
    message: wa::Message,
) -> Result<String> {
    let result = client.send_message(jid.clone(), message).await?;
    Ok(result.message_id)
}
```

For typed error matching:

```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 — reconnect first");
    }
    Err(SendError::Iq(e)) => {
        eprintln!("Server rejected the send: {}", e);
    }
    Err(SendError::InvalidRequest(msg)) => {
        eprintln!("Malformed request: {}", msg);
    }
    Err(e) => eprintln!("Send error: {}", e),
}
```

See the [Error Types reference](/api/errors) for the complete `SendError` definition and all domain error types.

## Best Practices

<Steps>
  ### Use the right message type

  * **Simple text**: Use `conversation`
  * **Links/formatting**: Use `extended_text_message`
  * **Replies**: Use `extended_text_message` with `context_info`
  * **Media**: Use specific media message types with optional captions
  * **Albums**: Use `album_message` parent + `wrap_as_album_child` for grouped media
  * **Sticker packs**: Use `create_sticker_pack_zip` + `build_sticker_pack_message` with two uploads (ZIP + thumbnail)
  * **Disappearing chats**: Use `send_message_with_options` with `ephemeral_expiration` matching the chat's timer
  * **CAG reactions**: Use `send_reaction` — encryption is applied automatically for CAG chats
  * **Channel comments**: Use `client.comments().send_text()` or `send_message()`

  ### Handle message IDs

  ```rust theme={null}
  // Store the result for later operations
  let result = client.send_message(&chat_jid, message).await?;

  // Use the message ID for reactions, edits, or deletes
  client.revoke_message(&chat_jid, &result.message_id, RevokeType::Sender).await?;

  // Use message_key() for album child linking or pinning
  let key = result.message_key();
  ```

  ### Quote context best practices

  * Always use `prepare_for_quote()` to avoid nested quote chains
  * Use `build_quote_context_with_info` instead of `build_quote_context` for cross-platform compatibility — it correctly resolves the `participant` field for newsletters and emits `remote_jid` only for cross-chat quotes (matching WhatsApp Web)
  * Preserve original media fields when quoting media messages
</Steps>

## Next Steps

* [Receiving Messages](/guides/receiving-messages) - Handle incoming messages and events
* [Media Handling](/guides/media-handling) - Upload and download media
* [Polls](/guides/polls) - Create polls and process votes
* [Group Management](/guides/group-management) - Work with group chats
* [Community management](/guides/communities) - CAG reactions and channel comments
