Skip to main content

whatsapp_rust/
bot.rs

1use crate::cache_config::CacheConfig;
2use crate::client::{Client, ClientBuilderError};
3use crate::pair_code::PairCodeOptions;
4#[cfg(feature = "plugins")]
5use crate::plugins::{ClientPlugin, PluginHostConfig, PluginRegistration, UntypedClientPlugin};
6use crate::store::commands::DeviceCommand;
7use crate::store::error::StoreError;
8use crate::store::persistence_manager::PersistenceManager;
9use crate::store::traits::Backend;
10use crate::types::durability_hook::InboundDurabilityHook;
11use crate::types::enc_handler::EncHandler;
12use crate::types::events::{Event, EventHandler, EventInterest, EventKind};
13use crate::types::message::MessageInfo;
14use futures::FutureExt;
15use log::{info, warn};
16use std::collections::HashMap;
17use std::future::Future;
18use std::marker::PhantomData;
19use std::pin::Pin;
20use std::sync::{Arc, Weak};
21use thiserror::Error;
22use wacore::proto_helpers::MessageBuilderExt;
23use wacore::runtime::Runtime;
24use wacore::store::DevicePropsOverride;
25use waproto::whatsapp as wa;
26
27/// Typestate marker: the builder field has been provided (or pre-filled by a
28/// feature-gated default).
29pub struct Provided;
30/// Typestate marker: no storage [`Backend`] has been provided yet.
31pub struct MissingBackend;
32/// Typestate marker: no transport factory has been provided yet. Enabled
33/// `tokio-transport` (default) pre-fills this slot.
34pub struct MissingTransport;
35/// Typestate marker: no HTTP client has been provided yet. Enabled
36/// `ureq-client` (default) pre-fills this slot.
37pub struct MissingHttpClient;
38/// Typestate marker: no async runtime has been provided yet. Enabled
39/// `tokio-runtime` (default) pre-fills this slot.
40pub struct MissingRuntime;
41
42#[cfg(feature = "tokio-transport")]
43type DefaultTransportState = Provided;
44#[cfg(not(feature = "tokio-transport"))]
45type DefaultTransportState = MissingTransport;
46
47#[cfg(feature = "ureq-client")]
48type DefaultHttpState = Provided;
49#[cfg(not(feature = "ureq-client"))]
50type DefaultHttpState = MissingHttpClient;
51
52#[cfg(feature = "tokio-runtime")]
53type DefaultRuntimeState = Provided;
54#[cfg(not(feature = "tokio-runtime"))]
55type DefaultRuntimeState = MissingRuntime;
56
57#[cfg(feature = "tokio-transport")]
58fn default_transport_factory() -> Option<Arc<dyn crate::transport::TransportFactory>> {
59    Some(Arc::new(
60        crate::transport::TokioWebSocketTransportFactory::new(),
61    ))
62}
63#[cfg(not(feature = "tokio-transport"))]
64fn default_transport_factory() -> Option<Arc<dyn crate::transport::TransportFactory>> {
65    None
66}
67
68#[cfg(feature = "ureq-client")]
69fn default_http_client() -> Option<Arc<dyn crate::http::HttpClient>> {
70    Some(Arc::new(crate::http::UreqHttpClient::new()))
71}
72#[cfg(not(feature = "ureq-client"))]
73fn default_http_client() -> Option<Arc<dyn crate::http::HttpClient>> {
74    None
75}
76
77#[cfg(feature = "tokio-runtime")]
78fn default_runtime() -> Option<Arc<dyn Runtime>> {
79    Some(Arc::new(crate::runtime_impl::TokioRuntime))
80}
81#[cfg(not(feature = "tokio-runtime"))]
82fn default_runtime() -> Option<Arc<dyn Runtime>> {
83    None
84}
85
86#[derive(Debug, Error)]
87#[non_exhaustive]
88pub enum BotBuilderError {
89    /// Initializing the device row in the storage backend failed.
90    #[error("failed to initialize the device store: {0}")]
91    Store(#[from] StoreError),
92    #[error("{0}")]
93    Client(#[from] ClientBuilderError),
94}
95
96/// `message` is `Arc` so cloning the context across spawned tasks only bumps a
97/// refcount, matching the pattern used by serenity's `Context` and matrix-sdk's
98/// `Room`/`Client`.
99#[derive(Clone)]
100pub struct MessageContext {
101    pub message: Arc<wa::Message>,
102    pub info: MessageInfo,
103    pub client: Arc<Client>,
104}
105
106impl MessageContext {
107    /// Builds a context from borrowed parts, deep-cloning `message`. Prefer
108    /// [`MessageContext::from_arc`]/[`MessageContext::from_inbound`] when an
109    /// `Arc<wa::Message>` is already at hand (the event bus always has one).
110    pub fn from_parts(message: &wa::Message, info: &MessageInfo, client: Arc<Client>) -> Self {
111        Self::from_arc(Arc::new(message.clone()), info, client)
112    }
113
114    pub fn from_arc(message: Arc<wa::Message>, info: &MessageInfo, client: Arc<Client>) -> Self {
115        Self {
116            message,
117            info: info.clone(),
118            client,
119        }
120    }
121
122    pub fn from_inbound(
123        inbound: &wacore::types::events::InboundMessage,
124        client: Arc<Client>,
125    ) -> Self {
126        Self::from_arc(Arc::clone(&inbound.message), &inbound.info, client)
127    }
128
129    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.bot.send_message", level = "debug", skip_all, fields(chat = %self.info.source.chat.observe()), err(Debug)))]
130    pub async fn send_message(
131        &self,
132        message: wa::Message,
133    ) -> Result<crate::send::SendResult, crate::send::SendError> {
134        self.client
135            .send_message(&self.info.source.chat, message)
136            .await
137    }
138
139    /// Reply with plain text in the same chat, without quoting.
140    pub async fn reply(
141        &self,
142        text: impl Into<String>,
143    ) -> Result<crate::send::SendResult, crate::send::SendError> {
144        self.send_message(wa::Message::text(text)).await
145    }
146
147    /// Reply with plain text, quoting the received message.
148    pub async fn reply_quoting(
149        &self,
150        text: impl Into<String>,
151    ) -> Result<crate::send::SendResult, crate::send::SendError> {
152        let context = self.build_quote_context();
153        self.send_message(wa::Message::text_with_context(text, context))
154            .await
155    }
156
157    pub fn build_quote_context(&self) -> wa::ContextInfo {
158        // A bot reply is same-chat: quoted chat and send target are both
159        // info.source.chat, so remote_jid is omitted (WA Web parity).
160        let chat = &self.info.source.chat;
161        wacore::proto_helpers::build_quote_context_with_info(
162            &self.info.id,
163            &self.info.source.sender,
164            chat,
165            chat,
166            &self.message,
167        )
168    }
169
170    /// Referential [`wa::MessageKey`] for [`wa::message::ReactionMessage::key`].
171    /// Sender-side revokes have a different shape; use [`Client::revoke_message`].
172    pub fn message_key(&self) -> wa::MessageKey {
173        use wacore_binary::JidExt;
174        let needs_participant =
175            self.info.source.is_group || self.info.source.chat.is_status_broadcast();
176        wa::MessageKey {
177            remote_jid: Some(self.info.source.chat.to_string()),
178            from_me: Some(self.info.source.is_from_me),
179            id: Some(self.info.id.clone()),
180            participant: needs_participant.then(|| self.info.source.sender.to_string()),
181        }
182    }
183
184    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.bot.edit_message", level = "debug", skip_all, fields(chat = %self.info.source.chat.observe()), err(Debug)))]
185    pub async fn edit_message(
186        &self,
187        original_message_id: impl Into<String>,
188        new_message: wa::Message,
189    ) -> Result<String, crate::send::SendError> {
190        self.client
191            .edit_message(&self.info.source.chat, original_message_id, new_message)
192            .await
193    }
194
195    /// Delete a message for everyone in the chat.
196    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.bot.revoke_message", level = "debug", skip_all, fields(chat = %self.info.source.chat.observe()), err(Debug)))]
197    pub async fn revoke_message(
198        &self,
199        message_id: impl Into<String>,
200        revoke_type: crate::send::RevokeType,
201    ) -> Result<(), crate::send::SendError> {
202        self.client
203            .revoke_message(&self.info.source.chat, message_id, revoke_type)
204            .await
205    }
206
207    /// React to the incoming message. An empty `emoji` removes a previous
208    /// reaction. The target key (including the group/status participant) is
209    /// taken from [`MessageContext::message_key`].
210    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.bot.react", level = "debug", skip_all, fields(chat = %self.info.source.chat.observe()), err(Debug)))]
211    pub async fn react(
212        &self,
213        emoji: &str,
214    ) -> Result<crate::send::SendResult, crate::send::SendError> {
215        self.client
216            .send_reaction(&self.info.source.chat, self.message_key(), emoji)
217            .await
218    }
219}
220
221type EventHandlerCallback =
222    Arc<dyn Fn(Arc<Event>, Arc<Client>) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
223
224/// The user callback bundled with the set of event kinds it wants. Carrying the
225/// interest here lets the bus skip materializing (and boxing) events the
226/// callback ignores.
227struct RegisteredHandler {
228    callback: EventHandlerCallback,
229    interest: EventInterest,
230}
231
232/// Union of every registered callback's interest, so the bus only materializes
233/// events at least one callback wants.
234fn combined_interest(handlers: &[RegisteredHandler]) -> EventInterest {
235    handlers
236        .iter()
237        .fold(EventInterest::none(), |acc, h| acc.union(h.interest))
238}
239
240/// How a bot's registered callbacks receive events off the core event bus.
241#[derive(Clone, Copy, Debug, Default)]
242#[non_exhaustive]
243pub enum EventDelivery {
244    /// Each event is delivered to each interested callback on its own spawned
245    /// task (default). A slow callback stalls neither the bus nor its siblings,
246    /// but ordering across events is not guaranteed and a persistently slow
247    /// consumer can accumulate unbounded in-flight tasks.
248    #[default]
249    Concurrent,
250    /// Events are delivered to the callbacks strictly in arrival order through a
251    /// single bounded mailbox drained by one task — the ordered `messages.upsert`
252    /// contract used by interoperable clients. Bounds
253    /// memory: when the mailbox is full the event is dropped and counted in
254    /// [`StatsSnapshot::events_dropped`](wacore::stats::StatsSnapshot::events_dropped)
255    /// instead of blocking the receive pipeline or growing without limit.
256    /// Register an inbound durability hook when no drop is acceptable
257    /// (at-least-once via redelivery).
258    Ordered {
259        /// Mailbox capacity — events buffered before drops begin. Clamped to ≥1.
260        capacity: usize,
261    },
262}
263
264/// Bridges the registered closures onto the core event bus per the chosen
265/// [`EventDelivery`] strategy.
266enum Delivery {
267    /// Fan each event out to every interested callback on its own spawned task.
268    Concurrent { handlers: Arc<[RegisteredHandler]> },
269    /// Hand each event to a single ordered drainer via a bounded mailbox.
270    Ordered {
271        tx: async_channel::Sender<Arc<Event>>,
272    },
273}
274
275struct CallbackBusAdapter {
276    // Weak: the bus lives inside `client.core`, so a strong ref here would pin
277    // the client for its whole lifetime. Upgraded per dispatch.
278    client: Weak<Client>,
279    delivery: Delivery,
280    interest: EventInterest,
281}
282
283impl CallbackBusAdapter {
284    fn new(client: Arc<Client>, handlers: Vec<RegisteredHandler>, delivery: EventDelivery) -> Self {
285        let interest = combined_interest(&handlers);
286        let delivery = match delivery {
287            EventDelivery::Concurrent => Delivery::Concurrent {
288                handlers: handlers.into(),
289            },
290            EventDelivery::Ordered { capacity } => {
291                let (tx, rx) = async_channel::bounded::<Arc<Event>>(capacity.max(1));
292                let handlers: Arc<[RegisteredHandler]> = handlers.into();
293                // Single drainer preserves arrival order; within an event the
294                // callbacks run in registration order. Weak so a dropped client
295                // exits the loop.
296                let drain_client = Arc::downgrade(&client);
297                let drain_handlers = Arc::clone(&handlers);
298                client
299                    .runtime
300                    .spawn(Box::pin(async move {
301                        while let Ok(event) = rx.recv().await {
302                            let Some(client) = drain_client.upgrade() else {
303                                break;
304                            };
305                            let kind = event.kind();
306                            for handler in drain_handlers.iter() {
307                                if handler.interest.wants(kind) {
308                                    // Keep the lone drainer alive across a faulty
309                                    // callback. catch_unwind guards only poll, so
310                                    // build the future inside the awaited block
311                                    // too — a panic while creating it is caught as
312                                    // well, not just one while polling.
313                                    let cb = handler.callback.clone();
314                                    let ev = Arc::clone(&event);
315                                    let cl = client.clone();
316                                    let ran =
317                                        std::panic::AssertUnwindSafe(
318                                            async move { cb(ev, cl).await },
319                                        )
320                                        .catch_unwind()
321                                        .await;
322                                    if ran.is_err() {
323                                        warn!(
324                                            "ordered event delivery callback panicked; continuing"
325                                        );
326                                    }
327                                }
328                            }
329                        }
330                    }))
331                    .detach();
332                Delivery::Ordered { tx }
333            }
334        };
335        Self {
336            client: Arc::downgrade(&client),
337            delivery,
338            interest,
339        }
340    }
341}
342
343impl EventHandler for CallbackBusAdapter {
344    fn handle_event(&self, event: Arc<Event>) {
345        match &self.delivery {
346            Delivery::Concurrent { handlers } => {
347                let Some(client) = self.client.upgrade() else {
348                    return;
349                };
350                let kind = event.kind();
351                for handler in handlers.iter() {
352                    if !handler.interest.wants(kind) {
353                        continue;
354                    }
355                    let callback = handler.callback.clone();
356                    let cb_client = client.clone();
357                    let event = Arc::clone(&event);
358                    client.runtime.spawn_detached(Box::pin(async move {
359                        callback(event, cb_client).await;
360                    }));
361                }
362            }
363            // Non-blocking on purpose: dropping on a full mailbox keeps a slow
364            // consumer from ever backpressuring the receive pipeline. Only a
365            // full mailbox is a capacity drop; a closed channel means the drainer
366            // is gone (teardown/panic) and must not be masked as one.
367            Delivery::Ordered { tx } => match tx.try_send(event) {
368                Ok(()) => {}
369                Err(async_channel::TrySendError::Full(_)) => {
370                    if let Some(client) = self.client.upgrade() {
371                        client.stats.record_event_dropped();
372                    }
373                }
374                Err(async_channel::TrySendError::Closed(_)) => {
375                    log::debug!("ordered event delivery channel closed; dropping event");
376                }
377            },
378        }
379    }
380
381    fn interest(&self) -> EventInterest {
382        self.interest
383    }
384}
385
386/// Handle to a bot started in the background via [`Bot::spawn`]. Awaiting it
387/// resolves once the run loop exits (logout, [`BotHandle::shutdown`], or abort).
388///
389/// Dropping the handle aborts the bot task. Keep it alive for as long as the
390/// bot should run, and prefer [`BotHandle::shutdown`] to stop it.
391#[must_use = "dropping the handle aborts the bot; bind it and await it, or call .shutdown()"]
392pub struct BotHandle {
393    client: Arc<Client>,
394    done_rx: futures::channel::oneshot::Receiver<()>,
395    abort_handle: wacore::runtime::AbortHandle,
396}
397
398impl BotHandle {
399    pub fn client(&self) -> Arc<Client> {
400        self.client.clone()
401    }
402
403    /// Gracefully stop the bot: disconnects (flushing the device snapshot,
404    /// buffered receipts and message secrets) and waits for the run loop to
405    /// exit.
406    pub async fn shutdown(mut self) {
407        self.client.disconnect().await;
408        let _ = (&mut self.done_rx).await;
409    }
410
411    /// Abort the bot task immediately. Skips the flush work
412    /// [`BotHandle::shutdown`] performs, so recently captured state may be
413    /// lost; escape hatch only.
414    pub fn abort(&self) {
415        self.abort_handle.abort();
416    }
417}
418
419impl std::future::Future for BotHandle {
420    type Output = ();
421
422    fn poll(
423        mut self: Pin<&mut Self>,
424        cx: &mut std::task::Context<'_>,
425    ) -> std::task::Poll<Self::Output> {
426        // Canceled only happens when the run task was aborted; both outcomes
427        // mean "the bot is no longer running", which is all awaiters care about.
428        Pin::new(&mut self.done_rx).poll(cx).map(|_| ())
429    }
430}
431
432/// `Bot::run` polls the client's main run loop on the caller's task, so the
433/// instrumented runtime never sees that future — without this, the most
434/// CPU-relevant work of a session would be missing from the hook on the
435/// common `bot.run().await` launch path. `Bot::spawn` needs no equivalent:
436/// it routes the same loop through `Runtime::spawn`.
437async fn run_metered<F: std::future::Future<Output = ()>>(
438    fut: F,
439    instrument: Option<Arc<dyn wacore::stats::TaskInstrument>>,
440) {
441    match instrument {
442        Some(i) => wacore::stats::MeteredFuture::new(Box::pin(fut), i).await,
443        None => fut.await,
444    }
445}
446
447/// A configured WhatsApp session with its event handlers already wired,
448/// ready to be started.
449///
450/// This is the high-level entry point and what most applications should use.
451/// Build one with [`Bot::builder`]: the typestate [`BotBuilder`] takes the
452/// storage backend (the only required dependency with the default cargo
453/// features), the pairing callbacks, and the message/event handlers, then
454/// hands back a `Bot`.
455///
456/// Starting it is a single call. [`Bot::run`] drives the session on the
457/// current task until logout or shutdown; [`Bot::spawn`] starts it on the
458/// runtime instead and returns a [`BotHandle`] you can await, shut down
459/// gracefully, or abort. Both consume the `Bot`, so handlers are registered
460/// at build time, not afterwards.
461///
462/// ```no_run
463/// # use whatsapp_rust::prelude::*;
464/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
465/// let bot = Bot::builder()
466///     .with_backend(SqliteStore::new("whatsapp.db").await?)
467///     .on_message(|ctx| async move {
468///         let _ = ctx.reply("pong").await;
469///     })
470///     .build()
471///     .await?;
472///
473/// bot.run().await;
474/// # Ok(())
475/// # }
476/// ```
477///
478/// Handlers registered through the builder (`on_message`, `on_event`, …)
479/// receive typed [`Event`] payloads. Anything the
480/// builder does not expose is reachable on the underlying client via
481/// [`Bot::client`], which stays valid after the bot is started.
482pub struct Bot {
483    client: Arc<Client>,
484    sync_task_receiver: Option<async_channel::Receiver<crate::sync_task::MajorSyncTask>>,
485    event_handlers: Vec<RegisteredHandler>,
486    event_delivery: EventDelivery,
487    raw_handlers: Vec<Arc<dyn EventHandler>>,
488    pair_code_options: Option<PairCodeOptions>,
489    /// Kept alongside the instrumented runtime: `Bot::run` polls the main run
490    /// loop on the caller's task, so it must meter that future itself —
491    /// `Runtime::spawn` never sees it (`Bot::spawn` does go through it).
492    task_instrument: Option<Arc<dyn wacore::stats::TaskInstrument>>,
493}
494
495impl std::fmt::Debug for Bot {
496    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
497        f.debug_struct("Bot")
498            .field("client", &"<Client>")
499            .field("sync_task_receiver", &self.sync_task_receiver.is_some())
500            .field("event_handlers", &self.event_handlers.len())
501            .field("event_delivery", &self.event_delivery)
502            .field("raw_handlers", &self.raw_handlers.len())
503            .field("pair_code_options", &self.pair_code_options.is_some())
504            .field("task_instrument", &self.task_instrument.is_some())
505            .finish()
506    }
507}
508
509impl Bot {
510    pub fn builder()
511    -> BotBuilder<MissingBackend, DefaultTransportState, DefaultHttpState, DefaultRuntimeState>
512    {
513        BotBuilder::new()
514    }
515
516    pub fn client(&self) -> Arc<Client> {
517        self.client.clone()
518    }
519
520    /// Run the bot on the current task until it shuts down (logout, or
521    /// [`Client::disconnect`] called on [`Bot::client`] from another task).
522    ///
523    /// To run in the background instead, use [`Bot::spawn`].
524    ///
525    /// Coroutines are LocalCopy across crates: a consumer crate that awaits
526    /// this future re-codegens the whole state machine graph behind it in its
527    /// own binary. The boxed barrier below type-erases the graph in a plain
528    /// (linker-shared) function, so callers poll through a vtable and the
529    /// graph is compiled once, here. One allocation per process.
530    pub async fn run(self) {
531        self.run_boxed().await
532    }
533
534    #[inline(never)]
535    fn run_boxed(self) -> wacore::runtime::BoxFuture<'static, ()> {
536        Box::pin(self.run_graph())
537    }
538
539    #[cfg_attr(
540        feature = "tracing",
541        tracing::instrument(name = "wa.bot.run", level = "debug", skip_all)
542    )]
543    async fn run_graph(self) {
544        let instrument = self.task_instrument.clone();
545        let client = self.start_background();
546        run_metered(client.run(), instrument).await;
547    }
548
549    /// Start the bot on its runtime and return a [`BotHandle`] to await,
550    /// gracefully shut down, or abort it.
551    pub fn spawn(self) -> BotHandle {
552        let client = self.start_background();
553
554        let run_client = client.clone();
555        let (done_tx, done_rx) = futures::channel::oneshot::channel::<()>();
556        let abort_handle = client.runtime.spawn(Box::pin(async move {
557            run_client.run().await;
558            let _ = done_tx.send(());
559        }));
560
561        BotHandle {
562            client,
563            done_rx,
564            abort_handle,
565        }
566    }
567
568    /// Wires the background workers and event handlers, returning the client
569    /// that drives the connection. Shared by [`Bot::run`] and [`Bot::spawn`].
570    fn start_background(self) -> Arc<Client> {
571        let Bot {
572            client,
573            sync_task_receiver,
574            event_handlers,
575            event_delivery,
576            raw_handlers,
577            pair_code_options,
578            task_instrument: _,
579        } = self;
580
581        if let Some(receiver) = sync_task_receiver {
582            client.start_sync_task_worker(receiver);
583        }
584
585        if !event_handlers.is_empty() {
586            client
587                .core
588                .event_bus
589                .subscribe_handler(Arc::new(CallbackBusAdapter::new(
590                    client.clone(),
591                    event_handlers,
592                    event_delivery,
593                )))
594                .detach();
595        }
596        for handler in raw_handlers {
597            client.core.event_bus.subscribe_handler(handler).detach();
598        }
599
600        // If pair code options are set, spawn a task to request pair code after socket is ready
601        if let Some(options) = pair_code_options {
602            let client_for_pair = client.clone();
603            client.runtime.spawn(Box::pin(async move {
604                // Wait for socket to be ready (before login) with 30 second timeout
605                if let Err(e) = client_for_pair
606                    .wait_for_socket(std::time::Duration::from_secs(30))
607                    .await
608                {
609                    warn!(target: "Bot/PairCode", "Timeout waiting for socket: {}", e);
610                    // The request never happens, so `pair_with_code` never runs
611                    // and never dispatches for it. Reported here instead: to a
612                    // consumer waiting on a code this is the same fact as any
613                    // other failure — none is coming — and leaving this one path
614                    // silent would put the indefinite wait back.
615                    client_for_pair.core.event_bus.dispatch(Event::PairingCodeError(
616                        crate::types::events::PairingCodeError::builder()
617                            .error(e.to_string())
618                            .build(),
619                    ));
620                    return;
621                }
622
623                // Check if already logged in (paired via QR or existing session)
624                if client_for_pair.is_logged_in() {
625                    info!(target: "Bot/PairCode", "Already logged in, skipping pair code request");
626                    return;
627                }
628
629                // Request pair code
630                match client_for_pair.pair_with_code(options).await {
631                    Ok(code) => {
632                        info!(target: "Bot/PairCode", "Pair code generated: {}", code);
633                    }
634                    Err(e) => {
635                        // Only logged here: `pair_with_code` already dispatched
636                        // `Event::PairingCodeError`, which is what a consumer
637                        // observes — this task is detached, so returning the
638                        // error is not an option.
639                        warn!(target: "Bot/PairCode", "Failed to request pair code: {}", e);
640                    }
641                }
642            })).detach();
643        }
644
645        client
646    }
647}
648
649/// Builder for [`Bot`] using the typestate pattern.
650///
651/// The four type parameters track whether the required fields (backend,
652/// transport factory, HTTP client, runtime) have been provided: `build()` is
653/// only available once all four are [`Provided`], turning missing-field errors
654/// into compile-time errors. With the default cargo features, transport, HTTP
655/// client and runtime start [`Provided`] (Tokio WebSocket, ureq, Tokio), so
656/// only the backend is required.
657#[must_use = "call .build() to produce the Bot; the builder does nothing on its own"]
658pub struct BotBuilder<
659    B = MissingBackend,
660    T = MissingTransport,
661    H = MissingHttpClient,
662    R = MissingRuntime,
663> {
664    // Required fields (guaranteed present when B/T/H/R = Provided)
665    backend: Option<Arc<dyn Backend>>,
666    transport_factory: Option<Arc<dyn crate::transport::TransportFactory>>,
667    http_client: Option<Arc<dyn crate::http::HttpClient>>,
668    runtime: Option<Arc<dyn Runtime>>,
669    // Optional fields
670    event_handlers: Vec<RegisteredHandler>,
671    event_delivery: EventDelivery,
672    raw_handlers: Vec<Arc<dyn EventHandler>>,
673    custom_enc_handlers: HashMap<String, Arc<dyn EncHandler>>,
674    inbound_durability_hook: Option<Arc<dyn InboundDurabilityHook>>,
675    override_version: Option<(u32, u32, u32)>,
676    device_props_override: Option<DevicePropsOverride>,
677    pair_code_options: Option<PairCodeOptions>,
678    skip_history_sync: bool,
679    initial_push_name: Option<String>,
680    cache_config: CacheConfig,
681    wanted_pre_key_count: Option<usize>,
682    resend_rate_limit: Option<(u32, u32)>,
683    task_instrument: Option<Arc<dyn wacore::stats::TaskInstrument>>,
684    alloc_meter: Option<Arc<wacore::stats::AllocMeter>>,
685    #[cfg(feature = "plugins")]
686    plugins: Vec<PluginRegistration>,
687    #[cfg(feature = "plugins")]
688    plugin_host_config: PluginHostConfig,
689    _marker: PhantomData<(B, T, H, R)>,
690}
691
692impl BotBuilder<MissingBackend, DefaultTransportState, DefaultHttpState, DefaultRuntimeState> {
693    fn new() -> Self {
694        Self {
695            backend: None,
696            transport_factory: default_transport_factory(),
697            http_client: default_http_client(),
698            runtime: default_runtime(),
699            event_handlers: Vec::new(),
700            event_delivery: EventDelivery::default(),
701            raw_handlers: Vec::new(),
702            custom_enc_handlers: HashMap::new(),
703            inbound_durability_hook: None,
704            override_version: None,
705            device_props_override: None,
706            pair_code_options: None,
707            skip_history_sync: false,
708            initial_push_name: None,
709            cache_config: CacheConfig::default(),
710            wanted_pre_key_count: None,
711            resend_rate_limit: None,
712            task_instrument: None,
713            alloc_meter: None,
714            #[cfg(feature = "plugins")]
715            plugins: Vec::new(),
716            #[cfg(feature = "plugins")]
717            plugin_host_config: PluginHostConfig::default(),
718            _marker: PhantomData,
719        }
720    }
721}
722
723impl<B, T, H, R> BotBuilder<B, T, H, R> {
724    /// Re-tags the typestate without touching any field, so each required-field
725    /// setter states only its own transition and the field list lives here.
726    fn cast<B2, T2, H2, R2>(self) -> BotBuilder<B2, T2, H2, R2> {
727        BotBuilder {
728            backend: self.backend,
729            transport_factory: self.transport_factory,
730            http_client: self.http_client,
731            runtime: self.runtime,
732            event_handlers: self.event_handlers,
733            event_delivery: self.event_delivery,
734            raw_handlers: self.raw_handlers,
735            custom_enc_handlers: self.custom_enc_handlers,
736            inbound_durability_hook: self.inbound_durability_hook,
737            override_version: self.override_version,
738            device_props_override: self.device_props_override,
739            pair_code_options: self.pair_code_options,
740            skip_history_sync: self.skip_history_sync,
741            initial_push_name: self.initial_push_name,
742            cache_config: self.cache_config,
743            wanted_pre_key_count: self.wanted_pre_key_count,
744            resend_rate_limit: self.resend_rate_limit,
745            task_instrument: self.task_instrument,
746            alloc_meter: self.alloc_meter,
747            #[cfg(feature = "plugins")]
748            plugins: self.plugins,
749            #[cfg(feature = "plugins")]
750            plugin_host_config: self.plugin_host_config,
751            _marker: PhantomData,
752        }
753    }
754
755    // ── Required-field setters (each transitions one type parameter) ──────
756
757    /// Use a backend implementation for storage. This is the only required
758    /// field when the default transport/HTTP/runtime features are enabled.
759    ///
760    /// The backend is wrapped in an `Arc` internally; use
761    /// [`BotBuilder::with_backend_arc`] to pass an already-shared backend.
762    ///
763    /// # Example
764    /// ```rust,ignore
765    /// let bot = Bot::builder()
766    ///     .with_backend(SqliteStore::new("whatsapp.db").await?)
767    ///     .build()
768    ///     .await?;
769    /// ```
770    pub fn with_backend(self, backend: impl Backend + 'static) -> BotBuilder<Provided, T, H, R> {
771        self.with_backend_arc(Arc::new(backend))
772    }
773
774    /// [`BotBuilder::with_backend`] for an already-shared `Arc<dyn Backend>`.
775    pub fn with_backend_arc(mut self, backend: Arc<dyn Backend>) -> BotBuilder<Provided, T, H, R> {
776        self.backend = Some(backend);
777        self.cast()
778    }
779
780    /// Set the transport factory for creating network connections, replacing
781    /// the `tokio-transport` default when that feature is enabled.
782    pub fn with_transport_factory<F>(mut self, factory: F) -> BotBuilder<B, Provided, H, R>
783    where
784        F: crate::transport::TransportFactory + 'static,
785    {
786        self.transport_factory = Some(Arc::new(factory));
787        self.cast()
788    }
789
790    /// Set the HTTP client used for media operations and version fetching,
791    /// replacing the `ureq-client` default when that feature is enabled.
792    pub fn with_http_client<C>(mut self, client: C) -> BotBuilder<B, T, Provided, R>
793    where
794        C: crate::http::HttpClient + 'static,
795    {
796        self.http_client = Some(Arc::new(client));
797        self.cast()
798    }
799
800    /// Set the async runtime implementation, replacing the `tokio-runtime`
801    /// default when that feature is enabled.
802    pub fn with_runtime<Rt: Runtime>(mut self, runtime: Rt) -> BotBuilder<B, T, H, Provided> {
803        self.runtime = Some(Arc::new(runtime));
804        self.cast()
805    }
806
807    /// Instrument the client's internal tasks with a
808    /// [`TaskInstrument`](wacore::stats::TaskInstrument) hook, called around
809    /// each poll (and around blocking work).
810    ///
811    /// Runtime-agnostic: the hook wraps whatever runtime the client uses, so
812    /// every task spawned through the `Runtime` trait is covered, and
813    /// [`Bot::run`] meters the main run loop itself — the read loop reports
814    /// on either launch path (`voip` media tasks spawn directly on Tokio and
815    /// are not covered). Pass a
816    /// [`CpuMeter`](wacore::stats::CpuMeter) for per-session CPU accounting
817    /// (keep a clone to read snapshots), or a custom hook to scope
818    /// allocator-attribution or platform samplers to this client's work.
819    /// Default: no hook — the runtime is used untouched.
820    ///
821    /// Occupies the same single-instrument slot as
822    /// [`with_alloc_meter`](Self::with_alloc_meter) (last setter wins): calling
823    /// this after `with_alloc_meter` drops the typed alloc-meter handle, so
824    /// [`Client::resource_report`](crate::Client::resource_report)'s `alloc`
825    /// field reverts to `None`.
826    ///
827    /// # Example
828    /// ```rust,ignore
829    /// use std::sync::Arc;
830    /// use wacore::stats::CpuMeter;
831    ///
832    /// let cpu = Arc::new(CpuMeter::new());
833    /// let bot = Bot::builder()
834    ///     .with_backend(backend)
835    ///     .with_task_instrument(cpu.clone())
836    ///     .build()
837    ///     .await?;
838    /// // later: cpu.snapshot().busy
839    /// ```
840    pub fn with_task_instrument(
841        mut self,
842        instrument: Arc<dyn wacore::stats::TaskInstrument>,
843    ) -> Self {
844        self.task_instrument = Some(instrument);
845        // Clear any alloc-meter handle: only the last instrument set is driven by
846        // the poll hooks, so a stale handle would make resource_report() report a
847        // never-updated all-zero snapshot instead of `None`.
848        self.alloc_meter = None;
849        self
850    }
851
852    /// Install an [`AllocMeter`](wacore::stats::AllocMeter) as this client's
853    /// task instrument and keep a typed handle so [`Client::resource_report`]
854    /// folds in its allocation-churn snapshot.
855    ///
856    /// Sugar over [`with_task_instrument`](Self::with_task_instrument): it
857    /// occupies the single instrument slot (so it's mutually exclusive with a
858    /// `CpuMeter` or another hook — last setter wins). The host still installs a
859    /// `#[global_allocator]` that calls [`AllocMeter::on_alloc`] /
860    /// [`AllocMeter::on_dealloc`]; see `examples/alloc_tracking.rs`.
861    ///
862    /// [`AllocMeter::on_alloc`]: wacore::stats::AllocMeter::on_alloc
863    /// [`AllocMeter::on_dealloc`]: wacore::stats::AllocMeter::on_dealloc
864    pub fn with_alloc_meter(mut self, meter: Arc<wacore::stats::AllocMeter>) -> Self {
865        self.task_instrument = Some(meter.clone());
866        self.alloc_meter = Some(meter);
867        self
868    }
869
870    /// Register a native plugin without changing the builder's typestate.
871    #[cfg(feature = "plugins")]
872    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
873    pub fn with_plugin<P: ClientPlugin>(mut self, plugin: P) -> Self {
874        self.plugins.push(PluginRegistration::new(plugin));
875        self
876    }
877
878    /// Register an already-shared native plugin without changing its marker type.
879    #[cfg(feature = "plugins")]
880    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
881    pub fn with_plugin_arc<P: ClientPlugin>(mut self, plugin: Arc<P>) -> Self {
882        self.plugins.push(PluginRegistration::new_arc(plugin));
883        self
884    }
885
886    /// Register a manifest-ID-keyed plugin that exposes no Rust typed API.
887    #[cfg(feature = "plugins")]
888    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
889    pub fn with_untyped_plugin<P: UntypedClientPlugin>(mut self, plugin: P) -> Self {
890        self.plugins.push(PluginRegistration::new_untyped(plugin));
891        self
892    }
893
894    /// Register an already-shared manifest-ID-keyed plugin.
895    #[cfg(feature = "plugins")]
896    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
897    pub fn with_untyped_plugin_arc<P: UntypedClientPlugin + ?Sized>(
898        mut self,
899        plugin: Arc<P>,
900    ) -> Self {
901        self.plugins
902            .push(PluginRegistration::new_untyped_arc(plugin));
903        self
904    }
905
906    /// Configure plugin lifecycle and tracked-task deadlines.
907    #[cfg(feature = "plugins")]
908    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
909    pub fn with_plugin_host_config(mut self, config: PluginHostConfig) -> Self {
910        self.plugin_host_config = config;
911        self
912    }
913
914    // ── Event handler registration (additive; order of registration is kept,
915    //    but handlers run on their own tasks, so cross-event ordering is not
916    //    guaranteed) ──────────────────────────────────────────────────────
917
918    /// Register a handler that receives every event kind.
919    pub fn on_event<F, Fut>(self, handler: F) -> Self
920    where
921        F: Fn(Arc<Event>, Arc<Client>) -> Fut + Send + Sync + 'static,
922        Fut: Future<Output = ()> + Send + 'static,
923    {
924        self.register_event_handler(EventInterest::ALL, handler)
925    }
926
927    /// Register a handler that receives only the given event kinds. The bus
928    /// skips materializing (and boxing the handler future for) every other
929    /// kind, so a narrowly-scoped bot does not pay for events it ignores.
930    pub fn on_event_for<F, Fut>(self, kinds: &[EventKind], handler: F) -> Self
931    where
932        F: Fn(Arc<Event>, Arc<Client>) -> Fut + Send + Sync + 'static,
933        Fut: Future<Output = ()> + Send + 'static,
934    {
935        self.register_event_handler(EventInterest::of(kinds), handler)
936    }
937
938    /// Run `handler` for every incoming message, with a ready
939    /// [`MessageContext`] (reply/react/edit helpers included).
940    ///
941    /// [`Event::Messages`] batches (one per commit during an offline drain,
942    /// single-message on live traffic) are fanned out here in arrival order,
943    /// awaiting each handler before the next — per-message bots keep their
944    /// ergonomics and gain in-batch ordering.
945    ///
946    /// The handler is CALLED for every message in the batch up front and the
947    /// returned futures then run in order (an `async` closure runs no body
948    /// code at call time, so for the typical handler this is unobservable).
949    /// Interleaving call+await instead would hold a `MessageContext` across
950    /// an await, which is not `Send` on wasm32.
951    pub fn on_message<F, Fut>(self, handler: F) -> Self
952    where
953        F: Fn(MessageContext) -> Fut + Send + Sync + 'static,
954        Fut: Future<Output = ()> + Send + 'static,
955    {
956        self.on_event_for(&[EventKind::Messages], move |event, client| {
957            // Futures are built before the async block: `MessageContext` is
958            // not `Send` on wasm32 (the Client's trait objects aren't), so it
959            // must never be held across an await — only the handler futures
960            // (which the `Fut: Send` bound covers) may cross one.
961            let futures: Vec<Fut> = event
962                .messages()
963                .map(|m| handler(MessageContext::from_inbound(m, Arc::clone(&client))))
964                .collect();
965            async move {
966                for future in futures {
967                    future.await;
968                }
969            }
970        })
971    }
972
973    /// Run `handler` with the QR payload (and validity window) each time a
974    /// pairing QR code is issued. Render `code` as a QR image for scanning.
975    pub fn on_qr_code<F, Fut>(self, handler: F) -> Self
976    where
977        F: Fn(String, std::time::Duration) -> Fut + Send + Sync + 'static,
978        Fut: Future<Output = ()> + Send + 'static,
979    {
980        self.on_event_for(&[EventKind::PairingQrCode], move |event, _client| {
981            let fut = match &*event {
982                Event::PairingQrCode(qr) => Some(handler(qr.code.clone(), qr.timeout)),
983                _ => None,
984            };
985            async move {
986                if let Some(fut) = fut {
987                    fut.await
988                }
989            }
990        })
991    }
992
993    /// Run `handler` with the 8-character pairing code (and validity window)
994    /// generated by [`BotBuilder::with_pair_code`] linking.
995    pub fn on_pair_code<F, Fut>(self, handler: F) -> Self
996    where
997        F: Fn(String, std::time::Duration) -> Fut + Send + Sync + 'static,
998        Fut: Future<Output = ()> + Send + 'static,
999    {
1000        self.on_event_for(&[EventKind::PairingCode], move |event, _client| {
1001            let fut = match &*event {
1002                Event::PairingCode(pc) => Some(handler(pc.code.clone(), pc.timeout)),
1003                _ => None,
1004            };
1005            async move {
1006                if let Some(fut) = fut {
1007                    fut.await
1008                }
1009            }
1010        })
1011    }
1012
1013    /// Run `handler` when a pair-code request fails, so no code will be issued
1014    /// ([`Event::PairingCodeError`]).
1015    ///
1016    /// The counterpart to [`BotBuilder::on_pair_code`], and the only way to
1017    /// observe the failure of a [`BotBuilder::with_pair_code`] request: that one
1018    /// runs in a detached task, so its `Err` reaches no caller.
1019    ///
1020    /// Branch on `err.rejection` rather than the message.
1021    /// [`PairCodeRejection::is_throttled`](crate::pair_code::PairCodeRejection::is_throttled)
1022    /// is the case to slow down for — re-requesting on the original schedule
1023    /// spends more of the budget the server just refused — and `err.backoff`
1024    /// carries the server's own delay when it named one.
1025    pub fn on_pair_code_error<F, Fut>(self, handler: F) -> Self
1026    where
1027        F: Fn(crate::types::events::PairingCodeError, Arc<Client>) -> Fut + Send + Sync + 'static,
1028        Fut: Future<Output = ()> + Send + 'static,
1029    {
1030        self.on_event_for(&[EventKind::PairingCodeError], move |event, client| {
1031            let fut = match &*event {
1032                Event::PairingCodeError(e) => Some(handler(e.clone(), client)),
1033                _ => None,
1034            };
1035            async move {
1036                if let Some(fut) = fut {
1037                    fut.await
1038                }
1039            }
1040        })
1041    }
1042
1043    /// Run `handler` when the server asks the companion to refresh an
1044    /// in-progress pairing code ([`Event::PairingCodeRefresh`]). The `bool` is
1045    /// `force_manual`. The typical reaction is to request a fresh code via
1046    /// [`Client::pair_with_code`] with the same phone number.
1047    pub fn on_pair_code_refresh<F, Fut>(self, handler: F) -> Self
1048    where
1049        F: Fn(bool, Arc<Client>) -> Fut + Send + Sync + 'static,
1050        Fut: Future<Output = ()> + Send + 'static,
1051    {
1052        self.on_event_for(&[EventKind::PairingCodeRefresh], move |event, client| {
1053            let fut = match &*event {
1054                Event::PairingCodeRefresh(r) => Some(handler(r.force_manual, client)),
1055                _ => None,
1056            };
1057            async move {
1058                if let Some(fut) = fut {
1059                    fut.await
1060                }
1061            }
1062        })
1063    }
1064
1065    /// Run `handler` once the client is connected and authenticated.
1066    pub fn on_connected<F, Fut>(self, handler: F) -> Self
1067    where
1068        F: Fn(Arc<Client>) -> Fut + Send + Sync + 'static,
1069        Fut: Future<Output = ()> + Send + 'static,
1070    {
1071        self.on_event_for(&[EventKind::Connected], move |_event, client| {
1072            handler(client)
1073        })
1074    }
1075
1076    /// Run `handler` when the device is logged out (unlinked from the phone).
1077    pub fn on_logged_out<F, Fut>(self, handler: F) -> Self
1078    where
1079        F: Fn(crate::types::events::LoggedOut) -> Fut + Send + Sync + 'static,
1080        Fut: Future<Output = ()> + Send + 'static,
1081    {
1082        self.on_event_for(&[EventKind::LoggedOut], move |event, _client| {
1083            let fut = match &*event {
1084                Event::LoggedOut(info) => Some(handler(info.clone())),
1085                _ => None,
1086            };
1087            async move {
1088                if let Some(fut) = fut {
1089                    fut.await
1090                }
1091            }
1092        })
1093    }
1094
1095    fn register_event_handler<F, Fut>(mut self, interest: EventInterest, handler: F) -> Self
1096    where
1097        F: Fn(Arc<Event>, Arc<Client>) -> Fut + Send + Sync + 'static,
1098        Fut: Future<Output = ()> + Send + 'static,
1099    {
1100        self.event_handlers.push(RegisteredHandler {
1101            callback: Arc::new(move |event, client| Box::pin(handler(event, client))),
1102            interest,
1103        });
1104        self
1105    }
1106
1107    /// Register a struct-based [`EventHandler`] directly on the event bus.
1108    ///
1109    /// Unlike the closure registrars, the handler keeps its state in `&self`
1110    /// (no per-field clone dance) and `handle_event` runs inline on the
1111    /// dispatch path: spawn your own task for slow work.
1112    pub fn with_event_handler(mut self, handler: impl EventHandler + 'static) -> Self {
1113        self.raw_handlers.push(Arc::new(handler));
1114        self
1115    }
1116
1117    /// Choose how registered callbacks receive events. Defaults to
1118    /// [`EventDelivery::Concurrent`]; use [`EventDelivery::Ordered`] for
1119    /// in-arrival-order, bounded delivery. Only affects the closure-based
1120    /// callbacks, not raw
1121    /// [`with_event_handler`](Self::with_event_handler) handlers, which always
1122    /// run inline on the dispatch path.
1123    pub fn with_event_delivery(mut self, delivery: EventDelivery) -> Self {
1124        self.event_delivery = delivery;
1125        self
1126    }
1127
1128    // ── Optional configuration ─────────────────────────────────────────────
1129
1130    /// Register a custom handler for a specific encrypted message type
1131    ///
1132    /// # Arguments
1133    /// * `enc_type` - The encrypted message type (e.g., "frskmsg")
1134    /// * `handler` - The handler implementation for this type
1135    pub fn with_enc_handler<Eh>(mut self, enc_type: impl Into<String>, handler: Eh) -> Self
1136    where
1137        Eh: EncHandler + 'static,
1138    {
1139        self.custom_enc_handlers
1140            .insert(enc_type.into(), Arc::new(handler));
1141        self
1142    }
1143
1144    /// Register an inbound durability hook for at-least-once delivery.
1145    ///
1146    /// By default the client acks a message as soon as it is decrypted
1147    /// (at-most-once): a crash or failed commit before the consumer persists it
1148    /// loses the message. With a hook registered, the ack is deferred until the
1149    /// hook commits the message; on failure the message is redelivered on the
1150    /// next connect. The hook must be idempotent (dedupe by `(chat, sender, id)`,
1151    /// since stanza ids are only unique within a chat/sender). See
1152    /// [`InboundDurabilityHook`] for the full contract and caveats.
1153    pub fn with_inbound_durability_hook<Dh>(mut self, hook: Dh) -> Self
1154    where
1155        Dh: InboundDurabilityHook + 'static,
1156    {
1157        self.inbound_durability_hook = Some(Arc::new(hook));
1158        self
1159    }
1160
1161    /// Override the WhatsApp version used by the client.
1162    ///
1163    /// By default, the client will automatically fetch the latest version from WhatsApp's servers.
1164    /// Use this method to force a specific version instead.
1165    ///
1166    /// # Arguments
1167    /// * `version` - A tuple of (primary, secondary, tertiary) version numbers
1168    pub fn with_version(mut self, version: (u32, u32, u32)) -> Self {
1169        self.override_version = Some(version);
1170        self
1171    }
1172
1173    /// Override the device properties sent to WhatsApp servers.
1174    /// This allows customizing how your device appears on the linked devices list.
1175    ///
1176    /// `platform_type` controls the display name in Linked Devices; defaults
1177    /// to `Unknown` ("Unknown device"). Only applied on the initial pairing.
1178    ///
1179    /// # Example
1180    /// ```rust,ignore
1181    /// use waproto::whatsapp::device_props::PlatformType;
1182    /// use wacore::store::DevicePropsOverride;
1183    ///
1184    /// Bot::builder()
1185    ///     .with_backend(backend)
1186    ///     .with_device_props(
1187    ///         DevicePropsOverride::new()
1188    ///             .with_os("macOS")
1189    ///             .with_platform_type(PlatformType::CHROME),
1190    ///     );
1191    /// ```
1192    pub fn with_device_props(mut self, override_: DevicePropsOverride) -> Self {
1193        self.device_props_override = Some(override_);
1194        self
1195    }
1196
1197    /// Configure pair code authentication to run automatically after connecting.
1198    ///
1199    /// When set, the pair code request will be sent automatically after establishing
1200    /// a connection, and the pairing code will be dispatched via `Event::PairingCode`
1201    /// (see [`BotBuilder::on_pair_code`]). This runs concurrently with QR code
1202    /// pairing - whichever completes first wins.
1203    ///
1204    /// The request runs in a detached task, so a failure cannot be returned to
1205    /// the caller: it arrives as `Event::PairingCodeError` instead (see
1206    /// [`BotBuilder::on_pair_code_error`]). Subscribe to it if the consumer must
1207    /// distinguish "still waiting for the user" from "no code is coming" — a
1208    /// rate-limited request is otherwise indistinguishable from the former.
1209    ///
1210    /// # Example
1211    /// ```rust,ignore
1212    /// use whatsapp_rust::pair_code::PairCodeOptions;
1213    ///
1214    /// // Platform identity is derived from `DeviceProps` configured via
1215    /// // `Bot::builder().with_device_props(...)`. Explicit overrides below
1216    /// // are optional — omit them to let derivation do the right thing.
1217    /// let bot = Bot::builder()
1218    ///     .with_backend(backend)
1219    ///     .with_pair_code(PairCodeOptions {
1220    ///         phone_number: "15551234567".to_string(),
1221    ///         custom_code: Some("ABCD1234".to_string()),
1222    ///         ..Default::default()
1223    ///     })
1224    ///     .on_pair_code(|code, _timeout| async move {
1225    ///         println!("Enter this code on your phone: {code}");
1226    ///     })
1227    ///     .build()
1228    ///     .await?;
1229    /// ```
1230    pub fn with_pair_code(mut self, options: PairCodeOptions) -> Self {
1231        self.pair_code_options = Some(options);
1232        self
1233    }
1234
1235    /// Skip processing of history sync notifications from the phone.
1236    ///
1237    /// When enabled, the client will acknowledge all incoming history sync
1238    /// notifications (so the phone considers them delivered) but will not
1239    /// download or process any historical data (INITIAL_BOOTSTRAP, RECENT,
1240    /// FULL, PUSH_NAME, etc.). A debug log entry is emitted for each skipped
1241    /// notification. This is useful for bot use cases where message history
1242    /// is not needed.
1243    ///
1244    /// Default: `false` (history sync is processed normally).
1245    pub fn skip_history_sync(mut self) -> Self {
1246        self.skip_history_sync = true;
1247        self
1248    }
1249
1250    /// Set how many one-time pre-keys are generated and uploaded per batch.
1251    ///
1252    /// Defaults to WA Web's UPLOAD_KEYS_COUNT (812). The value is clamped to the
1253    /// protocol-safe range at upload time. Useful for memory-constrained or
1254    /// embedded consumers that want a smaller batch.
1255    pub fn with_wanted_pre_key_count(mut self, count: usize) -> Self {
1256        self.wanted_pre_key_count = Some(count);
1257        self
1258    }
1259
1260    /// Tune the per-chat outbound resend rate limiter.
1261    ///
1262    /// Outbound retry resends to a chat are bounded by a token bucket: `burst`
1263    /// is the instantaneous allowance, `refill_per_min` the sustained ceiling
1264    /// per chat. This caps the aggregate resend rate WhatsApp's anti-abuse
1265    /// penalizes during a PN to LID migration fan-out, while throttled devices
1266    /// still recover via the fresh-SKDM mark. A `burst` of 0 disables it.
1267    ///
1268    /// Defaults are conservative (burst 20, refill 10/min) and apply without
1269    /// calling this. Can also be retuned live via
1270    /// [`Client::set_resend_rate_limit`](crate::Client::set_resend_rate_limit).
1271    pub fn with_resend_rate_limit(mut self, burst: u32, refill_per_min: u32) -> Self {
1272        self.resend_rate_limit = Some((burst, refill_per_min));
1273        self
1274    }
1275
1276    /// Set an initial push name on the device before connecting.
1277    ///
1278    /// This is included in the `ClientPayload` during registration, allowing the
1279    /// mock server to deterministically assign phone numbers based on push name
1280    /// (same push name = same phone, enabling multi-device testing).
1281    pub fn with_push_name(mut self, name: impl Into<String>) -> Self {
1282        self.initial_push_name = Some(name.into());
1283        self
1284    }
1285
1286    /// Configure cache TTL and capacity settings.
1287    ///
1288    /// By default, all caches match WhatsApp Web behavior. Use this method
1289    /// to customize cache durations for your use case.
1290    ///
1291    /// # Example
1292    /// ```rust,ignore
1293    /// use whatsapp_rust::{CacheConfig, CacheEntryConfig};
1294    ///
1295    /// // Disable TTL for group and device caches (good for bots with few groups)
1296    /// let bot = Bot::builder()
1297    ///     .with_backend(backend)
1298    ///     .with_cache_config(CacheConfig {
1299    ///         group_cache: CacheEntryConfig::new(None, 1_000),
1300    ///         device_registry_cache: CacheEntryConfig::new(None, 5_000),
1301    ///         ..Default::default()
1302    ///     })
1303    ///     .build()
1304    ///     .await?;
1305    /// ```
1306    pub fn with_cache_config(mut self, config: CacheConfig) -> Self {
1307        self.cache_config = config;
1308        self
1309    }
1310}
1311
1312// ── build() — only available when all 4 required fields are Provided ─────
1313
1314impl BotBuilder<Provided, Provided, Provided, Provided> {
1315    /// Boxed barrier: see [`Bot::run`]. Building the client wires every cache
1316    /// and background loop, so an unboxed await here would duplicate that
1317    /// whole construction graph into the consumer crate.
1318    pub async fn build(self) -> Result<Bot, BotBuilderError> {
1319        self.build_boxed().await
1320    }
1321
1322    #[inline(never)]
1323    fn build_boxed(self) -> wacore::runtime::BoxFuture<'static, Result<Bot, BotBuilderError>> {
1324        Box::pin(self.build_graph())
1325    }
1326
1327    #[cfg_attr(
1328        feature = "tracing",
1329        tracing::instrument(name = "wa.bot.build", level = "debug", skip_all, err(Debug))
1330    )]
1331    async fn build_graph(self) -> Result<Bot, BotBuilderError> {
1332        // Destructure to extract required fields — typestate guarantees all are Some.
1333        let (Some(runtime), Some(backend), Some(transport_factory), Some(http_client)) = (
1334            self.runtime,
1335            self.backend,
1336            self.transport_factory,
1337            self.http_client,
1338        ) else {
1339            unreachable!("typestate guarantees all required fields are Provided")
1340        };
1341
1342        let task_instrument = self.task_instrument;
1343        let alloc_meter = self.alloc_meter;
1344
1345        // Note: For multi-account mode, create the backend with SqliteStore::new_for_device()
1346        // before passing it to with_backend_arc()
1347        let persistence_manager = Arc::new(PersistenceManager::new(backend).await?);
1348
1349        // Apply initial push name if specified (for deterministic mock server phone assignment)
1350        if let Some(name) = self.initial_push_name {
1351            persistence_manager
1352                .process_command(DeviceCommand::SetPushName(name))
1353                .await;
1354        }
1355
1356        if let Some(override_) = self.device_props_override
1357            && !override_.is_empty()
1358        {
1359            // Field-by-field to avoid Debug-formatting waproto types (keeps their
1360            // generated Debug impls out of the binary).
1361            info!(
1362                "Applying device props override: os={:?} version={:?} platform_type={:?} require_full_sync={:?} history_sync_config={}",
1363                override_.os.as_deref(),
1364                override_.version.as_ref().map(|v| {
1365                    format!(
1366                        "{}.{}.{}",
1367                        v.primary.unwrap_or(0),
1368                        v.secondary.unwrap_or(0),
1369                        v.tertiary.unwrap_or(0)
1370                    )
1371                }),
1372                override_.platform_type.map(|p| p as i32),
1373                override_.require_full_sync,
1374                if override_.history_sync_config.is_some() {
1375                    "overridden"
1376                } else {
1377                    "default"
1378                },
1379            );
1380            persistence_manager
1381                .process_command(DeviceCommand::SetDeviceProps(override_))
1382                .await;
1383        }
1384
1385        info!("Creating client...");
1386        let client_builder = Client::builder()
1387            .with_runtime_arc(runtime)
1388            .with_persistence_manager(persistence_manager)
1389            .with_transport_factory_arc(transport_factory)
1390            .with_http_client_arc(http_client)
1391            .with_cache_config(self.cache_config)
1392            .with_custom_enc_handlers(self.custom_enc_handlers)
1393            .with_skip_history_sync(self.skip_history_sync)
1394            .with_background_saver_interval(std::time::Duration::from_secs(30));
1395        #[cfg(feature = "plugins")]
1396        let client_builder = client_builder
1397            .with_plugin_registrations(self.plugins)
1398            .with_plugin_host_config(self.plugin_host_config);
1399        let mut client_builder = client_builder;
1400
1401        if let Some(version) = self.override_version {
1402            client_builder = client_builder.with_version_override(version);
1403        }
1404        if let Some(hook) = self.inbound_durability_hook {
1405            client_builder = client_builder.with_inbound_durability_hook_arc(hook);
1406        }
1407        if let Some(count) = self.wanted_pre_key_count {
1408            client_builder = client_builder.with_wanted_pre_key_count(count);
1409        }
1410        if let Some((burst, refill_per_min)) = self.resend_rate_limit {
1411            client_builder = client_builder.with_resend_rate_limit(burst, refill_per_min);
1412        }
1413        client_builder = match alloc_meter {
1414            Some(meter) => client_builder.with_alloc_meter(meter),
1415            None => match task_instrument.clone() {
1416                Some(instrument) => client_builder.with_task_instrument(instrument),
1417                None => client_builder,
1418            },
1419        };
1420
1421        let (client, sync_task_receiver) = client_builder.build().await?.into_parts();
1422
1423        Ok(Bot {
1424            client,
1425            sync_task_receiver: Some(sync_task_receiver),
1426            event_handlers: self.event_handlers,
1427            event_delivery: self.event_delivery,
1428            raw_handlers: self.raw_handlers,
1429            pair_code_options: self.pair_code_options,
1430            task_instrument,
1431        })
1432    }
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437    use super::*;
1438    use crate::TokioRuntime;
1439    use crate::http::{HttpClient, HttpRequest, HttpResponse};
1440    use crate::store::SqliteStore;
1441    use anyhow::Result;
1442    use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory;
1443
1444    // Mock HTTP client for testing
1445    #[derive(Debug, Clone)]
1446    struct MockHttpClient;
1447
1448    #[async_trait::async_trait]
1449    impl HttpClient for MockHttpClient {
1450        async fn execute(&self, _request: HttpRequest) -> Result<HttpResponse> {
1451            // Return a mock response for version fetching
1452            Ok(HttpResponse {
1453                status_code: 200,
1454                body: br#"self.__swData=JSON.parse(/*BTDS*/"{\"dynamic_data\":{\"SiteData\":{\"server_revision\":1026131876,\"client_revision\":1026131876}}}");"#.to_vec(),
1455            })
1456        }
1457    }
1458
1459    async fn create_test_sqlite_backend() -> Arc<dyn Backend> {
1460        let temp_db = format!(
1461            "file:memdb_bot_{}?mode=memory&cache=shared",
1462            uuid::Uuid::new_v4()
1463        );
1464        Arc::new(
1465            SqliteStore::new(&temp_db)
1466                .await
1467                .expect("Failed to create test SqliteStore"),
1468        ) as Arc<dyn Backend>
1469    }
1470
1471    async fn create_test_sqlite_backend_for_device(device_id: i32) -> Arc<dyn Backend> {
1472        let temp_db = format!(
1473            "file:memdb_bot_{}?mode=memory&cache=shared",
1474            uuid::Uuid::new_v4()
1475        );
1476        Arc::new(
1477            SqliteStore::new_for_device(&temp_db, device_id)
1478                .await
1479                .expect("Failed to create test SqliteStore"),
1480        ) as Arc<dyn Backend>
1481    }
1482
1483    async fn test_client() -> Arc<Client> {
1484        Bot::builder()
1485            .with_backend_arc(create_test_sqlite_backend().await)
1486            .with_transport_factory(TokioWebSocketTransportFactory::new())
1487            .with_http_client(MockHttpClient)
1488            .with_runtime(TokioRuntime)
1489            .build()
1490            .await
1491            .expect("build")
1492            .client()
1493    }
1494
1495    #[cfg(feature = "plugins")]
1496    struct BotBuilderPlugin;
1497
1498    #[cfg(feature = "plugins")]
1499    impl ClientPlugin for BotBuilderPlugin {
1500        type Api = &'static str;
1501
1502        fn manifest(&self) -> crate::plugins::PluginManifest {
1503            crate::plugins::PluginManifest::new("bot-builder-test", "0.1.0")
1504        }
1505
1506        fn install(
1507            &self,
1508            _context: crate::plugins::PluginContext,
1509        ) -> wacore::runtime::BoxFuture<'_, Result<Arc<Self::Api>>> {
1510            Box::pin(async { Ok(Arc::new("installed")) })
1511        }
1512    }
1513
1514    #[cfg(feature = "plugins")]
1515    #[tokio::test]
1516    async fn typestate_builder_preserves_registered_plugins() {
1517        let bot = Bot::builder()
1518            .with_plugin(BotBuilderPlugin)
1519            .with_backend_arc(create_test_sqlite_backend().await)
1520            .with_transport_factory(TokioWebSocketTransportFactory::new())
1521            .with_http_client(MockHttpClient)
1522            .with_runtime(TokioRuntime)
1523            .build()
1524            .await
1525            .expect("bot plugin build");
1526        assert_eq!(
1527            bot.client().plugin::<BotBuilderPlugin>().as_deref(),
1528            Some(&"installed")
1529        );
1530        bot.client().disconnect().await;
1531    }
1532
1533    fn pairing_code_event(code: &str) -> Arc<Event> {
1534        Arc::new(Event::PairingCode(
1535            crate::types::events::PairingCode::builder()
1536                .code(code.to_string())
1537                .timeout(std::time::Duration::ZERO)
1538                .build(),
1539        ))
1540    }
1541
1542    /// `EventDelivery::Ordered` delivers events to a callback in arrival order —
1543    /// the ordered consumer contract the concurrent default can't promise.
1544    #[tokio::test]
1545    async fn ordered_delivery_preserves_arrival_order() {
1546        let client = test_client().await;
1547        let (order_tx, order_rx) = async_channel::unbounded::<String>();
1548        let handler = RegisteredHandler {
1549            callback: Arc::new(move |event, _client| {
1550                let order_tx = order_tx.clone();
1551                Box::pin(async move {
1552                    if let Event::PairingCode(pc) = &*event {
1553                        let _ = order_tx.send(pc.code.clone()).await;
1554                    }
1555                })
1556            }),
1557            interest: EventInterest::ALL,
1558        };
1559        let adapter = CallbackBusAdapter::new(
1560            client.clone(),
1561            vec![handler],
1562            EventDelivery::Ordered { capacity: 64 },
1563        );
1564
1565        let n = 20;
1566        for i in 0..n {
1567            adapter.handle_event(pairing_code_event(&i.to_string()));
1568        }
1569
1570        let mut seen = Vec::with_capacity(n);
1571        for _ in 0..n {
1572            seen.push(
1573                tokio::time::timeout(std::time::Duration::from_secs(5), order_rx.recv())
1574                    .await
1575                    .expect("timed out waiting for ordered callback")
1576                    .expect("callback ran"),
1577            );
1578        }
1579        let expected: Vec<String> = (0..n).map(|i| i.to_string()).collect();
1580        assert_eq!(
1581            seen, expected,
1582            "ordered delivery must preserve arrival order"
1583        );
1584    }
1585
1586    /// A full bounded mailbox drops events (counted in `events_dropped`) instead
1587    /// of blocking the dispatch path or growing without bound.
1588    #[tokio::test]
1589    async fn ordered_delivery_drops_and_counts_when_full() {
1590        let client = test_client().await;
1591        let (started_tx, started_rx) = async_channel::bounded::<()>(1);
1592        let (release_tx, release_rx) = async_channel::bounded::<()>(1);
1593        let handler = RegisteredHandler {
1594            callback: Arc::new(move |event, _client| {
1595                let started_tx = started_tx.clone();
1596                let release_rx = release_rx.clone();
1597                Box::pin(async move {
1598                    // Only the first event parks the single drainer, so the
1599                    // capacity-1 mailbox is deterministically full for the rest.
1600                    if let Event::PairingCode(pc) = &*event
1601                        && pc.code == "0"
1602                    {
1603                        let _ = started_tx.send(()).await;
1604                        let _ = release_rx.recv().await;
1605                    }
1606                })
1607            }),
1608            interest: EventInterest::ALL,
1609        };
1610        let adapter = CallbackBusAdapter::new(
1611            client.clone(),
1612            vec![handler],
1613            EventDelivery::Ordered { capacity: 1 },
1614        );
1615
1616        // #0 is pulled by the drainer, which then parks on `release`; the mailbox
1617        // is now empty and the drainer won't take another until released.
1618        adapter.handle_event(pairing_code_event("0"));
1619        tokio::time::timeout(std::time::Duration::from_secs(5), started_rx.recv())
1620            .await
1621            .expect("timed out waiting for drainer to start")
1622            .expect("drainer entered callback");
1623
1624        adapter.handle_event(pairing_code_event("1")); // fills capacity-1 mailbox
1625        adapter.handle_event(pairing_code_event("2")); // dropped
1626        adapter.handle_event(pairing_code_event("3")); // dropped
1627
1628        assert_eq!(
1629            client.stats.events_dropped(),
1630            2,
1631            "events past a full mailbox must be dropped and counted"
1632        );
1633
1634        let _ = release_tx.send(()).await; // unpark so the drainer can exit cleanly
1635    }
1636
1637    /// A panicking callback must not kill the single ordered drainer — later
1638    /// events still get delivered. The panic fires while *creating* the future
1639    /// (before the async block), the case a poll-only guard would miss.
1640    #[tokio::test]
1641    async fn ordered_delivery_survives_a_panicking_callback() {
1642        let client = test_client().await;
1643        let (tx, rx) = async_channel::unbounded::<String>();
1644        let handler = RegisteredHandler {
1645            callback: Arc::new(move |event: Arc<Event>, _client| {
1646                let tx = tx.clone();
1647                if let Event::PairingCode(pc) = &*event {
1648                    assert_ne!(&pc.code, "boom", "deliberate test panic");
1649                }
1650                Box::pin(async move {
1651                    if let Event::PairingCode(pc) = &*event {
1652                        let _ = tx.send(pc.code.clone()).await;
1653                    }
1654                })
1655            }),
1656            interest: EventInterest::ALL,
1657        };
1658        let adapter = CallbackBusAdapter::new(
1659            client.clone(),
1660            vec![handler],
1661            EventDelivery::Ordered { capacity: 16 },
1662        );
1663
1664        adapter.handle_event(pairing_code_event("a"));
1665        adapter.handle_event(pairing_code_event("boom")); // callback panics, isolated
1666        adapter.handle_event(pairing_code_event("b"));
1667
1668        let mut seen = Vec::with_capacity(2);
1669        for _ in 0..2 {
1670            seen.push(
1671                tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
1672                    .await
1673                    .expect("timed out waiting for post-panic delivery")
1674                    .expect("callback ran"),
1675            );
1676        }
1677        assert_eq!(
1678            seen,
1679            vec!["a".to_string(), "b".to_string()],
1680            "delivery must continue after a callback panic"
1681        );
1682    }
1683
1684    /// Regression: a `with_task_instrument` after `with_alloc_meter` must drop
1685    /// the alloc-meter handle, so `resource_report()` doesn't report a
1686    /// never-driven all-zero snapshot as if the meter were active.
1687    #[tokio::test]
1688    async fn task_instrument_after_alloc_meter_clears_stale_handle() {
1689        use wacore::stats::{AllocMeter, CpuMeter};
1690
1691        let bot = Bot::builder()
1692            .with_backend_arc(create_test_sqlite_backend().await)
1693            .with_transport_factory(TokioWebSocketTransportFactory::new())
1694            .with_http_client(MockHttpClient)
1695            .with_runtime(TokioRuntime)
1696            .with_alloc_meter(Arc::new(AllocMeter::new()))
1697            .with_task_instrument(Arc::new(CpuMeter::new()))
1698            .build()
1699            .await
1700            .expect("build");
1701        assert!(
1702            bot.client().resource_report().await.alloc.is_none(),
1703            "a later with_task_instrument must drop the alloc-meter handle"
1704        );
1705
1706        // Reverse order: with_alloc_meter is last, so its snapshot is present.
1707        let bot = Bot::builder()
1708            .with_backend_arc(create_test_sqlite_backend().await)
1709            .with_transport_factory(TokioWebSocketTransportFactory::new())
1710            .with_http_client(MockHttpClient)
1711            .with_runtime(TokioRuntime)
1712            .with_task_instrument(Arc::new(CpuMeter::new()))
1713            .with_alloc_meter(Arc::new(AllocMeter::new()))
1714            .build()
1715            .await
1716            .expect("build");
1717        assert!(
1718            bot.client().resource_report().await.alloc.is_some(),
1719            "with_alloc_meter installs the handle when it is the last setter"
1720        );
1721    }
1722
1723    #[tokio::test]
1724    async fn test_bot_builder_single_device() {
1725        let backend = create_test_sqlite_backend().await;
1726        let transport = TokioWebSocketTransportFactory::new();
1727        let http_client = MockHttpClient;
1728
1729        let bot = Bot::builder()
1730            .with_backend_arc(backend)
1731            .with_transport_factory(transport)
1732            .with_http_client(http_client)
1733            .with_runtime(TokioRuntime)
1734            .build()
1735            .await
1736            .expect("Failed to build bot");
1737
1738        // Verify bot was created successfully
1739        let _client = bot.client();
1740    }
1741
1742    #[tokio::test]
1743    async fn test_bot_builder_multi_device() {
1744        // Create a backend configured for device ID 42
1745        let backend = create_test_sqlite_backend_for_device(42).await;
1746        let transport = TokioWebSocketTransportFactory::new();
1747
1748        let bot = Bot::builder()
1749            .with_backend_arc(backend)
1750            .with_transport_factory(transport)
1751            .with_http_client(MockHttpClient)
1752            .with_runtime(TokioRuntime)
1753            .build()
1754            .await
1755            .expect("Failed to build bot");
1756
1757        // Verify bot was created successfully
1758        let _client = bot.client();
1759    }
1760
1761    #[tokio::test]
1762    async fn test_bot_builder_defaults_only_need_backend() {
1763        // With the default features on, transport/HTTP/runtime are pre-filled,
1764        // so providing the backend alone must reach build().
1765        let temp_db = format!(
1766            "file:memdb_bot_{}?mode=memory&cache=shared",
1767            uuid::Uuid::new_v4()
1768        );
1769        let store = SqliteStore::new(&temp_db)
1770            .await
1771            .expect("Failed to create test SqliteStore");
1772
1773        let bot = Bot::builder()
1774            .with_backend(store)
1775            // Override the default HTTP client so the test doesn't hit the network.
1776            .with_http_client(MockHttpClient)
1777            .build()
1778            .await
1779            .expect("Failed to build bot from defaults");
1780
1781        let _client = bot.client();
1782    }
1783
1784    #[tokio::test]
1785    async fn test_bot_builder_with_version_override() {
1786        let backend = create_test_sqlite_backend().await;
1787        let transport = TokioWebSocketTransportFactory::new();
1788        let http_client = MockHttpClient;
1789
1790        let bot = Bot::builder()
1791            .with_backend_arc(backend)
1792            .with_transport_factory(transport)
1793            .with_http_client(http_client)
1794            .with_version((2, 3000, 123456789))
1795            .with_runtime(TokioRuntime)
1796            .build()
1797            .await
1798            .expect("Failed to build bot with version override");
1799
1800        // Verify the bot was created successfully
1801        let client = bot.client();
1802
1803        // Check that the override version is stored in the client
1804        assert_eq!(client.override_version, Some((2, 3000, 123456789)));
1805    }
1806
1807    #[tokio::test]
1808    async fn test_bot_builder_with_device_props_override() {
1809        let backend = create_test_sqlite_backend().await;
1810        let transport = TokioWebSocketTransportFactory::new();
1811        let http_client = MockHttpClient;
1812
1813        let custom_os = "CustomOS".to_string();
1814        let custom_version = wa::device_props::AppVersion {
1815            primary: Some(99),
1816            secondary: Some(88),
1817            tertiary: Some(77),
1818            ..Default::default()
1819        };
1820
1821        let bot = Bot::builder()
1822            .with_backend_arc(backend)
1823            .with_transport_factory(transport)
1824            .with_http_client(http_client)
1825            .with_device_props(
1826                DevicePropsOverride::new()
1827                    .with_os(custom_os.clone())
1828                    .with_version(custom_version.clone()),
1829            )
1830            .with_runtime(TokioRuntime)
1831            .build()
1832            .await
1833            .expect("Failed to build bot with device props override");
1834
1835        let client = bot.client();
1836        let persistence_manager = client.persistence_manager();
1837        let device = persistence_manager.get_device_snapshot();
1838
1839        // Verify the device props were overridden
1840        assert_eq!(device.device_props.os, Some(custom_os));
1841        assert_eq!(
1842            device.device_props.version.as_option(),
1843            Some(&custom_version)
1844        );
1845    }
1846
1847    #[tokio::test]
1848    async fn test_bot_builder_with_os_only_override() {
1849        let backend = create_test_sqlite_backend().await;
1850        let transport = TokioWebSocketTransportFactory::new();
1851        let http_client = MockHttpClient;
1852
1853        let custom_os = "CustomOS".to_string();
1854
1855        let bot = Bot::builder()
1856            .with_backend_arc(backend)
1857            .with_transport_factory(transport)
1858            .with_http_client(http_client)
1859            .with_device_props(DevicePropsOverride::new().with_os(custom_os.clone()))
1860            .with_runtime(TokioRuntime)
1861            .build()
1862            .await
1863            .expect("Failed to build bot with OS only override");
1864
1865        let client = bot.client();
1866        let persistence_manager = client.persistence_manager();
1867        let device = persistence_manager.get_device_snapshot();
1868
1869        // Verify only OS was overridden, version should be default
1870        assert_eq!(device.device_props.os, Some(custom_os));
1871        // Version should be the default since we didn't override it
1872        assert_eq!(
1873            device.device_props.version.as_option(),
1874            Some(&wacore::store::Device::default_device_props_version())
1875        );
1876    }
1877
1878    #[tokio::test]
1879    async fn test_bot_builder_with_version_only_override() {
1880        let backend = create_test_sqlite_backend().await;
1881        let transport = TokioWebSocketTransportFactory::new();
1882        let http_client = MockHttpClient;
1883
1884        let custom_version = wa::device_props::AppVersion {
1885            primary: Some(99),
1886            secondary: Some(88),
1887            tertiary: Some(77),
1888            ..Default::default()
1889        };
1890
1891        let bot = Bot::builder()
1892            .with_backend_arc(backend)
1893            .with_http_client(http_client)
1894            .with_transport_factory(transport)
1895            .with_device_props(DevicePropsOverride::new().with_version(custom_version.clone()))
1896            .with_runtime(TokioRuntime)
1897            .build()
1898            .await
1899            .expect("Failed to build bot with version only override");
1900
1901        let client = bot.client();
1902        let persistence_manager = client.persistence_manager();
1903        let device = persistence_manager.get_device_snapshot();
1904
1905        // Verify only version was overridden, OS should be default ("rust")
1906        assert_eq!(
1907            device.device_props.version.as_option(),
1908            Some(&custom_version)
1909        );
1910        // OS should be the default since we didn't override it
1911        assert_eq!(
1912            device.device_props.os,
1913            Some(wacore::store::Device::default_os().to_string())
1914        );
1915    }
1916
1917    #[tokio::test]
1918    async fn test_bot_builder_with_platform_type_override() {
1919        let backend = create_test_sqlite_backend().await;
1920        let transport = TokioWebSocketTransportFactory::new();
1921        let http_client = MockHttpClient;
1922
1923        let bot = Bot::builder()
1924            .with_backend_arc(backend)
1925            .with_transport_factory(transport)
1926            .with_http_client(http_client)
1927            .with_device_props(
1928                DevicePropsOverride::new()
1929                    .with_platform_type(wa::device_props::PlatformType::CHROME),
1930            )
1931            .with_runtime(TokioRuntime)
1932            .build()
1933            .await
1934            .expect("Failed to build bot with platform type override");
1935
1936        let client = bot.client();
1937        let persistence_manager = client.persistence_manager();
1938        let device = persistence_manager.get_device_snapshot();
1939
1940        // Verify platform type was set to Chrome
1941        assert_eq!(
1942            device.device_props.platform_type,
1943            Some(wa::device_props::PlatformType::CHROME)
1944        );
1945        // OS and version should remain default
1946        assert_eq!(
1947            device.device_props.os,
1948            Some(wacore::store::Device::default_os().to_string())
1949        );
1950        assert_eq!(
1951            device.device_props.version.as_option(),
1952            Some(&wacore::store::Device::default_device_props_version())
1953        );
1954    }
1955
1956    #[tokio::test]
1957    async fn test_bot_builder_with_full_device_props_override() {
1958        let backend = create_test_sqlite_backend().await;
1959        let transport = TokioWebSocketTransportFactory::new();
1960        let http_client = MockHttpClient;
1961
1962        let custom_os = "macOS".to_string();
1963        let custom_version = wa::device_props::AppVersion {
1964            primary: Some(2),
1965            secondary: Some(0),
1966            tertiary: Some(0),
1967            ..Default::default()
1968        };
1969        let custom_platform = wa::device_props::PlatformType::SAFARI;
1970
1971        let bot = Bot::builder()
1972            .with_backend_arc(backend)
1973            .with_transport_factory(transport)
1974            .with_http_client(http_client)
1975            .with_device_props(
1976                DevicePropsOverride::new()
1977                    .with_os(custom_os.clone())
1978                    .with_version(custom_version.clone())
1979                    .with_platform_type(custom_platform),
1980            )
1981            .with_runtime(TokioRuntime)
1982            .build()
1983            .await
1984            .expect("Failed to build bot with full device props override");
1985
1986        let client = bot.client();
1987        let persistence_manager = client.persistence_manager();
1988        let device = persistence_manager.get_device_snapshot();
1989
1990        // Verify all device props were overridden
1991        assert_eq!(device.device_props.os, Some(custom_os));
1992        assert_eq!(
1993            device.device_props.version.as_option(),
1994            Some(&custom_version)
1995        );
1996        assert_eq!(device.device_props.platform_type, Some(custom_platform));
1997    }
1998
1999    #[tokio::test]
2000    async fn test_bot_builder_skip_history_sync() {
2001        let backend = create_test_sqlite_backend().await;
2002        let transport = TokioWebSocketTransportFactory::new();
2003        let http_client = MockHttpClient;
2004
2005        let bot = Bot::builder()
2006            .with_backend_arc(backend)
2007            .with_transport_factory(transport)
2008            .with_http_client(http_client)
2009            .skip_history_sync()
2010            .with_runtime(TokioRuntime)
2011            .build()
2012            .await
2013            .expect("Failed to build bot with skip_history_sync");
2014
2015        assert!(bot.client().skip_history_sync_enabled());
2016    }
2017
2018    #[tokio::test]
2019    async fn test_bot_builder_default_history_sync_enabled() {
2020        let backend = create_test_sqlite_backend().await;
2021        let transport = TokioWebSocketTransportFactory::new();
2022        let http_client = MockHttpClient;
2023
2024        let bot = Bot::builder()
2025            .with_backend_arc(backend)
2026            .with_transport_factory(transport)
2027            .with_http_client(http_client)
2028            .with_runtime(TokioRuntime)
2029            .build()
2030            .await
2031            .expect("Failed to build bot");
2032
2033        assert!(!bot.client().skip_history_sync_enabled());
2034    }
2035
2036    #[tokio::test]
2037    async fn test_bot_builder_wanted_pre_key_count() {
2038        let backend = create_test_sqlite_backend().await;
2039        let transport = TokioWebSocketTransportFactory::new();
2040        let http_client = MockHttpClient;
2041
2042        let bot = Bot::builder()
2043            .with_backend_arc(backend)
2044            .with_transport_factory(transport)
2045            .with_http_client(http_client)
2046            .with_wanted_pre_key_count(200)
2047            .with_runtime(TokioRuntime)
2048            .build()
2049            .await
2050            .expect("Failed to build bot with custom pre-key count");
2051
2052        assert_eq!(bot.client().wanted_pre_key_count(), 200);
2053    }
2054
2055    #[tokio::test]
2056    async fn test_bot_builder_default_wanted_pre_key_count() {
2057        let backend = create_test_sqlite_backend().await;
2058        let transport = TokioWebSocketTransportFactory::new();
2059        let http_client = MockHttpClient;
2060
2061        let bot = Bot::builder()
2062            .with_backend_arc(backend)
2063            .with_transport_factory(transport)
2064            .with_http_client(http_client)
2065            .with_runtime(TokioRuntime)
2066            .build()
2067            .await
2068            .expect("Failed to build bot");
2069
2070        assert_eq!(
2071            bot.client().wanted_pre_key_count(),
2072            crate::prekeys::DEFAULT_WANTED_PRE_KEY_COUNT
2073        );
2074    }
2075
2076    #[tokio::test]
2077    async fn registered_handlers_accumulate_instead_of_replacing() {
2078        let backend = create_test_sqlite_backend().await;
2079
2080        let bot = Bot::builder()
2081            .with_backend_arc(backend)
2082            .with_transport_factory(TokioWebSocketTransportFactory::new())
2083            .with_http_client(MockHttpClient)
2084            .with_runtime(TokioRuntime)
2085            .on_message(|_ctx| async {})
2086            .on_qr_code(|_code, _timeout| async {})
2087            .on_event(|_event, _client| async {})
2088            .build()
2089            .await
2090            .expect("Failed to build bot");
2091
2092        assert_eq!(bot.event_handlers.len(), 3);
2093
2094        // The catch-all handler widens the union to every kind.
2095        let interest = combined_interest(&bot.event_handlers);
2096        assert_eq!(interest, EventInterest::ALL);
2097    }
2098
2099    #[test]
2100    fn combined_interest_is_the_union_of_handler_interests() {
2101        let noop: EventHandlerCallback = Arc::new(|_event, _client| Box::pin(async {}));
2102        let handlers = vec![
2103            RegisteredHandler {
2104                callback: noop.clone(),
2105                interest: EventInterest::of(&[EventKind::Messages]),
2106            },
2107            RegisteredHandler {
2108                callback: noop,
2109                interest: EventInterest::of(&[EventKind::PairingQrCode]),
2110            },
2111        ];
2112
2113        let interest = combined_interest(&handlers);
2114        assert!(interest.wants(EventKind::Messages));
2115        assert!(interest.wants(EventKind::PairingQrCode));
2116        assert!(!interest.wants(EventKind::Receipt));
2117    }
2118
2119    #[tokio::test]
2120    async fn from_arc_does_not_deep_clone() {
2121        let backend = create_test_sqlite_backend().await;
2122        let bot = Bot::builder()
2123            .with_backend_arc(backend)
2124            .with_transport_factory(TokioWebSocketTransportFactory::new())
2125            .with_http_client(MockHttpClient)
2126            .with_runtime(TokioRuntime)
2127            .build()
2128            .await
2129            .expect("Failed to build bot");
2130
2131        let original = Arc::new(wa::Message {
2132            conversation: Some("ping".to_string()),
2133            ..Default::default()
2134        });
2135        let original_ptr = Arc::as_ptr(&original);
2136
2137        let ctx =
2138            MessageContext::from_arc(Arc::clone(&original), &MessageInfo::default(), bot.client());
2139
2140        assert!(std::ptr::eq(Arc::as_ptr(&ctx.message), original_ptr));
2141    }
2142
2143    async fn test_context_with_info(info: MessageInfo) -> MessageContext {
2144        let backend = create_test_sqlite_backend().await;
2145        let bot = Bot::builder()
2146            .with_backend_arc(backend)
2147            .with_transport_factory(TokioWebSocketTransportFactory::new())
2148            .with_http_client(MockHttpClient)
2149            .with_runtime(TokioRuntime)
2150            .build()
2151            .await
2152            .expect("Failed to build bot");
2153        MessageContext::from_arc(Arc::new(wa::Message::default()), &info, bot.client())
2154    }
2155
2156    fn react_info(chat: &str, sender: &str, id: &str, is_group: bool) -> MessageInfo {
2157        use crate::types::message::MessageSource;
2158        MessageInfo {
2159            id: id.to_string(),
2160            source: MessageSource {
2161                chat: chat.parse().expect("chat jid"),
2162                sender: sender.parse().expect("sender jid"),
2163                is_group,
2164                is_from_me: false,
2165                ..Default::default()
2166            },
2167            ..Default::default()
2168        }
2169    }
2170
2171    #[tokio::test]
2172    async fn react_target_key_carries_group_participant() {
2173        let info = react_info(
2174            "120363012345@g.us",
2175            "15551230000@s.whatsapp.net",
2176            "MSGID01",
2177            true,
2178        );
2179        let ctx = test_context_with_info(info).await;
2180        let key = ctx.message_key();
2181
2182        assert_eq!(key.remote_jid.as_deref(), Some("120363012345@g.us"));
2183        assert_eq!(key.id.as_deref(), Some("MSGID01"));
2184        assert_eq!(key.from_me, Some(false));
2185        // Group reactions must attribute the original sender via participant.
2186        assert_eq!(
2187            key.participant.as_deref(),
2188            Some("15551230000@s.whatsapp.net")
2189        );
2190    }
2191
2192    #[tokio::test]
2193    async fn react_target_key_omits_participant_in_dm() {
2194        let info = react_info(
2195            "15559990000@s.whatsapp.net",
2196            "15559990000@s.whatsapp.net",
2197            "MSGID02",
2198            false,
2199        );
2200        let ctx = test_context_with_info(info).await;
2201        let key = ctx.message_key();
2202
2203        assert_eq!(
2204            key.remote_jid.as_deref(),
2205            Some("15559990000@s.whatsapp.net")
2206        );
2207        // DMs do not carry participant (matches WA Web message-key shape).
2208        assert!(key.participant.is_none());
2209    }
2210
2211    #[tokio::test]
2212    async fn react_target_key_carries_status_author() {
2213        let info = react_info(
2214            "status@broadcast",
2215            "15551112222@s.whatsapp.net",
2216            "MSGID03",
2217            false,
2218        );
2219        let ctx = test_context_with_info(info).await;
2220        let key = ctx.message_key();
2221
2222        // status@broadcast reactions fan out to the author's devices, so the
2223        // author must be present in participant for the send path to extract it.
2224        assert_eq!(
2225            key.participant.as_deref(),
2226            Some("15551112222@s.whatsapp.net")
2227        );
2228    }
2229
2230    #[tokio::test]
2231    async fn run_metered_reports_to_instrument() {
2232        let meter = Arc::new(wacore::stats::CpuMeter::new());
2233        run_metered(
2234            async {
2235                tokio::task::yield_now().await;
2236            },
2237            Some(meter.clone()),
2238        )
2239        .await;
2240        // yield_now forces Pending once, so the wrapper must see >= 2 polls,
2241        // and the busy-time attribution path must have accumulated something.
2242        assert!(meter.snapshot().polls >= 2);
2243        assert!(meter.snapshot().busy > std::time::Duration::ZERO);
2244
2245        // No instrument: plain passthrough must still drive to completion.
2246        run_metered(async {}, None).await;
2247    }
2248}