--- type: integration title: WhatsApp client integration description: How src/whatsapp.rs wraps whatsapp-rust — session startup, 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] 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-21T21:49:12.291Z" } verified: - by: openwiki/0.7.2 at: 2026-10-21T21:49:12.291Z --- # 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 query its own JIDs. `rusqlite` is pinned so that it shares `libsqlite3-sys` with the `diesel` backend of `whatsapp-rust-sqlite-storage` (`Cargo.toml:14-15`). ## Startup `start(session_db, &events)` (`src/whatsapp.rs:208-241`): 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 and the bot; 5. when the bot task ends, emits `Disconnected("client stopped")`. `EventForwarder` (`src/whatsapp.rs:244-265`) only pushes raw events, in order, into an internal unbounded channel; its interest set is `SUBSCRIBED_KINDS` (`src/whatsapp.rs:51-70`), 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 ``` ## WaEvent `WaEvent` (`src/whatsapp.rs:81-108`): | Variant | Meaning | |---|---| | `Qr(String)` | Pairing QR payload to render ([Terminal UI](../architecture/terminal-ui.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:413-459`) maps raw events to these variants (`Event::OfflineSyncCompleted` becomes `WaEvent::OfflineSyncCompleted`). Failures become `Error` texts such as `pairing QR codes exhausted; restart to pair again`, `pairing failed: ...`, `WhatsApp rejected this client version (client outdated)`, `account temporarily banned: (expires in Ns)`, `connection failure: ()` (`describe_connect_failure`, `src/whatsapp.rs:796-801`), `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:808-810`), 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:825-827`). - `canonical_jid` (`src/whatsapp.rs:366-386`) 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:191-197`) returns this account's PN and LID; it is empty before pairing. ## Live messages `handle_live_message` (`src/whatsapp.rs:475-521`): - 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:817-820`); - 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:267-275`): push names from messages and `PushNameUpdate`, saved names from `ContactUpdate` (full name, else first name, `src/whatsapp.rs:526-540`) and from history sync. `note_name` (`src/whatsapp.rs:329-351`) 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:555-575`). Decoding (inflate + protobuf) runs in `spawn_blocking` via `extract_history` (`src/whatsapp.rs:704-745`). `emit_history` (`src/whatsapp.rs:581-648`) 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:836-848`) 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:856-868`); 3. caption of other media; 4. `describe_other` (`src/whatsapp.rs:876-894`): 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:915-963`): `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:150-163`) rejects empty text, parses the JID and returns the new message id. - `WaHandle::download_image(&ImageRef)` (`src/whatsapp.rs:170-183`) 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:109-128`). ## Tests `src/whatsapp.rs:965+` 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. See [Unit tests](../testing/unit-tests.md).