From aa29e856cf2fa0bfb2ff7d005da6c85fa5586b65 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 17:33:23 +0000 Subject: [PATCH 1/4] docs: update for bot API overhaul (whatsapp-rust#852) - Rewrite quickstart to use prelude::*, on_message/on_qr_code typed registrars, single-dep Cargo.toml, and bot.run().await (no double-await) - Update installation to document single-dependency consumption and move UreqHttpClient import from transport to http module - Update receiving-messages guide to show typed registrars as the primary pattern and on_message for filtering - Update sending-messages guide to document ctx.reply/reply_quoting, wa::Message::text/text_with_context, and Client::send_text helpers - Add changelog entry 2026-06-11-bot-api-overhaul with full breaking change table and migration guide https://claude.ai/code/session_012JLxpJvMsHwZymEZWpbaP7 --- changelog/2026-06-11-bot-api-overhaul.mdx | 171 ++++++++++++ docs.json | 1 + guides/receiving-messages.mdx | 90 +++--- guides/sending-messages.mdx | 44 ++- installation.mdx | 38 ++- quickstart.mdx | 320 +++++++++++----------- 6 files changed, 450 insertions(+), 214 deletions(-) create mode 100644 changelog/2026-06-11-bot-api-overhaul.mdx diff --git a/changelog/2026-06-11-bot-api-overhaul.mdx b/changelog/2026-06-11-bot-api-overhaul.mdx new file mode 100644 index 00000000..57aa0caf --- /dev/null +++ b/changelog/2026-06-11-bot-api-overhaul.mdx @@ -0,0 +1,171 @@ +--- +title: "June 11, 2026 — Bot API overhaul (breaking)" +description: "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. Every consumer will need a migration, 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 +[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 +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 +// 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 +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 +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 +// 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 +// 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 +// 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 +// Before +use whatsapp_rust::transport::UreqHttpClient; + +// After +use whatsapp_rust::http::UreqHttpClient; +``` + +**Builder dependencies — drop sibling crates from `Cargo.toml`:** +```toml +# 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" +``` diff --git a/docs.json b/docs.json index a5cda70b..b19459bb 100644 --- a/docs.json +++ b/docs.json @@ -138,6 +138,7 @@ "group": "Changelog", "pages": [ "changelog/overview", + "changelog/2026-06-11-bot-api-overhaul", "changelog/2026-06-11-dockerfile-share-generics", "changelog/2026-06-11-pdo-once-per-message", "changelog/2026-06-11-history-sync-secret-prescan", diff --git a/guides/receiving-messages.mdx b/guides/receiving-messages.mdx index fcb15e45..ac26042a 100644 --- a/guides/receiving-messages.mdx +++ b/guides/receiving-messages.mdx @@ -11,32 +11,51 @@ This guide covers message event handling, decryption, and receipt management in ### Subscribing to Events -Use the Bot API to handle events: +Use the typed registrars on `BotBuilder` — they extract the relevant payload before calling your handler, so you never pattern-match on `Arc` for the common cases: ```rust -use whatsapp_rust::bot::Bot; -use wacore::types::events::Event; +use whatsapp_rust::prelude::*; let bot = Bot::builder() - .with_backend(backend) - .with_transport_factory(transport_factory) - .with_http_client(http_client) - .on_event(|event, client| async move { - match &*event { - Event::Message(message, info) => { - println!("📨 Message from: {}", info.source.sender); - println!("💬 Text: {:?}", message.text_content()); - } - Event::Connected(_) => { - println!("✅ Connected!"); - } - _ => {} - } + .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 +.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 +.with_event_handler(my_stateful_handler) +``` + See [Bot API reference](/api/bot#event-handling) for full details. ### Available Events @@ -565,30 +584,25 @@ 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: +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 -use wacore_binary::jid::JidExt; +use whatsapp_rust::prelude::*; -.on_event(|event, _client| async move { - match &*event { - Event::Message(message, info) => { - // Ignore own messages - if info.source.is_from_me { - return; - } - - // Only handle group messages - if !info.source.chat.is_group() { - return; - } - - // Only handle text messages - if let Some(text) = message.text_content() { - println!("Group text: {}", text); - } - } - _ => {} +.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}"); } }) ``` diff --git a/guides/sending-messages.mdx b/guides/sending-messages.mdx index 7510a1e5..80310684 100644 --- a/guides/sending-messages.mdx +++ b/guides/sending-messages.mdx @@ -7,11 +7,53 @@ description: Learn how to send text messages, reactions, channel comments, quote 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 +.on_message(|ctx| async move { + // Reply without quoting + ctx.reply("pong").await?; + + // Reply quoting the received message + ctx.reply_quoting("pong").await?; +}) +``` + +### `Client::send_text` (from any `Client` reference) + +```rust +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 +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: Some(...) }` by hand, but less verbose. + +--- + ## Sending text messages ### Simple text message -Use the `conversation` field for plain text messages: +Use the `conversation` field for plain text messages, or `wa::Message::text("...")` for shorter syntax: ```rust use waproto::whatsapp as wa; diff --git a/installation.mdx b/installation.mdx index 551626dc..3ecc0774 100644 --- a/installation.mdx +++ b/installation.mdx @@ -16,18 +16,40 @@ Before installing whatsapp-rust, ensure you have: ## Add to your project -Add whatsapp-rust and its required dependencies to your `Cargo.toml`: +`whatsapp-rust` re-exports the entire stack (`wacore`, `wacore_binary`, `waproto`, and all bundled implementations), so **one dependency line is enough** for most projects: + +```toml Cargo.toml +[dependencies] +whatsapp-rust = "0.6" +tokio = { version = "1.48", features = ["macros", "rt-multi-thread"] } +``` + +Every sub-crate path is reachable through the main crate: + +- `whatsapp_rust::waproto::whatsapp` (aliased as `wa` in `prelude`) +- `whatsapp_rust::wacore`, `whatsapp_rust::wacore_binary` +- `whatsapp_rust::store::SqliteStore`, `whatsapp_rust::http::UreqHttpClient`, `whatsapp_rust::transport::TokioWebSocketTransportFactory` + +The same holds for git consumers — no need to pin every sibling crate: + +```toml Cargo.toml +[dependencies] +whatsapp-rust = { git = "https://github.com/oxidezap/whatsapp-rust", rev = "" } +``` + +If you need to declare a sibling crate explicitly (for example to enable a crate-specific feature flag), you can still add it individually. The full multi-crate form: ```toml Nightly (default) [dependencies] 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" +# Only needed for crate-specific features not exposed through whatsapp-rust: +# 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" tokio = { version = "1.48", features = ["macros", "rt-multi-thread"] } ``` @@ -42,11 +64,7 @@ whatsapp-rust = { version = "0.6", default-features = false, features = [ "signal", "moka-cache", ] } -whatsapp-rust-sqlite-storage = "0.6" -whatsapp-rust-tokio-transport = "0.6" -whatsapp-rust-ureq-http-client = "0.6" wacore = { version = "0.6", default-features = false } -waproto = "0.6" tokio = { version = "1.48", features = ["macros", "rt-multi-thread"] } ``` diff --git a/quickstart.mdx b/quickstart.mdx index bb8a3e77..18c8d3ca 100644 --- a/quickstart.mdx +++ b/quickstart.mdx @@ -7,44 +7,34 @@ This guide will help you create a simple WhatsApp bot that responds to messages. ## Basic example +Add the dependencies — one crate is enough; `whatsapp-rust` re-exports the entire stack: + +```toml Cargo.toml +[dependencies] +whatsapp-rust = "0.6" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +``` + Here's a minimal bot that responds to "ping" messages: ```rust src/main.rs -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; +use whatsapp_rust::prelude::*; #[tokio::main] async fn main() -> Result<(), Box> { - // Initialize storage backend - let backend = Arc::new(SqliteStore::new("whatsapp.db").await?); - - // Build the bot - 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 { code, .. } => { - println!("Scan this QR code with WhatsApp:\n{}", code); - } - Event::Message(msg, info) => { - println!("Message from {}: {:?}", info.source.sender, msg); - } - _ => {} - } + let bot = Bot::builder() + .with_backend(SqliteStore::new("whatsapp.db").await?) + .on_qr_code(|code, _timeout| async move { + println!("Scan this QR code with WhatsApp:\n{code}"); + }) + .on_message(|ctx| async move { + println!("Message from {}: {:?}", ctx.info.source.sender, ctx.message.text_content()); }) .build() .await?; - // Start the bot - bot.run().await?.await?; + // Runs until logout or client.disconnect() from another task. + bot.run().await; Ok(()) } ``` @@ -57,51 +47,44 @@ async fn main() -> Result<(), Box> { The bot needs persistent storage for session data, keys, and state: ```rust - let backend = Arc::new(SqliteStore::new("whatsapp.db").await?); + let backend = SqliteStore::new("whatsapp.db").await?; ``` This creates a SQLite database file named `whatsapp.db` in your current directory. The session will persist across restarts. - The `Bot::builder()` pattern lets you configure all required components: + The `Bot::builder()` pattern lets you configure all components: ```rust - let mut bot = Bot::builder() + let bot = Bot::builder() .with_backend(backend) - .with_transport_factory(TokioWebSocketTransportFactory::new()) - .with_http_client(UreqHttpClient::new()) - .with_runtime(TokioRuntime) ``` - All four components (backend, transport, HTTP client, runtime) are required. The builder uses a typestate pattern — your code won't compile if any are missing. + With the default cargo features, only the backend is required. The Tokio WebSocket transport, ureq HTTP client, and Tokio runtime are pre-wired. Use `with_transport_factory`, `with_http_client`, and `with_runtime` to override any of them (for example when targeting WASM or a custom transport). - - Use `.on_event()` to handle incoming events from WhatsApp: + + Typed registrars cover the common cases — no `match &*event` needed: ```rust - .on_event(|event, client| async move { - match &*event { - Event::PairingQrCode { code, .. } => { - println!("QR Code:\n{}", code); - } - Event::Message(msg, info) => { - // Handle incoming message - } - Event::Connected(_) => { - println!("Connected successfully!"); - } - _ => {} - } + .on_qr_code(|code, _timeout| async move { + println!("QR Code:\n{code}"); + }) + .on_message(|ctx| async move { + // ctx.message, ctx.info, ctx.reply(...), ctx.react(...) + println!("From {}: {:?}", ctx.info.source.sender, ctx.message.text_content()); + }) + .on_connected(|_client| async { + println!("Connected successfully!"); }) ``` - The event handler receives two parameters: - - `event`: An `Arc` — use `&*event` or `event.as_ref()` to pattern-match on the inner event type - - `client`: An `Arc` you can use to send messages or call API methods + Available typed registrars: `on_message`, `on_qr_code`, `on_pair_code`, `on_connected`, `on_logged_out`. Use `on_event` or `on_event_for` as catch-alls for events without a typed registrar. Multiple handlers of any kind accumulate — registering a second one no longer silently replaces the first. + + `on_message` delivers a ready [`MessageContext`](/api/bot#messagecontext) with `ctx.reply(text)`, `ctx.reply_quoting(text)`, `ctx.react(emoji)`, and `ctx.send_message(msg)` helpers. @@ -111,68 +94,81 @@ async fn main() -> Result<(), Box> { .build() .await?; - bot.run().await?.await?; + bot.run().await; ``` - The double `.await?` is intentional: - - First `.await?` starts the bot and returns a `BotHandle` - - Second `.await?` waits for the bot to finish running + `bot.run().await` runs the bot on the current task until it disconnects or logs out. To run in the background instead, use `bot.spawn()`, which returns a [`BotHandle`](/api/bot#bothandle) with `client()`, `shutdown()`, and `abort()`. ## Responding to messages -Let's extend the bot to respond to "ping" with "pong": +Let's extend the bot to respond to "ping" with "pong" using the `on_message` helper and `ctx.reply`: ```rust -use wacore::proto_helpers::MessageExt; -use waproto::whatsapp as wa; +use whatsapp_rust::prelude::*; -.on_event(|event, client| async move { - match &*event { - Event::PairingQrCode { code, .. } => { - println!("QR Code:\n{}", code); - } - Event::Message(msg, info) => { - // Check if message is a text message saying "ping" - if let Some(text) = msg.text_content() { - if text == "ping" { - // Create reply message - let reply = wa::Message { - conversation: Some("pong".to_string()), - ..Default::default() - }; - - // Send the reply - if let Err(e) = client.send_message(info.source.chat.clone(), reply).await { - eprintln!("Failed to send reply: {}", e); - } - } - } +.on_qr_code(|code, _timeout| async move { + println!("QR Code:\n{code}"); +}) +.on_message(|ctx| async move { + if ctx.message.text_content() == Some("ping") { + if let Err(e) = ctx.reply("pong").await { + eprintln!("Failed to send reply: {e}"); } - _ => {} } }) ``` ### Key methods -- `msg.text_content()` - Extract text from any message type (conversation, extended text, etc.) -- `client.send_message()` - Send a message to a chat -- `info.source.chat` - The JID (identifier) of the chat where the message came from -- `info.source.sender` - The JID of the user who sent the message +- `msg.text_content()` — Extract text from any message type (conversation, extended text, etc.) +- `ctx.reply(text)` — Send a plain-text reply in the same chat without quoting +- `ctx.reply_quoting(text)` — Send a plain-text reply that quotes the received message +- `ctx.react(emoji)` — React to the received message +- `ctx.send_message(msg)` — Send an arbitrary `wa::Message` to the source chat +- `client.send_text(jid, text)` — Send a plain-text message from a `Client` reference +- `info.source.chat` — The JID (identifier) of the chat where the message came from +- `info.source.sender` — The JID of the user who sent the message + +For raw protobuf access (advanced cases), build a `wa::Message` directly: + +```rust +use whatsapp_rust::prelude::*; + +.on_message(|ctx| async move { + if ctx.message.text_content() == Some("ping") { + let reply = wa::Message { + conversation: Some("pong".to_string()), + ..Default::default() + }; + if let Err(e) = ctx.send_message(reply).await { + eprintln!("Failed to send reply: {e}"); + } + } +}) +``` + +Or with the `MessageBuilderExt` helpers: + +```rust +use whatsapp_rust::prelude::*; // re-exports MessageBuilderExt + +let msg = wa::Message::text("pong"); +let msg_with_quote = wa::Message::text_with_context("pong", ctx.build_quote_context()); +``` ## Authentication methods ### QR code pairing (default) -The bot automatically generates QR codes when not authenticated. Scan with your phone to link: +The bot automatically generates QR codes when not authenticated. Display them with `on_qr_code`: ```rust -Event::PairingQrCode { code, .. } => { - println!("Scan this QR code:\n{}", code); -} +.on_qr_code(|code, timeout| async move { + println!("Scan this QR code (valid {}s):\n{code}", timeout.as_secs()); +}) ``` ### Pair code (phone number) @@ -180,24 +176,17 @@ Event::PairingQrCode { code, .. } => { Alternatively, link using a phone number and 8-digit code: ```rust +use whatsapp_rust::prelude::*; use whatsapp_rust::pair_code::PairCodeOptions; -let mut bot = Bot::builder() - .with_backend(backend) - .with_transport_factory(TokioWebSocketTransportFactory::new()) - .with_http_client(UreqHttpClient::new()) - .with_runtime(TokioRuntime) +let bot = Bot::builder() + .with_backend(SqliteStore::new("whatsapp.db").await?) .with_pair_code(PairCodeOptions { phone_number: "15551234567".to_string(), ..Default::default() }) - .on_event(|event, client| async move { - match &*event { - Event::PairingCode { code, .. } => { - println!("Enter this code on your phone: {}", code); - } - _ => {} - } + .on_pair_code(|code, _timeout| async move { + println!("Enter this code on your phone: {code}"); }) .build() .await?; @@ -279,36 +268,30 @@ The demo bot responds to `🦀ping` with a quoted `🏓 Pong!` reply, edits the ## Using MessageContext -For cleaner message handling, use `MessageContext` to wrap the message, metadata, and client together. This provides convenience methods like `send_message` (auto-targets the source chat), `build_quote_context`, `edit_message`, and `revoke_message`: +`on_message` delivers a ready `MessageContext`, so you no longer need to extract it from `Arc` manually. Use typed handler functions for cleaner separation: ```rust -use whatsapp_rust::bot::{Bot, MessageContext}; +use whatsapp_rust::prelude::*; -.on_event(|event, client| async move { - if let Some(ctx) = MessageContext::from_event(&event, client) { - handle_message(&ctx).await; - } +.on_message(|ctx| async move { + handle_message(ctx).await; }) ``` Then define focused handler functions: ```rust -async fn handle_message(ctx: &MessageContext) { - if let Some(text) = ctx.message.text_content() { - if text == "ping" { - let reply = wa::Message { - conversation: Some("pong".to_string()), - ..Default::default() - }; - if let Err(e) = ctx.send_message(reply).await { - eprintln!("Failed to send: {}", e); - } +async fn handle_message(ctx: MessageContext) { + if ctx.message.text_content() == Some("ping") { + if let Err(e) = ctx.reply("pong").await { + eprintln!("Failed to send: {e}"); } } } ``` +`MessageContext` provides convenience methods including `send_message` (auto-targets the source chat), `reply`, `reply_quoting`, `react`, `build_quote_context`, `edit_message`, and `revoke_message`. + ### Media forwarding with CDN reuse You can also forward media instantly by reusing the original CDN fields — no download or re-upload needed: @@ -333,25 +316,45 @@ fn build_media_reply(message: &wa::Message) -> Option { See the [media forwarding guide](/guides/media-handling#forwarding-media-via-cdn-reuse) for more details. +## Background operation and graceful shutdown + +Use `spawn()` to run the bot in the background while your code continues, and `shutdown()` for a clean stop: + +```rust +use whatsapp_rust::prelude::*; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let bot = Bot::builder() + .with_backend(SqliteStore::new("whatsapp.db").await?) + .on_message(|ctx| async move { + let _ = ctx.reply("hello").await; + }) + .build() + .await?; + + let handle = bot.spawn(); // bot runs in the background; client is available via handle.client() + + tokio::signal::ctrl_c().await?; + handle.shutdown().await; // graceful: flushes pending state, then stops + Ok(()) +} +``` + +`BotHandle` also exposes `abort()` as an escape hatch (skips the flush). Awaiting the handle resolves to `()` once the run loop exits. + ## Complete example with logging Here's a production-ready example with proper logging, reactions, message editing, and media CDN reuse: ```rust src/main.rs -use chrono::{Local, Utc}; use log::{error, info}; -use std::sync::Arc; -use wacore::proto_helpers::MessageExt; -use wacore::types::events::Event; -use waproto::whatsapp as wa; -use whatsapp_rust::bot::{Bot, MessageContext}; -use whatsapp_rust::TokioRuntime; -use whatsapp_rust::store::SqliteStore; -use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory; -use whatsapp_rust_ureq_http_client::UreqHttpClient; +use whatsapp_rust::prelude::*; +use whatsapp_rust::pair_code::PairCodeOptions; const PING_TRIGGER: &str = "🦀ping"; const PONG_TEXT: &str = "🏓 Pong!"; +const REACTION_EMOJI: &str = "🏓"; #[tokio::main] async fn main() -> Result<(), Box> { @@ -361,7 +364,7 @@ async fn main() -> Result<(), Box> { writeln!( buf, "{} [{:<5}] [{}] - {}", - Local::now().format("%H:%M:%S"), + wacore::time::now_utc().format("%H:%M:%S"), record.level(), record.target(), record.args() @@ -369,59 +372,46 @@ async fn main() -> Result<(), Box> { }) .init(); - let backend = Arc::new(SqliteStore::new("whatsapp.db").await?); + let store = SqliteStore::new("whatsapp.db").await?; info!("SQLite backend initialized"); - 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 { code, .. } => { - println!("\n{}", code); - } - Event::Message(msg, info) => { - let ctx = MessageContext::from_parts(msg, info, client); - handle_message(&ctx).await; - } - Event::Connected(_) => info!("Bot connected!"), - Event::LoggedOut(_) => error!("Bot was logged out!"), - _ => {} - } + let bot = Bot::builder() + .with_backend(store) + .on_qr_code(|code, _timeout| async move { + println!("\n{code}\n"); + }) + .on_connected(|_client| async { + info!("Bot connected!"); + }) + .on_logged_out(|_info| async { + error!("Bot was logged out!"); + }) + .on_message(|ctx| async move { + handle_message(ctx).await; }) .build() .await?; info!("Starting bot..."); - bot.run().await?.await?; + bot.run().await; Ok(()) } -async fn handle_message(ctx: &MessageContext) { +async fn handle_message(ctx: MessageContext) { // Try CDN-reuse media reply first (instant, no download needed) if let Some(reply) = build_media_pong(&ctx.message) { if let Err(e) = ctx.send_message(reply).await { - error!("Failed to send media pong: {}", e); + error!("Failed to send media pong: {e}"); } return; } - // Handle text ping if ctx.message.text_content() == Some(PING_TRIGGER) { - let context_info = ctx.build_quote_context(); - let reply = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { - text: Some(PONG_TEXT.to_string()), - context_info: Some(Box::new(context_info)), - ..Default::default() - })), - ..Default::default() - }; - - if let Err(e) = ctx.send_message(reply).await { - error!("Failed to send pong: {}", e); + if let Err(e) = ctx.react(REACTION_EMOJI).await { + error!("Failed to send reaction: {e}"); + } + if let Err(e) = ctx.reply_quoting(PONG_TEXT).await { + error!("Failed to send pong: {e}"); } } } @@ -564,4 +554,4 @@ See the [wacore benchmarks documentation](/api/wacore#benchmarks) for details on Explore all available client methods - \ No newline at end of file + From 2bd04f2e819cca1933fb99bff67a8cbd4cf9c389 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 17:37:42 +0000 Subject: [PATCH 2/4] fix: address review comments on bot-api-overhaul docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop broken #bothandle anchor; link to /api/bot instead (no BotHandle heading exists in api/bot.mdx yet) - Replace .await? with if-let-Err in on_message examples — the handler closure returns () so ? would be a compile error for users copying it https://claude.ai/code/session_012JLxpJvMsHwZymEZWpbaP7 --- guides/sending-messages.mdx | 8 ++++++-- quickstart.mdx | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/guides/sending-messages.mdx b/guides/sending-messages.mdx index 80310684..747aaf68 100644 --- a/guides/sending-messages.mdx +++ b/guides/sending-messages.mdx @@ -18,10 +18,14 @@ When you're already inside an `on_message` handler: ```rust .on_message(|ctx| async move { // Reply without quoting - ctx.reply("pong").await?; + if let Err(e) = ctx.reply("pong").await { + eprintln!("Failed to reply: {e}"); + } // Reply quoting the received message - ctx.reply_quoting("pong").await?; + if let Err(e) = ctx.reply_quoting("pong").await { + eprintln!("Failed to reply: {e}"); + } }) ``` diff --git a/quickstart.mdx b/quickstart.mdx index 18c8d3ca..31aad0bb 100644 --- a/quickstart.mdx +++ b/quickstart.mdx @@ -97,7 +97,7 @@ async fn main() -> Result<(), Box> { bot.run().await; ``` - `bot.run().await` runs the bot on the current task until it disconnects or logs out. To run in the background instead, use `bot.spawn()`, which returns a [`BotHandle`](/api/bot#bothandle) with `client()`, `shutdown()`, and `abort()`. + `bot.run().await` runs the bot on the current task until it disconnects or logs out. To run in the background instead, use `bot.spawn()`, which returns a [`BotHandle`](/api/bot) with `client()`, `shutdown()`, and `abort()`. From 3a6adebdfb68f0d9501aa7f5183d38899192a97a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 17:38:52 +0000 Subject: [PATCH 3/4] fix: address cubic review findings - quickstart: on_message handler now actually replies to "ping" with "pong" - installation: add tokio to git-revision install example - changelog: add tokio to migration "after" Cargo.toml snippet https://claude.ai/code/session_012JLxpJvMsHwZymEZWpbaP7 --- changelog/2026-06-11-bot-api-overhaul.mdx | 1 + installation.mdx | 1 + quickstart.mdx | 6 +++++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/changelog/2026-06-11-bot-api-overhaul.mdx b/changelog/2026-06-11-bot-api-overhaul.mdx index 57aa0caf..491809a4 100644 --- a/changelog/2026-06-11-bot-api-overhaul.mdx +++ b/changelog/2026-06-11-bot-api-overhaul.mdx @@ -168,4 +168,5 @@ waproto = "0.6" # After (one line is enough) whatsapp-rust = "0.6" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ``` diff --git a/installation.mdx b/installation.mdx index 3ecc0774..abefa1d3 100644 --- a/installation.mdx +++ b/installation.mdx @@ -35,6 +35,7 @@ The same holds for git consumers — no need to pin every sibling crate: ```toml Cargo.toml [dependencies] whatsapp-rust = { git = "https://github.com/oxidezap/whatsapp-rust", rev = "" } +tokio = { version = "1.48", features = ["macros", "rt-multi-thread"] } ``` If you need to declare a sibling crate explicitly (for example to enable a crate-specific feature flag), you can still add it individually. The full multi-crate form: diff --git a/quickstart.mdx b/quickstart.mdx index 31aad0bb..20719643 100644 --- a/quickstart.mdx +++ b/quickstart.mdx @@ -28,7 +28,11 @@ async fn main() -> Result<(), Box> { println!("Scan this QR code with WhatsApp:\n{code}"); }) .on_message(|ctx| async move { - println!("Message from {}: {:?}", ctx.info.source.sender, ctx.message.text_content()); + if ctx.message.text_content() == Some("ping") { + if let Err(e) = ctx.reply("pong").await { + eprintln!("Failed to reply: {e}"); + } + } }) .build() .await?; From d206aad15191b48f83e23d6dc63c76a6479f39e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 17:41:34 +0000 Subject: [PATCH 4/4] docs(changelog): address review nitpicks in bot-api-overhaul entry - Use second-person voice ("You will need to migrate") per CodeRabbit suggestion - Add tokio `signal` feature to migration Cargo.toml snippet so the ctrl_c example compiles https://claude.ai/code/session_012JLxpJvMsHwZymEZWpbaP7 --- changelog/2026-06-11-bot-api-overhaul.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog/2026-06-11-bot-api-overhaul.mdx b/changelog/2026-06-11-bot-api-overhaul.mdx index 491809a4..557aef2c 100644 --- a/changelog/2026-06-11-bot-api-overhaul.mdx +++ b/changelog/2026-06-11-bot-api-overhaul.mdx @@ -3,7 +3,7 @@ title: "June 11, 2026 — Bot API overhaul (breaking)" description: "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. Every consumer will need a migration, but the migration is mechanical. See the [breaking changes](#breaking-changes) section below. +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 @@ -168,5 +168,5 @@ waproto = "0.6" # After (one line is enough) whatsapp-rust = "0.6" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } ```