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:
651d1fb6c9
resource: repo://Cargo.toml7ea5eac45e
resource: repo://src/whatsapp.rs
generated: { by: "opencode", at: "2026-10-24T16:39:50.206Z" }
---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:
WaEvent values sent to the
event loop;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.
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).
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):
SqliteStore::new (session.db, see
Configuration and data);Bot with that backend and an EventForwarder handler;Translator task (keeping its abort handle) and the bot;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
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):
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;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;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 (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.
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.handle_live_message (src/whatsapp.rs:703-755):
status@broadcast and messages with no renderable content;recipient_alt for own messages and sender_alt otherwise;push_name_of drops own
and empty names, src/whatsapp.rs:1052-1058);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;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.
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.
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.
describe_message (src/whatsapp.rs:1071-1083) turns a protobuf message into
text, in this order:
[immagine] or [immagine] <caption>; audio (including voice
notes) → [messaggio vocale] (describe_image_or_audio,
src/whatsapp.rs:1091-1103);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.
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).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.