Bot provides a simplified, ergonomic API for building WhatsApp bots. It handles client setup, event routing, and background sync tasks automatically.
Don’t confuse this with
client.bots(), which fetches WhatsApp’s server-side directory of first-party AI bots. Bot here is the client-side framework for building a program that answers messages — a different domain that happens to share the word.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
Arc on each call, so that Arc isn’t shared with any other builder — though the client value itself still can be: cloning a Clone client like UreqHttpClient shares its underlying ureq::Agent and connection pool, so with_http_client(shared.clone()) already shares state today. Use with_http_client_arc to hand several bots the identical Arc<dyn HttpClient> instead of a clone apiece.
C: HttpClient
required
HTTP client implementation
with_http_client_arc
with_http_client, but for a client that’s already behind an Arc<dyn HttpClient>. Reach for this when a process runs several bots and you want them all pointing at one client — and one connection pool — instead of a clone apiece per builder.
A single non-Clone client passed by value to one builder already works with with_http_client, since it carries no Clone bound. What with_http_client can’t do is hand that same non-Clone client to a second builder — passed by value, it moves into the first one and no further. This setter covers that case, and it’s also the only way in for a client that’s already type-erased to Arc<dyn HttpClient> because the host chooses it at runtime.
Arc<dyn HttpClient>
required
Already-shared 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 checks the cached WhatsApp Web version on each connect and only fetches a new one if that cache is missing or over 24 hours old. It reads that version from
web.whatsapp.com/sw.js, except on wasm32 targets, which read the same revision from the Facebook JS SDK bundle instead (see HTTP Client — the version fetch does not pool a connection). 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.As of PR #1360: that
wasm32 source, the Facebook JS SDK bundle, sits on common tracker blocklists. If a content blocker keeps a wasm client from reaching it, the client still connects: it settles for the version the device already holds and reports this on Event::Connected via app_version_fallback. Call with_version to skip the fetch (and this fallback) entirely, on either target.with_device_props
DeviceProps fields sent to WhatsApp servers during pairing. Takes a DevicePropsOverride built via its chained setters — a field left unset keeps the library’s default for that field, it does not clear it.
DevicePropsOverride
required
Builder describing which
DeviceProps fields to overrideDevicePropsOverride
WA Web itself only ever sends one of two coherent
DeviceProps shapes, chosen by whether the companion is a browser or the Windows-native (“win_hybrid”) client:The library’s sync fields (
require_full_sync, history_sync_config) default to the browser row’s sync behavior. Pairing requests a recent history sync, not a full backfill.The identity fields (os, platform_type) deliberately follow neither row. They stay at "rust" / UNKNOWN by default, so the library doesn’t impersonate a specific client unless you configure one.To opt into a full backfill, set require_full_sync together with the matching history_sync_config fields. Setting require_full_sync alone produces a combination no real WhatsApp client sends: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.with_ab_props_fetch
bool
required
Whether
fetch_props() runs on connect. Default: true.The catalog is the largest frame of an ordinary login — a few thousand props, ~30 KB compressed — and the client keeps a couple of dozen of them. It’s consumed as a stream (see
Client::execute_streaming), so on most hosts it costs nothing worth turning off. Turn it off only if your target’s heap can’t afford even the compressed frame plus the inflate state (~80 KB together) at the moment it arrives.Turned off, every flag reads as its registry default — the value WA Web itself uses before its first fetch. The server sees no abt request (whatsmeow never sends one) and accepts your client either way. The cost: an account the server has 1:1-LID-migrated is not recognized as such from the props (lid_one_on_one_migration_enabled defaults to off), and the privacy-token and trusted-contact-token gates run on their defaults.Authentication
with_pair_code
PairCodeOptions
required
Configuration for pair code authentication
Pair code runs concurrently with QR code pairing — whichever completes first wins.
with_pair_code runs Client::pair_with_code in a detached task, so a failure never reaches a caller as an Err — it only reaches Event::PairingCodeError. Register on_pair_code_error if the consumer must distinguish “still waiting for the user” from “no code is coming” (a rate-limited request otherwise looks identical to the former).on_pair_code_refresh
Event::PairingCodeRefresh, fired when the in-progress phone-number pairing code should be replaced. The bool argument is force_manual.
F
required
Async function that receives
force_manual: bool and Arc<Client>This callback fires for two triggers, not just a server request: the server asking for a refresh (only 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), and a non-refused companion_finish — accepted, or its own 30s wait going unanswered — whose pair-success then went unanswered for a minute (force_manual is always false for this second trigger). A companion_finish the server actively refuses is reported through on_pair_code_error instead, immediately rather than after this timeout — register that handler too if you need to react to a refusal, since it no longer reaches this one. See Pair code refresh events for the full breakdown.on_pair_code_error
Event::PairingCodeError, fired when a pair-code flow fails — either a stage-1 request that never gets a code issued, or a stage-2 companion_finish the server refuses after a code was already entered on the phone. The counterpart to on_pair_code_refresh on the failure path, and a dedicated convenience over matching the event yourself in on_event — either works to observe it. A with_pair_code request runs in a detached task, so the Err it would otherwise return reaches no caller directly; this event is the only surface that reports its failure at all.
F
required
Async function that receives the
PairingCodeError event and Arc<Client>Branch on
err.rejection rather than the message, which is not a stable surface — see Pair code failure events for the full field breakdown and the two failures (PairCodeError::CodeAlreadyOutstanding and Cancelled) that deliberately never reach this handler.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.ephemeral_expiration and comment_target used to live on the shared MessageInfo. Writing them there needed an Arc::make_mut copy of the whole struct on every message in an ephemeral chat. They moved to InboundMessage to avoid that copy, and MessageContext now carries them too.Use from_inbound when you need these fields — it’s the only constructor that sees the stanza-derived event. from_arc and from_parts build from message/info alone, so they always leave both fields None. A bot handler reached through Bot::on_message (which uses from_inbound internally) sees them populated whenever the underlying event carried them.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. ephemeral_expiration and comment_target are left None, since this constructor never sees the stanza they’re read from.
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, and carries ephemeral_expiration and comment_target straight from the event. 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
Client::edit_message for what the returned SendResult describes.
revoke_message
Client::revoke_message for what the returned SendResult describes.
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.device_registry_cache’s default rose from 5,000 to 20,000 in whatsapp-rust#1400. At 5,000, if your account was active in a few dozen mid-sized groups, it could hold more distinct contacts than the cache, so every group-devices memo recompute paid a backend read per evicted member. The cache only ever holds what has been resolved, so you pay nothing for the higher ceiling if your account stays small.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.group_devices_memo_capacity and dm_devices_memo_capacity replaced two private, fixed constants in whatsapp-rust#1400 and gained least-recently-used eviction in the same change (previously oldest-first). The prior fixed bound was 64 groups: if you rotate sends across more than 64 groups, oldest-first eviction guaranteed the entry for the group you were about to resolve had already been evicted by the intervening sends, for a hit rate of exactly zero. At the new default of 512, the client_group_scale bench’s warm-resolve pass over 256 groups of 64 members each (one send per group, cycling through all of them) goes from a 33.4 ms median — every group missing and re-resolving from scratch, at the old 64-group bound — to 33.0 µs — every group hitting, at the new 512 bound.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.Steady-state sizing.
msg_secret_retention sizes what is, by row count, the largest table the store holds: one row per inbound message that carries a messageSecret, plus one per outbound message that mints one, each kept until its horizon passes. The steady state is therefore the horizon’s worth of traffic, and nothing else bounds it. For a busy bot — roughly 15k inbound and 1.5k outbound messages a day — the default 30-day text horizon settles at ~500k rows, or ~130 MB at roughly 270 bytes a row once the primary key and the expiry index are counted, and materially more where poll traffic (a 90-day horizon) is heavy. Shortening text trades add-on decryption of older messages for disk, and is the one lever that moves the figure. See whatsapp-rust#1411.InMemoryBackend only: on top of the horizons above, the built-in InMemoryBackend caps the number of expiring secret rows it retains at 8,192, backend-wide across all chats — so wasm32 linear memory (whose allocator never returns freed pages) can’t grow without bound from that traffic. Permanent rows (expires_at = 0, written by Full policy or a direct put_msg_secret call) are never evicted and don’t count toward the cap, so they can still accumulate past it. Once the cap is hit, eviction drops the soonest-to-expire rows first, always by whole message — a message’s sender-alias rows are kept or dropped together. That’s not necessarily the oldest messages: horizons differ by class, so a fresh text secret (30-day horizon) can expire, and be evicted, before an older poll secret (90-day horizon). SqliteStore, the production backend, has no such cap and honors the full msg_secret_retention horizons unmodified. See whatsapp-rust#1297.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
- Bots - Server-side directory of first-party AI bots
- Events - All event types
- Sending Messages - Sending messages
- Storage - Storage and multi-account patterns