Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions changelog/2026-06-11-bot-api-overhaul.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
---
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"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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<dyn Backend>`.

### 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"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
```
1 change: 1 addition & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
90 changes: 52 additions & 38 deletions guides/receiving-messages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Event>` 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
Expand Down Expand Up @@ -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}");
}
})
```
Expand Down
48 changes: 47 additions & 1 deletion guides/sending-messages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,57 @@ 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
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
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;
Expand Down
Loading