|
|
@@ -0,0 +1,153 @@
|
|
|
+---
|
|
|
+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]
|
|
|
+verified:
|
|
|
+ - by: openwiki/0.7.2
|
|
|
+ at: 2026-10-19T18:58:46.219Z
|
|
|
+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-19T18:58:46.219Z" }
|
|
|
+---
|
|
|
+
|
|
|
+# 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`).
|
|
|
+
|
|
|
+## Startup
|
|
|
+
|
|
|
+`start(session_db, &events)` (`src/whatsapp.rs:204-237`):
|
|
|
+
|
|
|
+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:240-261`) only pushes raw events, in
|
|
|
+order, into an internal unbounded channel; its interest set is
|
|
|
+`SUBSCRIBED_KINDS` (`src/whatsapp.rs:51-69`). 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:82-103`):
|
|
|
+
|
|
|
+| 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 |
|
|
|
+| `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:409-448`) maps raw events to
|
|
|
+these variants. 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: ...`, `connection failure: ...`,
|
|
|
+`stream error: ...` and `could not decrypt a message from ...`;
|
|
|
+`StreamReplaced` becomes `Disconnected("stream replaced by another session")`.
|
|
|
+
|
|
|
+## JIDs
|
|
|
+
|
|
|
+- JIDs are stored as `user@server` without device suffix
|
|
|
+ (`jid_to_string` uses `to_non_ad_string`, `src/whatsapp.rs:805`).
|
|
|
+- `canonical_jid` (`src/whatsapp.rs:362-377`) 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:187-193`) returns this account's PN
|
|
|
+ and LID; it is empty before pairing.
|
|
|
+
|
|
|
+## Live messages
|
|
|
+
|
|
|
+`handle_live_message` (`src/whatsapp.rs:464-510`):
|
|
|
+
|
|
|
+- 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:797-800`);
|
|
|
+- attaches an `ImageRef` only when the rendered text is an image text;
|
|
|
+- emits `StoredMessage` with the WhatsApp timestamp and `is_ai = false`.
|
|
|
+
|
|
|
+## Contact names
|
|
|
+
|
|
|
+Names come from two sources ranked `Push < Saved` (`NameSource`,
|
|
|
+`src/whatsapp.rs:264-270`): push names from messages and `PushNameUpdate`,
|
|
|
+saved names from `ContactUpdate` (full name, else first name,
|
|
|
+`src/whatsapp.rs:515-537`) and from history sync. `note_name`
|
|
|
+(`src/whatsapp.rs:325-343`) 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:544-564`). Decoding (inflate + protobuf)
|
|
|
+runs in `spawn_blocking` via `extract_history` (`src/whatsapp.rs:693-734`).
|
|
|
+`emit_history` (`src/whatsapp.rs:570-636`) then emits push names, inline
|
|
|
+address-book contacts, conversation names and every renderable message.
|
|
|
+History messages never carry an image reference, so they cannot trigger
|
|
|
+vision replies; 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:816-828`) turns a protobuf message into
|
|
|
+text, in this order:
|
|
|
+
|
|
|
+1. plain or extended text;
|
|
|
+2. image → `[immagine]` or `[immagine] <caption>`; audio (including voice
|
|
|
+ notes) → `[messaggio vocale]` (`src/whatsapp.rs:836-848`);
|
|
|
+3. caption of other media;
|
|
|
+4. `describe_other` (`src/whatsapp.rs:856-874`): 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:899-943`): `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:146-159`) rejects
|
|
|
+ empty text, parses the JID and returns the new message id.
|
|
|
+- `WaHandle::download_image(&ImageRef)` (`src/whatsapp.rs:166-179`) 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:107-131`).
|
|
|
+
|
|
|
+## Tests
|
|
|
+
|
|
|
+`src/whatsapp.rs:945-1173` covers 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).
|