Skip to content

Latest commit

 

History

History
603 lines (479 loc) · 20.7 KB

quickstart.mdx

File metadata and controls

603 lines (479 loc) · 20.7 KB
title Quickstart
description Build your first WhatsApp bot in minutes

This guide will help you create a simple WhatsApp bot that responds to messages. You'll learn the core concepts and have a working bot by the end.

Basic example

Add the dependencies — one crate is enough; whatsapp-rust re-exports the entire stack:

[dependencies]
whatsapp-rust = { git = "https://github.com/oxidezap/whatsapp-rust", rev = "d804acaec1439e54c43c129e2f90d45016506abe" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Here's a minimal bot that responds to "ping" messages:

use whatsapp_rust::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let bot = Bot::builder()
        .with_backend(SqliteStore::open("whatsapp.db").await?)
        .on_qr_code(|code, _timeout| async move {
            println!("Scan this QR code with WhatsApp:\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 reply: {e}");
                }
            }
        })
        .build()
        .await?;

    // Runs until logout or client.shutdown() from another task.
    let reason = bot.run().await;
    println!("Session ended: {reason:?}");
    Ok(())
}

Step-by-step breakdown

The bot needs persistent storage for session data, keys, and state:
let backend = SqliteStore::open("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 components:
let bot = Bot::builder()
    .with_backend(backend)
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). Typed registrars cover the common cases — no `match &*event` needed:
.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!");
})

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 with ctx.reply(text), ctx.reply_quoting(text), ctx.react(emoji), and ctx.send_message(msg) helpers.

Build the bot and start the event loop:
.build()
.await?;

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 with client(), shutdown(), and abort().

Responding to messages

Let's extend the bot to respond to "ping" with "pong" using the on_message helper and ctx.reply:

use whatsapp_rust::prelude::*;

.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.)
  • 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:

use whatsapp_rust::prelude::*;

.on_message(|ctx| async move {
    if ctx.message.text_content() == Some("ping") {
        let mut reply = wa::Message::default();
        reply.conversation = Some("pong".to_string());
        if let Err(e) = ctx.send_message(reply).await {
            eprintln!("Failed to send reply: {e}");
        }
    }
})

Or with the MessageBuilderExt helpers:

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. Display them with on_qr_code:

.on_qr_code(|code, timeout| async move {
    println!("Scan this QR code (valid {}s):\n{code}", timeout.as_secs());
})

Pair code (phone number)

Alternatively, link using a phone number and 8-digit code:

use whatsapp_rust::prelude::*;
use whatsapp_rust::pair_code::PairCodeOptions;

let bot = Bot::builder()
    .with_backend(SqliteStore::open("whatsapp.db").await?)
    .with_pair_code(PairCodeOptions {
        phone_number: "15551234567".to_string(),
        ..Default::default()
    })
    .on_pair_code(|code, _timeout| async move {
        println!("Enter this code on your phone: {code}");
    })
    .build()
    .await?;

PairCodeOptions derives companion_platform_id and companion_platform_display from the device's PlatformType by default (Chrome with Chrome (Linux) for the stock web profile). You can override the wire id when needed:

use whatsapp_rust::pair_code::PairCodeOptions;
use whatsapp_rust::wacore::companion_reg::CompanionWebClientType;

PairCodeOptions {
    phone_number: "15551234567".to_string(),
    show_push_notification: true,
    custom_code: Some("ABCD1234".to_string()), // or None for random
    // `None` auto-derives from `Device.device_props.platform_type`.
    platform_id: Some(CompanionWebClientType::Chrome),
}
`platform_id` accepts the [`CompanionWebClientType`](/concepts/authentication#companionwebclienttype) wire enum (single-byte ASCII ids). The display string is always derived — there is no separate `platform_display` field. Pair code and QR code authentication run concurrently. Whichever method completes first will be used.

Running the bot

On the first run, the bot will generate a QR code:
cargo run

Scan the QR code with WhatsApp on your phone:

  1. Open WhatsApp on your phone
  2. Go to Settings → Linked Devices
  3. Tap "Link a Device"
  4. Scan the QR code displayed in your terminal
After pairing, the session is saved. The bot will automatically reconnect:
cargo run

You should see:

Connected successfully!
Send "ping" to your bot from any WhatsApp chat. It should reply with "pong"!

Demo binary CLI flags

The repository includes a demo bot example (examples/demo.rs) that supports CLI arguments for authentication:

cargo run --example demo                                      # QR code pairing only
cargo run --example demo -- --phone 15551234567               # Pair code + QR code (concurrent)
cargo run --example demo -- -p 15551234567                    # Short form
cargo run --example demo -- -p 15551234567 --code MYCODE12    # Custom 8-char pair code
cargo run --example demo -- -p 15551234567 -c MYCODE12        # Short form

The demo bot responds to 🦀ping with a quoted 🏓 Pong! reply, edits the reply to append the send latency, and supports media ping/pong via CDN reuse.

Using MessageContext

on_message delivers a ready MessageContext, so you no longer need to extract it from Arc<Event> manually. Use typed handler functions for cleaner separation:

use whatsapp_rust::prelude::*;

.on_message(|ctx| async move {
    handle_message(ctx).await;
})

Then define focused handler functions:

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:

Constructing a message with a sub-message field (like `image_message` below) requires `MessageField`. `whatsapp-rust` re-exports it directly from `prelude`, so `use whatsapp_rust::prelude::*` is enough — no extra `Cargo.toml` entry needed. See [Installation](/installation#add-to-your-project).
/// Reuses the original CDN blob, only swaps the caption.
/// Instant regardless of file size.
fn build_media_reply(message: &wa::Message) -> Option<wa::Message> {
    let base = message.get_base_message();
    if let Some(img) = base.image_message.as_option() {
        return Some({
            let mut value = wa::Message::default();
            value.image_message = MessageField::some({
                let mut value = img.clone();
                value.caption = Some("Received your image!".to_string());
                value
            });
            value
        });
    }
    None
}

See the media forwarding guide 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:

use whatsapp_rust::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let bot = Bot::builder()
        .with_backend(SqliteStore::open("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()

    whatsapp_rust::shutdown_signal().await;
    let report = handle.shutdown().await;
    let stopped = matches!(
        report.run,
        BotRunOutcome::Completed(RunCompletionReason::ShutdownRequested)
    );
    let shutdown = &report.shutdown;
    let drained = matches!(shutdown.inbound, DrainOutcome::Completed)
        && matches!(shutdown.outbound, DrainOutcome::Completed)
        && matches!(shutdown.signal_settle, DrainOutcome::Completed);
    if !stopped
        || !drained
        || shutdown.device.is_err()
        || shutdown.message_secrets.failed_batches != 0
        || shutdown.message_secrets.first_error.is_some()
    {
        return Err(std::io::Error::other(format!("incomplete shutdown: {report:?}")).into());
    }
    Ok(())
}

BotHandle also exposes abort() as an escape hatch that skips the flush. The example returns an error if the run did not stop as requested, a tracked drain did not complete, or persistence reported a failure. A completed drain does not prove successful delivery, and this report does not join every detached callback or stop concurrent producers. See the Bot API.

Prefer [`shutdown_signal()`](/installation#graceful-shutdown) over a bare `tokio::signal::ctrl_c()`: it resolves on **SIGTERM as well as SIGINT**. Supervisors like `docker stop`, Kubernetes, and systemd stop a process with SIGTERM first — a Ctrl+C-only wait never sees it, so cleanup is skipped and the process is hard-killed once the stop grace period elapses. Requires the `signal` feature, on by default.

Complete example with logging

Add the logging dependencies alongside the pinned whatsapp-rust and Tokio dependencies above:

log = "0.4"
env_logger = "0.11"

This example logs events and responds with text or reused media:

use log::{error, info};
use whatsapp_rust::prelude::*;

const PING_TRIGGER: &str = "🦀ping";
const PONG_TEXT: &str = "🏓 Pong!";
const REACTION_EMOJI: &str = "🏓";

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
        .format(|buf, record| {
            use std::io::Write;
            writeln!(
                buf,
                "{} [{:<5}] [{}] - {}",
                whatsapp_rust::wacore::time::now_utc().format("%H:%M:%S"),
                record.level(),
                record.target(),
                record.args()
            )
        })
        .init();

    let store = SqliteStore::open("whatsapp.db").await?;
    info!("SQLite backend initialized");

    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...");
    let reason = bot.run().await;
    println!("Session ended: {reason:?}");
    Ok(())
}

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}");
        }
        return;
    }

    if ctx.message.text_content() == Some(PING_TRIGGER) {
        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}");
        }
    }
}

/// Reuses the original CDN blob, only swaps the caption.
/// Instant regardless of file size — no download or re-upload.
fn build_media_pong(message: &wa::Message) -> Option<wa::Message> {
    let base = message.get_base_message();
    if let Some(img) = base.image_message.as_option()
        && img.caption.as_deref() == Some(PING_TRIGGER)
    {
        return Some({
            let mut value = wa::Message::default();
            value.image_message = MessageField::some({
                let mut value = img.clone();
                value.caption = Some(PONG_TEXT.to_string());
                value
            });
            value
        });
    }
    if let Some(vid) = base.video_message.as_option()
        && vid.caption.as_deref() == Some(PING_TRIGGER)
    {
        return Some({
            let mut value = wa::Message::default();
            value.video_message = MessageField::some({
                let mut value = vid.clone();
                value.caption = Some(PONG_TEXT.to_string());
                value
            });
            value
        });
    }
    None
}

Configuring log targets

whatsapp-rust uses the log crate with module-specific targets for fine-grained filtering. You can use RUST_LOG to control which components emit log output.

Available log targets

Target Description
Client/Keepalive Keepalive ping/pong and dead socket detection
Client/Recv Incoming frame processing (unmarshal, decompress)
Client/Send Outgoing message encryption and dispatch
Client/OfflineSync Offline message sync progress and timing
Client/AppState App state sync (contacts, settings, etc.)
Client/AccountSync Account-level sync operations
Client/PairCode Pair code authentication flow
Client/Pair QR code pairing flow
Client/Receipt Receipt processing (read, delivered, played)
Client/TcToken Trusted contact token operations
Client/Group Group metadata and participant operations
Client/Contacts Contact sync and lookup
Client/Business Business profile updates
Client/PDO Peer Data Operations — message recovery from primary phone via bare-JID peer messaging and retry fallback
Client/IQ IQ stanza send/receive
Client/Ack Stanza acknowledgment
Client/Status Status/story operations
Client/Picture Profile picture updates
Client/UnifiedSession Session establishment
Blocking Block/unblock operations
Chatstate Typing indicator events
PresenceHandler Presence update processing
AppState App state patch encoding/decoding
Bot/PairCode Bot-level pair code handling

Filtering examples

# Show only connection and messaging logs
RUST_LOG="Client/Keepalive=debug,Client/Send=debug,Client/Recv=trace" cargo run

# Debug offline sync issues
RUST_LOG="Client/OfflineSync=debug" cargo run

# Quiet mode: only errors and warnings
RUST_LOG="warn" cargo run

# Verbose: all client internals at debug level
RUST_LOG="debug" cargo run
During shutdown or disconnect, the client automatically downgrades sync errors from `error` to `debug` level to reduce noise. This means you won't see spurious error logs when the client is intentionally disconnecting.

Running with Docker

You can also run the bot using Docker instead of compiling locally:

docker build -t whatsapp-rust .
docker run -v ./data:/data whatsapp-rust

Session data is stored in the /data directory inside the container. Mount a volume to persist it across restarts. The container shuts down gracefully on docker stop — the bot disconnects cleanly from WhatsApp before exiting. See the installation guide for more details.

Benchmarking

The repository includes a benchmark example at examples/benchmark.rs that you can use for quick integration-level performance testing. It uses an in-memory backend and supports a custom WebSocket URL via the WHATSAPP_WS_URL environment variable:

cargo run --example benchmark --features danger-skip-tls-verify
The benchmark example requires the `danger-skip-tls-verify` feature flag because it's designed for use with local test servers.

For end-to-end numbers, run the bench-integration suite — it drives real scenarios (connect, send, receive, reconnect) against a mock server, so point MOCK_SERVER_URL at one before you start:

# Requires a mock server (e.g., Bartender)
MOCK_SERVER_URL="wss://127.0.0.1:8080/ws/chat" \
  cargo bench -p bench-integration

For low-level protocol numbers, run the divan suites in wacore — CI tracks the same ones through CodSpeed. They cover the full send/receive pipeline (DM and group messaging with various participant counts), binary protocol encoding, Signal Protocol operations, and reporting token generation:

# Default workspace members only — this leaves out bench-integration,
# which needs the mock server above
cargo bench

# The voip-gated suites need their feature turned on
cargo bench -p wacore --features voip

See the wacore benchmarks documentation for details on each suite, allocation optimizations, and CI integration.

Next steps

Learn about different message types and how to send them Upload and download images, videos, and documents Create and manage WhatsApp groups Explore all available client methods

Before changing the dependency, read the Rust migration guide. Its newer merged contracts and experimental proposals are separate from this page's source pin.