--- 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, JID canonicalization, contact names, history sync, message rendering, image download and sending. tags: [whatsapp, whatsapp-rust, events, jid, history-sync, relink, session] 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-23T21:26:24.156Z" } verified: - by: openwiki/0.7.2 at: 2026-10-23T21:26:24.156Z --- # 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, 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:160-170`) 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:172-180`) 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:200-210`), 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:297-312`) calls `open_session` and wraps the result in a new `WaHandle`. `open_session` (`src/whatsapp.rs:369-416`): 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:417-439`) only pushes raw events, in order, into an internal unbounded channel; its interest set is `SUBSCRIBED_KINDS` (`src/whatsapp.rs:54-74`), which includes `OfflineSyncCompleted` and `AppStateSyncFailed`. 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:221-237`) 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:182-198`): `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:314-329`) 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:349-367`) 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:104-130`): | 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 }` | A `StoredMessage` (live or history sync); `image` is `Some` only for live image messages | | `Contact(Contact)` | A new or better contact name | | `Error(String)` | Non-fatal problem shown in the status line | `Translator::handle_event` (`src/whatsapp.rs:585-634`) 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:971-976`), `stream error: ...` and `could not decrypt a message from ...`; `StreamReplaced` becomes `Disconnected("stream replaced by another session")`. `AppStateSyncFailed` is reported only when at least one collection failed **fatally** (`is_fatal_sync_failure`, `src/whatsapp.rs:983-985`), as `app state sync incomplete (fatal: [...], retryable: [...])`; retryable or skipped collections are retried by the library on a forced reconnect and are not shown. ## JIDs - JIDs are stored as `user@server` without device suffix (`jid_to_string` uses `to_non_ad_string`, `src/whatsapp.rs:1000-1002`). - `canonical_jid` (`src/whatsapp.rs:541-556`) 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:279-295`) 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:650-696`): - 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:992-995`); - attaches an `ImageRef` only when the rendered text is an image text; - 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:442-450`): push names from messages and `PushNameUpdate`, saved names from `ContactUpdate` (full name, else first name, `src/whatsapp.rs:701-715`) and from history sync. `note_name` (`src/whatsapp.rs:499-522`) 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:730-750`). Decoding (inflate + protobuf) runs in `spawn_blocking` via `extract_history` (`src/whatsapp.rs:879-920`). `emit_history` (`src/whatsapp.rs:756-822`) then emits push names, inline address-book contacts, conversation names and every renderable message. History messages never carry an image reference (so their images are never described); 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:1011-1023`) 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:1031-1043`); 3. caption of other media; 4. `describe_other` (`src/whatsapp.rs:1051-1069`): 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:1090-1138`): `is_placeholder` (only `[media]` and `[messaggio non supportato]`), `is_voice_note`, `is_image` and `image_caption`. ## Sending and downloading - `WaHandle::send_text(chat_jid, text)` (`src/whatsapp.rs:239-257`) 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:259-277`) downloads and decrypts the image into memory (`DownloadedImage`), falling back to `image/jpeg` when the MIME type is blank. Images are never written to disk, and `ImageRef`'s `Debug` omits key material (`src/whatsapp.rs:132-151`). ## Tests `src/whatsapp.rs:1140+` covers `is_fatal_sync_failure`, text and extended text, captions and placeholders, image and voice rendering, 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:1163-1183`), 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`. See [Unit tests](../testing/unit-tests.md).