whatsapp-client.md 11 KB


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 (including voice-note placeholders and audio references), image/audio download and sending. tags: [whatsapp, whatsapp-rust, events, jid, history-sync, relink, session, audio] sources:

  • id: openwiki-source-651d1fb6c9 resource: repo://Cargo.toml
  • id: openwiki-source-7ea5eac45e resource: repo://src/whatsapp.rs generated: { by: "opencode", at: "2026-10-24T16:39:50.206Z" } ---

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 audios, 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:196-203) holds the running Session in an Arc<Mutex<Option<Session>>>, 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:206-213) is one client (Arc<Client>), 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:238-243), 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:358-370) calls open_session and wraps the result in a new WaHandle. open_session (src/whatsapp.rs:430-471):

  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 (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:472-495) only pushes raw events, in order, into an internal unbounded channel; its interest set is SUBSCRIBED_KINDS (src/whatsapp.rs:58-75), 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:261-270) replaces the session with a new, unpaired one so a new QR code is offered (end-to-end flow: WhatsApp relink and pairing):

  1. takes the current session out of the slot (the slot stays empty) and calls Session::stop (src/whatsapp.rs:223-231): 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:374-386) 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:407-428) 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:109-135):

Variant Meaning
Qr(String) Pairing QR payload to render (Terminal UI, web panel)
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, 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:641-690) 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: <code> (expires in Ns), connection failure: <reason> (<message>) (describe_connect_failure, src/whatsapp.rs:1031-1041), 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:1043-1050), 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:1060-1069).
  • canonical_jid (src/whatsapp.rs:594-614) 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:339-348) 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:703-755):

  • 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:1052-1058);
  • 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 only rewrites @<own number> mentions in the text before building the AI context.

Contact names

Names come from two sources ranked Push < Saved (NameSource, src/whatsapp.rs:497-503): push names from messages and PushNameUpdate (src/whatsapp.rs:779-783), saved names from ContactUpdate (full name, else first name, src/whatsapp.rs:760-774) and from history sync. note_name (src/whatsapp.rs:557-579) 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:789-813). Decoding (inflate + protobuf) runs in spawn_blocking via extract_history (src/whatsapp.rs:939-984). emit_history (src/whatsapp.rs:815-836) then emits push names, inline address-book contacts, conversation names (emit_history_conversation, src/whatsapp.rs:843-884) 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:1071-1083) 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] (describe_image_or_audio, src/whatsapp.rs:1091-1103);
  3. caption of other media;
  4. describe_other (src/whatsapp.rs:1111-1129): 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:1169-1203): 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:277-290) 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:297-310) 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:317-330) 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:144-154, src/whatsapp.rs:172-182).

Tests

src/whatsapp.rs:1216 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:1241-1259), 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.