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
<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.
Basic Usage
Builder Methods
builder
with_backend
Arc<dyn Backend>
required
Backend implementation providing storage operations
with_transport_factory
F: TransportFactory
required
Transport factory implementation
with_http_client
C: HttpClient
required
HTTP client implementation
with_runtime
Rt: Runtime
required
Runtime implementation providing spawn, sleep, and spawn_blocking
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.with_task_instrument
TaskInstrument hook, called around every poll of a spawned task (and around blocking work). Runtime-agnostic — it wraps whichever Runtime the client uses (via with_runtime or the default), so every task spawned through the Runtime trait is covered. Bot::run also meters the main run loop itself (the read loop, including frame decryption), so that work is covered whether you launch via bot.run().await or Bot::spawn() — the two paths never double-wrap. Default: no hook, the runtime is used untouched and nothing is metered.
Pass the built-in wacore::stats::CpuMeter for per-session CPU accounting (busy time + poll count), keeping a clone to read .snapshot() later. Or implement TaskInstrument yourself to scope allocator attribution, an ESP-IDF heap_caps sampler, or any other per-session platform hook — the library only calls on_poll_start/on_poll_end and never inspects what the hook does.
Arc<dyn wacore::stats::TaskInstrument>
required
Hook invoked around every poll of the client’s internal tasks
Opt-in and off by default — instrumenting every poll has measurable overhead, so treat this as a diagnostics tool rather than an always-on meter. See
Client::stats() for always-on wire I/O counters (atomics incremented on every frame) and Client::memory_report() for the on-demand, zero-cost-when-unused memory breakdown.with_alloc_meter
wacore::stats::AllocMeter as this client’s task instrument and keeps a typed handle so Client::resource_report() can fold in its allocation-churn snapshot. AllocMeter is a first-class TaskInstrument — the churn counterpart to CpuMeter’s busy-time tracking — that attributes heap bytes allocated and freed to this client.
This is sugar over with_task_instrument: it occupies the same single instrument slot, so it’s mutually exclusive with CpuMeter or any other hook — last setter wins. Calling with_task_instrument after with_alloc_meter drops the typed alloc-meter handle, so resource_report()’s alloc field reverts to None even though the instrument itself is replaced.
The library never touches an allocator directly. The host must install a #[global_allocator] that calls AllocMeter::on_alloc / AllocMeter::on_dealloc on every (de)allocation — examples/alloc_tracking.rs in the source repo is the ~20-line reference implementation.
Arc<wacore::stats::AllocMeter>
required
The allocation meter to install and drive via the poll hooks
Event Handling
on_event
Arc<Event> — use &*event to pattern-match on the inner event type.
F
required
Async function that receives
Arc<Event> and Arc<Client>on_event_for
on_event, but registers the handler with a narrowed EventInterest so the event bus skips materializing kinds you don’t subscribe to. Useful when you only handle a couple of event types and want to avoid paying the Arc allocation for high-frequency events like presence or receipts.
with_event_delivery
on_event/on_event_for (and the other closure-based registrars) receive events off the bus.
EventDelivery
required
Delivery strategy. Defaults to
EventDelivery::Concurrent — existing consumers are unaffected unless they opt in.capacity is clamped to at least 1. Only affects the closure-based callbacks (on_event, on_event_for, on_message, and the other typed registrars) — a raw handler registered via with_event_handler always runs inline on the dispatch path and is unaffected by this setting.with_event_handler
EventHandler directly on the bus. Unlike the closure registrars, the handler holds its state as struct fields (no per-field clone dance); because handle_event takes &self, mutable state requires interior mutability (Mutex, RwLock, or atomics). handle_event runs inline on the dispatch path — spawn your own task for slow work. Not affected by with_event_delivery.
Example:
Using ChannelEventHandler
For scenarios where you need to process events outside of a closure (e.g., testing, custom event loops, or runtime-agnostic code), useChannelEventHandler with register_handler instead of on_event:
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
String
required
Encrypted message type (e.g., “frskmsg”, “skmsg”)
H: EncHandler
required
Handler implementation
On
wasm32 targets, EncHandler drops the Send + Sync supertrait — your handler can capture !Send JS handles. On native, Send + Sync is retained via MaybeSendSync so Arc<dyn EncHandler> remains thread-safe. This mirrors the convention used by EventHandler and SendContextResolver.Configuration
with_version
(u32, u32, u32)
required
Tuple of (primary, secondary, tertiary) version numbers
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
Option<String>
Operating system name (e.g., “macOS”, “Windows”, “Linux”)
Option<AppVersion>
App version struct
Option<PlatformType>
Platform type (determines device name shown on phone)
with_push_name
String
required
Display name to set on the device
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
PairCodeOptions
required
Configuration for pair code authentication
Pair code runs concurrently with QR code pairing — whichever completes first wins.
on_pair_code_refresh
Event::PairingCodeRefresh, fired when the server asks the companion to refresh an in-progress pair code. The bool argument is force_manual.
F
required
Async function that receives
force_manual: bool and Arc<Client>Only fires while a pair-code flow is outstanding and the notification’s ref matches it — a
refresh_code notification for a stale or unrelated flow is ignored.Cache Configuration
with_cache_config
CacheConfig
required
Custom cache configuration
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:- Stream-decrypts external blobs in 8KB chunks (or moves inline payloads without copying)
- Decompresses zlib data on a blocking thread with pre-allocated buffers capped at 8 MiB
- Walks protobuf fields manually instead of decoding the entire message tree — only internal data (pushname, NCT salt, TC tokens) is extracted at this stage
- Wraps the compressed payload in a
LazyHistorySyncwith cheap metadata (sync type, chunk order, progress) available without decoding - Dispatches
Event::HistorySync(Box<LazyHistorySync>)— full protobuf decoding is deferred until you call.get(). Use.stream()for incremental memory-bounded access,.decompress()for one-shot inflation, or.compressed_bytes()for the raw compressed payload
skip_history_sync
- 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
with_wanted_pre_key_count
UPLOAD_KEYS_COUNT. Default: 812.
The value is clamped at upload time to 5..=65_535. Values outside that range log a warn! and are clamped to the nearest bound.
usize
required
Pre-keys per upload batch. Clamped to
5..=65_535.Building and Running
build
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.BotBuilder is #[must_use] — building it does nothing until you call .build(), so a builder chain left unbound (or dropped before .build()) now triggers a compiler warning.client
run
BotHandle that implements Future. You can also call .abort() on it to cancel the bot.
Example:
If a
with_task_instrument hook is configured, run() meters the client’s main run loop (the read loop, including frame decryption) itself, in addition to the tasks the instrumented runtime already covers. This closes the gap where bot.run().await polls that future on the caller’s task rather than through Runtime::spawn.MessageContext
A convenience helper for message handling. You can construct it from anInboundMessage — the item type carried by Event::Messages’ MessageBatch:
Since v0.6
message is Arc<wa::Message> (was Box<wa::Message>). This matches the InboundMessage payload and lets from_inbound / from_arc reuse the bus-dispatched Arc with zero deep clones.from_parts
MessageContext from individual message components. Internally clones the wa::Message into a new Arc.
from_arc
MessageContext from an existing Arc<wa::Message> without copying the body — pair this with the Arc you receive from an InboundMessage to keep dispatch zero-clone.
from_inbound
MessageContext from a single InboundMessage (one item of a MessageBatch). Unlike the removed from_event, this is infallible — there’s no “wrong event kind” case once you’re iterating Event::Messages’ batch. Reuses the existing Arc<wa::Message> rather than cloning the body. This is what Bot::on_message uses internally to fan a batch out to your per-message handler, invoked once per item in arrival order.
send_message
SendResult containing the message_id and to JID.
build_quote_context
- Correct stanza_id/participant for groups and newsletters
- Stripping nested mentions
- Preserving bot quote chains
edit_message
revoke_message
react
participant are taken from the context — you only supply the emoji. Pass an empty string ("") to remove a previously sent reaction.
Internally this calls Client::send_reaction with self.message_key() as the target.
Example:
Newsletter (channel) messages don’t flow through
MessageContext::react. Use client.newsletter().send_reaction() for newsletter reactions.Complete Example
Cache configuration reference
TheCacheConfig struct controls TTL and capacity for all internal caches. All fields have sensible defaults matching WhatsApp Web behavior.
CacheEntryConfig
Available Caches
Timed caches
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)
The lane’s live entry count plus cumulative capacity evictions and blocked evictions are exposed via
Client::memory_report() (group_distribution_locks, group_distribution_lock_evictions, group_distribution_lock_eviction_blocks), so operators can derive eviction rates and tune this capacity without guessing.Sent message DB cleanup
The
sent_message_ttl_secs default was raised from 300s to 7200s. Retry receipts can arrive well after a message is sent (e.g. after the recipient comes back online); a 5-minute TTL could expire the stored payload before its retry, silently dropping the retry. Two hours covers realistic offline gaps.messageSecret retention
The client storesmessageSecret values so it can later decrypt add-ons that reference an original message — poll votes, poll/event edits, message edits, and Meta AI / fbid bot replies. Retention is bounded by policy and a per-class event-time horizon (expires_at = parent_message_ts + horizon, not insertion time), so secrets survive offline gaps without growing unbounded.
OriginalMessageResolver trait lets you supply secrets from your own store (required when the policy is Disabled):
MsgSecretStore and the LID/PN alternate lookups miss.
MsgSecretPolicy, MsgSecretRetention, and OriginalMessageResolver are re-exported from the crate root (whatsapp_rust::{MsgSecretPolicy, MsgSecretRetention, OriginalMessageResolver}). The default Managed policy is bounded and needs no tuning for most apps.Custom cache store overrides
You can replace any of the pluggable caches with a customCacheStore backend (e.g., Redis):
None keep the default in-process PortableCache behaviour. See Custom backends — cache store for a full implementation guide.
Coordination caches (
session_locks, chat_lanes, group_distribution_locks), 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
DB-backed sent message retry
Sent messages are persisted to the database for retry handling, matching WhatsApp Web’sgetMessageTable 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:
- Every
send_message()call stores the serialized message payload in thesent_messagestable - On retry receipt, the client retrieves and consumes the payload (atomic take)
- Expired entries are periodically cleaned up based on
sent_message_ttl_secs
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.
See Also
- Client - Lower-level client API
- Events - All event types
- Sending Messages - Sending messages
- Storage - Storage and multi-account patterns