Skip to main content
The Bot provides a simplified, ergonomic API for building WhatsApp bots. It handles client setup, event routing, and background sync tasks automatically.

Overview

Use the Bot builder pattern to:
  • Configure storage backend, transport, HTTP client, and async runtime
  • Register event handlers
  • Configure device properties and versions
  • Enable pair code authentication
  • Skip history sync for bot use cases
The builder uses a typestate pattern with four type parameters <B, T, H, R> (Backend, Transport, HttpClient, Runtime). The build() method is only callable when all four are Provided, making missing-component errors compile-time instead of runtime.
The Bot is the recommended way to use whatsapp-rust. It provides sensible defaults and handles boilerplate setup.

Basic Usage

use whatsapp_rust::bot::Bot;
use whatsapp_rust::TokioRuntime;
use wacore::types::events::Event;

let mut bot = Bot::builder()
    .with_backend(backend)
    .with_transport_factory(transport)
    .with_http_client(http_client)
    .with_runtime(TokioRuntime)
    .on_event(|event, client| async move {
        match &*event {
            Event::Message(msg, info) => {
                println!("Message from {}: {:?}", info.source.sender, msg);
            }
            Event::Connected(_) => {
                println!("Connected to WhatsApp!");
            }
            _ => {}
        }
    })
    .build()
    .await?;

let bot_handle = bot.run().await?;
bot_handle.await?;

Builder Methods

builder

pub fn builder() -> BotBuilder
Creates a new bot builder.

with_backend

pub fn with_backend(self, backend: Arc<dyn Backend>) -> Self
Sets the storage backend (required).
backend
Arc<dyn Backend>
required
Backend implementation providing storage operations
Example:
use whatsapp_rust::store::SqliteStore;

let backend = Arc::new(SqliteStore::new("whatsapp.db").await?);
let bot = Bot::builder()
    .with_backend(backend)
    // ...
For multi-account scenarios, use SqliteStore::new_for_device(path, device_id) to create isolated storage per account.

with_transport_factory

pub fn with_transport_factory<F>(self, factory: F) -> Self
where
    F: TransportFactory + 'static
Sets the transport factory for creating WebSocket connections (required).
factory
F: TransportFactory
required
Transport factory implementation
Example:
use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory;

let bot = Bot::builder()
    .with_transport_factory(TokioWebSocketTransportFactory::new())
    // ...

with_http_client

pub fn with_http_client<C>(self, client: C) -> Self
where
    C: HttpClient + 'static
Sets the HTTP client for media operations and version fetching (required).
client
C: HttpClient
required
HTTP client implementation
Example:
use whatsapp_rust_ureq_http_client::UreqHttpClient;

let bot = Bot::builder()
    .with_http_client(UreqHttpClient::new())
    // ...

with_runtime

pub fn with_runtime<Rt>(self, runtime: Rt) -> Self
where
    Rt: Runtime + 'static
Sets the async runtime for spawning tasks, sleeping, and blocking operations (required).
runtime
Rt: Runtime
required
Runtime implementation providing spawn, sleep, and spawn_blocking
Example:
use whatsapp_rust::TokioRuntime;

let bot = Bot::builder()
    .with_runtime(TokioRuntime)
    // ...
TokioRuntime is only available when the tokio-runtime feature is enabled (it is by default). To use a different async runtime, implement the Runtime trait from wacore::runtime. See custom backends for details.

Event Handling

on_event

pub fn on_event<F, Fut>(self, handler: F) -> Self
where
    F: Fn(Arc<Event>, Arc<Client>) -> Fut + Send + Sync + 'static,
    Fut: Future<Output = ()> + Send + 'static
Registers an async event handler. The handler receives an Arc<Event> — use &*event to pattern-match on the inner event type.
handler
F
required
Async function that receives Arc<Event> and Arc<Client>
Example:
use waproto::whatsapp as wa;

Bot::builder()
    .on_event(|event, client| async move {
        match &*event {
            Event::Message(msg, info) => {
                // Reply to messages
                let reply = wa::Message {
                    conversation: Some("Hello back!".to_string()),
                    ..Default::default()
                };
                let _ = client.send_message(info.source.chat.clone(), reply).await;
            }
            Event::Connected(_) => {
                println!("Bot online!");
            }
            _ => {}
        }
    })
    // ...
See Events Reference for all event types.

Using ChannelEventHandler

For scenarios where you need to process events outside of a closure (e.g., testing, custom event loops, or runtime-agnostic code), use ChannelEventHandler with register_handler instead of on_event:
use wacore::types::events::{ChannelEventHandler, Event};

let mut bot = Bot::builder()
    .with_backend(backend)
    .with_transport_factory(transport)
    .with_http_client(http_client)
    .with_runtime(TokioRuntime)
    .build()
    .await?;

let client = bot.client();
let (event_handler, event_rx) = ChannelEventHandler::new();
client.register_handler(event_handler);

let handle = bot.run().await?;

// Process events in your own async loop
while let Ok(event) = event_rx.recv().await {
    match &*event {
        Event::Connected(_) => println!("Connected!"),
        Event::Message(msg, info) => {
            println!("Message from {}", info.source.sender);
        }
        _ => {}
    }
}
ChannelEventHandler uses async-channel (runtime-agnostic) with an unbounded buffer, so events fired before the receiver starts listening are not lost. You can combine it with on_event — both handlers will receive all events.

with_enc_handler

pub fn with_enc_handler<H>(self, enc_type: impl Into<String>, handler: H) -> Self
where
    H: EncHandler + 'static
Registers a custom handler for specific encrypted message types.
enc_type
String
required
Encrypted message type (e.g., “frskmsg”, “skmsg”)
handler
H: EncHandler
required
Handler implementation

Configuration

with_version

pub fn with_version(self, version: (u32, u32, u32)) -> Self
Overrides the WhatsApp version used by the client.
version
(u32, u32, u32)
required
Tuple of (primary, secondary, tertiary) version numbers
Example:
Bot::builder()
    .with_version((2, 3000, 1027868167))
    // ...
By default, the client automatically fetches the latest WhatsApp Web version from web.whatsapp.com/sw.js on each connect and caches it for 24 hours. Use with_version to pin a specific version when you need deterministic behavior — for example, in integration tests or CI environments where external HTTP requests are undesirable.

with_device_props

pub fn with_device_props(
    self,
    os_name: Option<String>,
    version: Option<wa::device_props::AppVersion>,
    platform_type: Option<wa::device_props::PlatformType>
) -> Self
Overrides device properties sent to WhatsApp servers.
os_name
Option<String>
Operating system name (e.g., “macOS”, “Windows”, “Linux”)
version
Option<AppVersion>
App version struct
platform_type
Option<PlatformType>
Platform type (determines device name shown on phone)
Example:
use waproto::whatsapp::device_props::{AppVersion, PlatformType};

Bot::builder()
    .with_device_props(
        Some("macOS".to_string()),
        Some(AppVersion {
            primary: Some(2),
            secondary: Some(0),
            tertiary: Some(0),
            ..Default::default()
        }),
        Some(PlatformType::Chrome),
    )
    // ...
The platform_type determines what device name is shown on the phone’s “Linked Devices” list. Common values: Chrome, Firefox, Safari, Desktop.

with_push_name

pub fn with_push_name(self, name: impl Into<String>) -> Self
Sets an initial push name on the device before connecting.
name
String
required
Display name to set on the device
Example:
Bot::builder()
    .with_push_name("My Bot")
    // ...
The push name is included in the ClientPayload during registration. This is useful for testing scenarios where the server assigns phone numbers based on push name.

Authentication

with_pair_code

pub fn with_pair_code(self, options: PairCodeOptions) -> Self
Configures pair code authentication to run automatically after connecting.
options
PairCodeOptions
required
Configuration for pair code authentication
Example:
use whatsapp_rust::pair_code::{PairCodeOptions, PlatformId};
use wacore::types::events::Event;

Bot::builder()
    .with_pair_code(PairCodeOptions {
        phone_number: "15551234567".to_string(),
        show_push_notification: true,
        custom_code: None,
        platform_id: PlatformId::Chrome,
        platform_display: "Chrome (Linux)".to_string(),
    })
    .on_event(|event, _client| async move {
        match &*event {
            Event::PairingCode { code, timeout } => {
                println!("Enter this code on your phone: {}", code);
                println!("Expires in: {} seconds", timeout.as_secs());
            }
            _ => {}
        }
    })
    // ...
Pair code runs concurrently with QR code pairing - whichever completes first wins.

Cache Configuration

with_cache_config

pub fn with_cache_config(self, config: CacheConfig) -> Self
Configures cache TTL and capacity settings for internal caches.
config
CacheConfig
required
Custom cache configuration
Example:
use whatsapp_rust::{CacheConfig, CacheEntryConfig};
use std::time::Duration;

Bot::builder()
    .with_cache_config(CacheConfig {
        group_cache: CacheEntryConfig::new(None, 500), // No TTL, 500 entries
        device_registry_cache: CacheEntryConfig::new(Some(Duration::from_secs(1800)), 2000),
        ..Default::default()
    })
    // ...
See Cache Configuration for available cache types.

History Sync

History sync transfers chat history from the phone to the linked device. The processing pipeline is optimized for minimal RAM usage through zero-copy streaming and lazy parsing.

How it works

When your bot receives history sync data, the pipeline:
  1. Stream-decrypts external blobs in 8KB chunks (or moves inline payloads without copying)
  2. Decompresses zlib data on a blocking thread with pre-allocated buffers capped at 8 MiB
  3. Walks protobuf fields manually instead of decoding the entire message tree — only internal data (pushname, NCT salt, TC tokens) is extracted at this stage
  4. Wraps the decompressed blob in a LazyHistorySync with cheap metadata (sync type, chunk order, progress) available without decoding
  5. Dispatches Event::HistorySync(Box<LazyHistorySync>) — full protobuf decoding is deferred until you call .get(). Use .raw_bytes() for custom partial decoding if you only need specific fields
If no event handlers are registered, the blob is not retained in memory.

skip_history_sync

pub fn skip_history_sync(self) -> Self
Skips processing of history sync notifications from the phone. When enabled:
  • Sends a receipt so the phone stops retrying uploads
  • Does not download or process historical data
  • Emits debug log for each skipped notification
  • Useful for bot use cases where message history is not needed
Example:
Bot::builder()
    .skip_history_sync()
    // ...
For bots that only need to respond to new messages, enabling this can significantly reduce startup time and bandwidth usage.

Building and Running

build

pub async fn build(self) -> Result<Bot, BotBuilderError>
Builds the bot with the configured options. Errors:
pub enum BotBuilderError {
    Other(anyhow::Error),
}
VariantCause
OtherBackend initialization or other runtime failure
Missing required components (backend, transport, HTTP client, runtime) are caught at compile time via the typestate pattern — build() is only available when all four type parameters are Provided. You won’t see runtime errors for missing components.

client

pub fn client(&self) -> Arc<Client>
Returns the underlying Client Arc. Example:
let bot = Bot::builder()
    .with_backend(backend)
    .with_transport_factory(transport)
    .with_http_client(http_client)
    .with_runtime(TokioRuntime)
    .build()
    .await?;

let client = bot.client();
let jid = client.get_pn().await;

run

pub async fn run(&mut self) -> Result<BotHandle>
Starts the bot’s connection loop and background workers. Returns a BotHandle that implements Future. You can also call .abort() on it to cancel the bot. Example:
let mut bot = Bot::builder()
    // ... configuration
    .build()
    .await?;

let handle = bot.run().await?;

// Wait for bot to finish (runs until disconnect)
handle.await?;
You must call .await? on the returned handle to keep the bot running. If you drop the handle, the bot will continue running in the background.

MessageContext

A convenience helper for message handling. You can construct it from the Event::Message components:
pub struct MessageContext {
    pub message: Box<wa::Message>,
    pub info: MessageInfo,
    pub client: Arc<Client>,
}

from_parts

pub fn from_parts(message: &wa::Message, info: &MessageInfo, client: Arc<Client>) -> Self
Constructs a MessageContext from individual message components.

from_event

pub fn from_event(event: &Event, client: Arc<Client>) -> Option<Self>
Extracts a MessageContext from an Event. Returns None if the event is not an Event::Message.

send_message

pub async fn send_message(&self, message: wa::Message) -> Result<SendResult, anyhow::Error>
Sends a message to the same chat. Returns a SendResult containing the message_id and to JID.

build_quote_context

pub fn build_quote_context(&self) -> wa::ContextInfo
Builds a quote context for replying to this message. Handles:
  • Correct stanza_id/participant for groups and newsletters
  • Stripping nested mentions
  • Preserving bot quote chains
Example:
use waproto::whatsapp as wa;
use whatsapp_rust::bot::MessageContext;

.on_event(|event, client| async move {
    if let Some(ctx) = MessageContext::from_event(&event, client) {
        let reply = wa::Message {
            extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage {
                text: Some("Quoted reply!".to_string()),
                context_info: Some(Box::new(ctx.build_quote_context())),
                ..Default::default()
            })),
            ..Default::default()
        };
        let _ = ctx.send_message(reply).await;
    }
})

edit_message

pub async fn edit_message(
    &self,
    original_message_id: impl Into<String>,
    new_message: wa::Message
) -> Result<String, anyhow::Error>
Edits a message in the same chat.

revoke_message

pub async fn revoke_message(
    &self,
    message_id: String,
    revoke_type: RevokeType
) -> Result<(), anyhow::Error>
Deletes a message in the same chat.

Complete Example

use whatsapp_rust::bot::Bot;
use whatsapp_rust::TokioRuntime;
use whatsapp_rust::store::SqliteStore;
use wacore::types::events::Event;
use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory;
use whatsapp_rust_ureq_http_client::UreqHttpClient;
use waproto::whatsapp as wa;
use std::sync::Arc;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Set up storage
    let backend = Arc::new(SqliteStore::new("bot.db").await?);

    // Build bot
    let mut bot = Bot::builder()
        .with_backend(backend)
        .with_transport_factory(TokioWebSocketTransportFactory::new())
        .with_http_client(UreqHttpClient::new())
        .with_runtime(TokioRuntime)
        .skip_history_sync() // Bot only needs new messages
        .on_event(|event, client| async move {
            match &*event {
                Event::Message(msg, info) => {
                    // Echo messages back
                    if let Some(text) = &msg.conversation {
                        let reply = wa::Message {
                            conversation: Some(format!("You said: {}", text)),
                            ..Default::default()
                        };
                        let _ = client.send_message(info.source.chat.clone(), reply).await;
                    }
                }
                Event::Connected(_) => {
                    println!("Bot is now online!");
                    // Set status
                    let _ = client.presence().set_available().await;
                }
                Event::PairingQrCode { code, .. } => {
                    println!("Scan this QR code:");
                    println!("{}", code);
                }
                _ => {}
            }
        })
        .build()
        .await?;

    // Run bot
    let handle = bot.run().await?;
    handle.await?;

    Ok(())
}

Cache Configuration Reference

The CacheConfig struct controls TTL and capacity for all internal caches. All fields have sensible defaults matching WhatsApp Web behavior.

CacheEntryConfig

pub struct CacheEntryConfig {
    pub timeout: Option<Duration>,  // None = no time-based expiry
    pub capacity: u64,              // Maximum entries
}

Available Caches

Timed caches

CacheDefault TTLDefault CapacityDescription
group_cache1 hour250Group metadata
device_registry_cache1 hour5,000Device registry
lid_pn_cache1 hour (TTI)10,000LID-to-phone mapping
retried_group_messages5 minutes2,000Retry tracking
recent_messages5 minutes0 (disabled)Optional L1 in-memory cache for sent messages (retry support)
message_retry_counts5 minutes1,000Retry count tracking
pdo_pending_requests30 seconds500PDO pending requests
sender_key_devices_cache1 hour (TTI)500Per-group SKDM distribution state
The lid_pn_cache and sender_key_devices_cache use time-to-idle (TTI) semantics — entries expire after being idle for the timeout period. All other caches use time-to-live (TTL) semantics.
The recent_messages cache is disabled by default (capacity 0), meaning sent messages are stored only in the database for retry handling — matching WhatsApp Web’s behavior. Set capacity greater than 0 to enable a fast in-memory L1 cache in front of the database. See DB-backed sent message retry for details.

Coordination caches (capacity-only, no TTL)

SettingDefaultDescription
session_locks_capacity10,000Per-device Signal session lock capacity
chat_lanes_capacity5,000Per-chat lane capacity (combined enqueue lock + message queue)

Sent message DB cleanup

SettingDefaultDescription
sent_message_ttl_secs300 (5 min)TTL in seconds for sent messages in DB before periodic cleanup. Set to 0 to disable automatic cleanup.

Custom cache store overrides

You can replace any of the pluggable caches with a custom CacheStore backend (e.g., Redis):
FieldCacheDescription
cache_stores.group_cacheGroup metadataGroup info lookups
cache_stores.device_registry_cacheDevice registryDevice registry entries
cache_stores.lid_pn_cacheLID-PN mappingLID-to-phone bidirectional lookups
use whatsapp_rust::{CacheConfig, CacheStores};
use std::sync::Arc;

let redis = Arc::new(MyRedisCacheStore::new("redis://localhost:6379"));

// Route specific caches to Redis
let config = CacheConfig {
    cache_stores: CacheStores {
        group_cache: Some(redis.clone()),
        device_registry_cache: Some(redis.clone()),
        ..Default::default()
    },
    ..Default::default()
};

// Or route all pluggable caches at once
let config = CacheConfig {
    cache_stores: CacheStores::all(redis.clone()),
    ..Default::default()
};
Fields left as None keep the default in-process moka behavior. See Custom backends — cache store for a full implementation guide.
Coordination caches (session_locks, chat_lanes), the signal write-behind cache, and pdo_pending_requests always stay in-process — they hold live Rust objects that cannot be serialized to an external store.

Custom Configuration Example

use whatsapp_rust::{CacheConfig, CacheEntryConfig};
use std::time::Duration;

let config = CacheConfig {
    // Disable TTL for group cache (entries only evicted by capacity)
    group_cache: CacheEntryConfig::new(None, 500),
    // Shorter TTL for device cache
    device_registry_cache: CacheEntryConfig::new(Some(Duration::from_secs(1800)), 3_000),
    // Enable L1 in-memory cache for sent messages (faster retry lookups)
    recent_messages: CacheEntryConfig::new(Some(Duration::from_secs(300)), 1_000),
    // Use defaults for everything else
    ..Default::default()
};

let bot = Bot::builder()
    .with_runtime(TokioRuntime)
    .with_cache_config(config)
    // ...

DB-backed sent message retry

Sent messages are persisted to the database for retry handling, matching WhatsApp Web’s getMessageTable pattern. When a retry receipt arrives, the client looks up the original message payload from the database, re-encrypts it, and resends. How it works:
  1. Every send_message() call stores the serialized message payload in the sent_messages table
  2. On retry receipt, the client retrieves and consumes the payload (atomic take)
  3. Expired entries are periodically cleaned up based on sent_message_ttl_secs
Optional L1 cache: By default, the recent_messages cache capacity is 0 (DB-only mode). If you set capacity greater than 0, sent messages are also cached in memory for faster retrieval. In L1 mode, the DB write is backgrounded since the cache serves reads immediately. In DB-only mode, the write is awaited to guarantee persistence.
let config = CacheConfig {
    // Enable L1 in-memory cache for faster retry lookups
    recent_messages: CacheEntryConfig::new(Some(Duration::from_secs(300)), 1_000),
    // Keep sent messages in DB for 10 minutes before cleanup
    sent_message_ttl_secs: 600,
    ..Default::default()
};

See Also