whatsapp-client.md 7.9 KB


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-651d1fb6c9 resource: repo://Cargo.toml
  • id: openwiki-source-7ea5eac45e resource: repo://src/whatsapp.rs generated: { by: "claude-code", at: "2026-10-19T19:53:56.480Z" } verified:
  • by: openwiki/0.7.2 at: 2026-10-19T19:53:56.480Z ---

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;
  • a cloneable WaHandle used by App 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:211-244):

  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);
  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:247-268) 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. 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-110):

Variant Meaning
Qr(String) Pairing QR payload to render (Terminal UI)
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
Message { message, image, addressed_to } A StoredMessage (live or history sync); image is Some only for live image messages; addressed_to lists the raw JIDs the message directly addresses (empty for 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:416-456) 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: ..., 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:815-817).
  • canonical_jid (src/whatsapp.rs:369-384) 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:194-200) returns this account's PN and LID; it is empty before pairing.

Live messages

handle_live_message (src/whatsapp.rs:472-519):

  • 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:807-810);
  • attaches an ImageRef only when the rendered text is an image text;
  • emits StoredMessage with the WhatsApp timestamp and is_ai = false, plus addressed_to from addressed_jids.

addressed_jids (src/whatsapp.rs:892-919) reads the ContextInfo of the first content kind that carries one (extended text, image, video, audio, document, sticker) and returns the mentioned_jid list followed by the quoted message's participant — the latter only when the message actually quotes one (quoted_message or stanza_id set). The JIDs are raw (not canonicalized); the AI autoreply compares them with the own JIDs to decide whether a group message is addressed to the user.

Contact names

Names come from two sources ranked Push < Saved (NameSource, src/whatsapp.rs:270-277): push names from messages and PushNameUpdate, saved names from ContactUpdate (full name, else first name, src/whatsapp.rs:524-546) and from history sync. note_name (src/whatsapp.rs:332-350) ignores empty names, never lets a push name override a saved one, skips unchanged names and otherwise emits WaEvent::Contact. The history store persists them.

History sync

Each HistorySync chunk is processed in its own task (handle_history, src/whatsapp.rs:553-573). Decoding (inflate + protobuf) runs in spawn_blocking via extract_history (src/whatsapp.rs:703-744). emit_history (src/whatsapp.rs:579-646) 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) and always have an empty addressed_to; 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:826-844) 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:846-858);
  3. caption of other media;
  4. describe_other (src/whatsapp.rs:866-884): 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 (src/whatsapp.rs:944-988): 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:153-166) rejects empty text, parses the JID and returns the new message id.
  • WaHandle::download_image(&ImageRef) (src/whatsapp.rs:173-186) 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:112-129).

Tests

src/whatsapp.rs:990-1282 covers addressed_jids (mentions, quoted participant, participant without quote, media context info, plain text), 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.