--- type: integration title: WhatsApp client integration description: How src/whatsapp.rs wraps whatsapp-rust — the replaceable session behind WaHandle, session startup and relink (logout, session database reset, new unpaired client), translation of raw events into WaEvent, the scheduled "unavailable" presence that keeps phone notifications working, JID canonicalization, contact names, history sync, message rendering (including voice-note placeholders and audio references), image/audio download, profile picture URLs and sending. tags: [whatsapp, whatsapp-rust, events, jid, history-sync, relink, session, audio, presence, avatars] sources: - id: openwiki-source-651d1fb6c9e49916a916ab51 resource: repo://Cargo.toml - id: openwiki-source-7ea5eac45e5030178a6ea05b resource: repo://src/whatsapp.rs generated: { by: "claude-code", at: "2026-10-25T12:54:53.684Z" } verified: - by: openwiki/0.7.2 at: 2026-10-25T12:54:53.684Z --- # WhatsApp client integration `src/whatsapp.rs` is the only module that talks to WhatsApp. It wraps the `whatsapp-rust` 0.7 crate (`Cargo.toml:10`, default features off; `sqlite-storage`, `tokio-transport`, `tokio-runtime`, `ureq-client`, `tokio-native`, `signal` on) and exposes two things to the rest of the app: - a stream of plain `WaEvent` values sent to the [event loop](../architecture/runtime-and-event-loop.md); - a cloneable `WaHandle` used by [`App`](../architecture/app-state.md) to send text, download images and audios, fetch profile picture URLs, query its own JIDs and replace the session with a new unpaired one (`relink`). `rusqlite` is pinned so that it shares `libsqlite3-sys` with the `diesel` backend of `whatsapp-rust-sqlite-storage` (`Cargo.toml:17-18`); the module also uses it directly to empty the session database during a relink. ## Session and handle `WaHandle` (`src/whatsapp.rs:202-209`) holds the running `Session` in an `Arc>>`, the `session.db` path and the `WaEvent` sender shared by every session. Clones share the same slot, so a relink started from one clone is seen by all. A `Session` (`src/whatsapp.rs:212-219`) is one client (`Arc`), the abort handle of its translator task and a `watch` receiver that becomes `true` when the client run loop stops. Every client operation goes through `WaHandle::client` (`src/whatsapp.rs:244-249`), which fails with `WhatsApp session not running` while the slot is empty — during a relink or after a failed one. The lock recovers from poisoning (`lock_session`). ## Startup `start(session_db, events)` (`src/whatsapp.rs:384-396`) calls `open_session` and wraps the result in a new `WaHandle`. `open_session` (`src/whatsapp.rs:456-496`): 1. requires a UTF-8 session path and creates its parent directory; 2. opens the session store with `SqliteStore::new` (`session.db`, see [Configuration and data](../operations/configuration-and-data.md)); 3. builds a `Bot` with that backend and an `EventForwarder` handler; 4. spawns the `Translator` task (keeping its abort handle) and the bot; 5. when the bot task ends, emits `Disconnected("client stopped")` and marks the session stopped on its `watch` channel. `EventForwarder` (`src/whatsapp.rs:498-521`) only pushes raw events, in order, into an internal unbounded channel; its interest set is `SUBSCRIBED_KINDS` (`src/whatsapp.rs:59-79`), which includes `OfflineSyncCompleted`, `AppStateSyncFailed` and `SelfPushNameUpdated`. All translation happens in `Translator`, which also owns a name cache. ``` whatsapp-rust Bot --raw events--> EventForwarder --chan--> Translator --WaEvent--> App ``` ## Relink `WaHandle::relink` (`src/whatsapp.rs:267-276`) replaces the session with a new, unpaired one so a new QR code is offered (end-to-end flow: [WhatsApp relink and pairing](../workflows/relink-and-pairing.md)): 1. takes the current session out of the slot (the slot stays empty) and calls `Session::stop` (`src/whatsapp.rs:229-237`): `logout` (deregisters the device on the phone when connected, best effort, and disconnects), waits up to 10 s (`STOP_TIMEOUT_SECONDS`) for the run loop to stop, then aborts the translator task; 2. `reset_session_files` (`src/whatsapp.rs:400-412`) deletes `session.db` and its `-wal` / `-shm` side files (missing files are fine). When the deletion fails — on Windows the library keeps the database open until the process exits — `clear_session_tables` (`src/whatsapp.rs:433-454`) instead deletes every row of every table except SQLite internals and the `__diesel_schema_migrations` record, in one transaction with a 5 s busy timeout. The stopped client writes nothing more afterwards; 3. `open_session` starts a new client on the empty database and stores it in the slot. Its pairing QR codes arrive as `WaEvent::Qr` on the same events channel. Any error in steps 2–3 is returned and leaves the slot empty until the next successful relink (sends then fail with `WhatsApp session not running`). ## WaEvent `WaEvent` (`src/whatsapp.rs:115-141`): | Variant | Meaning | |---|---| | `Qr(String)` | Pairing QR payload to render ([Terminal UI](../architecture/terminal-ui.md), [web panel](web-control-panel.md)) | | `Connected` | Session is online | | `Disconnected(String)` | Connection lost, with reason | | `LoggedOut` | Device unlinked; pairing must restart | | `OfflineSyncCompleted` | All messages received while the app was offline have been delivered (and, being sequential, already emitted as `Message`); triggers the startup [catch-up](../workflows/ai-autoreply.md) | | `Message { message, image, audio }` | A `StoredMessage` (live, history sync or phone-sent); `image` / `audio` are `Some` only for live media messages (never history sync) | | `Contact(Contact)` | A new or better contact name | | `Error(String)` | Non-fatal problem shown in the status line | `Translator::handle_event` (`src/whatsapp.rs:667-717`) maps raw events to these variants (`Event::OfflineSyncCompleted` becomes `WaEvent::OfflineSyncCompleted`). Failures become `Error` texts such as `pairing QR codes exhausted; request a new QR to pair again` (F8 or the web panel's "Nuovo QR"), `pairing failed: ...`, `WhatsApp rejected this client version (client outdated)`, `account temporarily banned: (expires in Ns)`, `connection failure: ()` (`describe_connect_failure`, `src/whatsapp.rs:1085-1095`), `stream error: ...` and `could not decrypt a message from ...`; `StreamReplaced` becomes `Disconnected("stream replaced by another session")`. `Connected` and `SelfPushNameUpdated` additionally schedule the offline presence described below. `AppStateSyncFailed` is reported only when at least one collection failed **fatally** (`is_fatal_sync_failure`, `src/whatsapp.rs:1097-1104`), as `app state sync incomplete (fatal: [...], retryable: [...])`; retryable or skipped collections are retried by the library on a forced reconnect and are not shown. ## Presence and phone notifications The library marks the account *available* on every (re)connect and push-name update, and while a linked device is available WhatsApp suppresses push notifications on the phone. `schedule_offline_presence` (`src/whatsapp.rs:719-741`) undoes this: it spawns a task that waits `PRESENCE_SETTLE_SECONDS` (5 s, `src/whatsapp.rs:81-83`), returns silently if the client is no longer connected, and otherwise sends an "unavailable" presence with `client.presence().set_unavailable()`. - It runs after every `Event::Connected` (after emitting `WaEvent::Connected`) and after every `Event::SelfPushNameUpdated`. - On the first pairing the push name is not known yet, so the call fails with `PresenceError::PushNameEmpty`; that error is ignored because the later `SelfPushNameUpdated` event schedules the presence again. - Any other failure is reported as `failed to set unavailable presence: ` (`WaEvent::Error`). The bot therefore appears offline to contacts and the phone keeps receiving notifications, while messages are still delivered to the bot. ## JIDs - JIDs are stored as `user@server` without device suffix (`jid_to_string` uses `to_non_ad_string`, `src/whatsapp.rs:1114-1123`). - `canonical_jid` (`src/whatsapp.rs:620-640`) prefers the phone-number (PN) form: non-LID JIDs are kept; a LID uses the alternate PN JID from the message, else the store's LID→PN mapping; on lookup failure the LID is kept and `LID lookup failed for ...` is reported. - `WaHandle::own_jids` (`src/whatsapp.rs:365-374`) returns this account's PN and LID; it is empty before pairing completes and while no session is running. ## Live messages `handle_live_message` (`src/whatsapp.rs:757-809`): - skips `status@broadcast` and messages with no renderable content; - keeps group JIDs as the chat; for 1:1 chats canonicalizes using `recipient_alt` for own messages and `sender_alt` otherwise; - records the sender push name as a contact name (`push_name_of` drops own and empty names, `src/whatsapp.rs:1106-1112`); - attaches an `ImageRef` (`image_ref_of`) only when the rendered text is an image text, and an `AudioRef` (`audio_ref_of`) only when it is a voice note; - emits `StoredMessage` with the WhatsApp timestamp and `is_ai = false`. Mentions and quotes are not extracted: the message text is passed on as rendered, and the [AI autoreply](../workflows/ai-autoreply.md) only rewrites `@` mentions in the text before building the AI context. ## Contact names Names come from two sources ranked `Push < Saved` (`NameSource`, `src/whatsapp.rs:523-529`): push names from messages and `PushNameUpdate` (`src/whatsapp.rs:833-838`), saved names from `ContactUpdate` (full name, else first name, `src/whatsapp.rs:814-828`) and from history sync. `note_name` (`src/whatsapp.rs:583-605`) ignores empty names, never lets a push name override a saved one, skips unchanged names and otherwise emits `WaEvent::Contact`. The [history store](../concepts/history-store.md) persists them. ## History sync Each `HistorySync` chunk is processed in its own task (`handle_history`, `src/whatsapp.rs:843-867`). Decoding (inflate + protobuf) runs in `spawn_blocking` via `extract_history` (`src/whatsapp.rs:993-1038`). `emit_history` (`src/whatsapp.rs:869-890`) then emits push names, inline address-book contacts, conversation names (`emit_history_conversation`, `src/whatsapp.rs:897-938`) and every renderable message. History messages never carry an `ImageRef` or `AudioRef` (so live-only media handling never applies to them); the sender is the own PN for own messages, otherwise the group participant or the chat. Undecodable conversations are reported as `history sync skipped N undecodable conversations`. ## Message rendering `describe_message` (`src/whatsapp.rs:1125-1137`) turns a protobuf message into text, in this order: 1. plain or extended text; 2. image → `[immagine]` or `[immagine] `; audio (including voice notes) → `[messaggio vocale]` (`describe_image_or_audio`, `src/whatsapp.rs:1145-1157`); 3. caption of other media; 4. `describe_other` (`src/whatsapp.rs:1165-1183`): protocol, reaction, encrypted reaction, poll vote and sender-key distribution messages are silent (`None`, not stored); video, document, sticker and PTV → `[media]`; anything else → `[messaggio non supportato]`. Helpers used by the [AI autoreply](../workflows/ai-autoreply.md) (`src/whatsapp.rs:1223-1262`): `is_placeholder` (only `[media]` and `[messaggio non supportato]`; the voice and image placeholders are **not** placeholders in this sense), `is_voice_note`, `is_image` and `image_caption`. ## Sending and downloading - `WaHandle::send_text(chat_jid, text)` (`src/whatsapp.rs:283-296`) rejects empty text, parses the JID and returns the new message id; it fails while no session is running. - `WaHandle::download_image(&ImageRef)` (`src/whatsapp.rs:303-316`) downloads and decrypts the image into memory (`DownloadedImage`), falling back to `image/jpeg` when the MIME type is blank. `WaHandle::download_audio` (`src/whatsapp.rs:323-336`) does the same for audios (`DownloadedAudio`, fallback `audio/ogg`). Media is never written to disk by this module, and the `Debug` impls of `ImageRef`/`AudioRef` omit key material (`src/whatsapp.rs:150-160`, `src/whatsapp.rs:178-188`). ## Profile pictures `WaHandle::profile_picture_url(chat_jid)` (`src/whatsapp.rs:338-357`) parses the JID (private chat or group, `user@server` form) and sends one IQ request through `client.contacts().get_profile_picture(&jid, true)`, asking for the small preview. It returns `Some(url)` with the WhatsApp CDN URL, `None` when the chat has no picture or hides it for privacy, and an error for an invalid JID, while no session is running, or on a network failure (`failed to fetch the profile picture of `). Only the URL is returned; the picture itself is never downloaded by the bot. It is used by the [web panel avatar fetch](web-control-panel.md#profile-pictures), which paces the calls to avoid rate limits. ## Tests `src/whatsapp.rs:1270` onward covers `is_fatal_sync_failure`, text and extended text, captions and placeholders, image and voice rendering (including the audio-message placeholder and `audio_ref_of`), image references, placeholder helpers, silent reactions/protocol messages, JID device-suffix removal and push-name filtering. An `#[ignore]`d live test, `relink_deletes_the_session_and_offers_a_new_qr` (`src/whatsapp.rs:1293-1313`), connects to the WhatsApp servers, waits for a QR, relinks and checks that the second QR carries new device keys; run it with `cargo test -- --ignored`. The offline presence and `profile_picture_url` have no tests. See [Unit tests](../testing/unit-tests.md).