README.md 11 KB

wh

Terminal WhatsApp client (ratatui) with per-chat and global AI autoreply via an OpenAI-compatible API.

Build

cargo build --release

wh --version (or -V) prints the build version and exits: package version and short git commit, e.g. wh 0.1.0 (cde0f55) (unknown when built without git). The same version is shown at the bottom right of the main screen.

Cross-compile: Linux x86_64 static (musl)

Built from Windows with cargo-zigbuild. Zig provides the C cross-compiler and linker for the bundled C code (SQLite, ring). TLS uses ring only (reqwest is built with rustls-no-provider and the ring provider is installed at startup), so the large aws-lc-sys C build is avoided. A rebuild after a source change takes seconds; a full rebuild is needed only when dependencies change.

One-time prerequisites:

rustup target add x86_64-unknown-linux-musl
winget install -e --id zig.zig      # or: pip install ziglang
cargo install --locked cargo-zigbuild

Build:

cargo zigbuild --release --target x86_64-unknown-linux-musl

Output: target/x86_64-unknown-linux-musl/release/wh

Verify on Linux:

file wh   # ELF 64-bit x86-64, statically linked
chmod +x wh && ./wh --version

Data directory

On Linux everything lives in ~/.config/wh/ of the user running wh; on Windows in %APPDATA%\wh. File names are case-sensitive on Linux.

File Content
config.toml settings (optional)
SOUL.md system prompt (required for the AI)
VISION.md request used to describe images (optional)
session.db WhatsApp linked-device session
history.db chats, messages, autoreply flags, image descriptions
ai_requests.log AI request log, only with ai_log = true

Copy session.db from Windows to reuse the linked session instead of scanning the QR again; never run both copies at the same time. To pair again, use F8 or the web panel "Nuovo QR" button.

Configuration

Settings live in config.toml in the data directory. Start from config.toml.global.template: copy it there and fill openai_api_key. A missing file means defaults. Unknown keys are ignored.

Key Default Description
openai_api_key unset API key of the OpenAI-compatible endpoint (required for AI)
openai_base_url https://ollama.com/v1 endpoint base URL
openai_model deepseek-v4.1-flash model for autoreplies
openai_vision_model unset model that describes images (see Images); unset disables image understanding
ai_log false append every AI request and its outcome to ai_requests.log (full chat context in clear text)
reply_delay_seconds 30 seconds the autoreply waits after the first unanswered message of a chat before answering (0 = at once)
catch_up_seconds 7200 after a restart, answer the last unanswered private message up to this age (0 = off)
ai_retries 2 retries of a failed AI request, 2s, 4s, ... backoff (0 = off; HTTP 4xx except 429 never retried)
web_port unset port of the web control panel (1-65535); unset disables it

Voice-note transcription adds four optional transcribe_* keys, documented in Voice notes.

Live reload

config.toml, SOUL.md and VISION.md are checked every 2 seconds and reloaded when changed, without restarting wh (status Configurazione ricaricata). An invalid file keeps the current configuration (Configurazione non ricaricata: ...). Only web_port needs a restart.

System prompt: SOUL.md

The system prompt (the responder's identity and rules) lives in its own file, SOUL.md, next to config.toml, and is sent as written. Start from soul.md.template. Without openai_api_key and a non-blank SOUL.md the app runs as a plain WhatsApp client and the status line says AI non configurata. SOUL.md and VISION.md are personal and ignored by git.

The app appends to the prompt only factual context: whether the chat is private (Chat privata con «nome».) or a group (Chat di gruppo «nome».) and who writes the user/assistant messages. Everything about behaviour belongs in SOUL.md. Texts the model can find in the conversation:

Text Meaning
Nome: ... in groups, each participant message starts with the sender name
@[te] a mention of the account owner
[Il contatto ha inviato un'immagine. Descrizione: ...] an image, replaced by its stored description
[Il contatto ha inviato un'immagine che non puoi vedere] an image without description
[Il contatto ha inviato un messaggio vocale che non puoi ascoltare] a voice note

Not answering: the no_reply tool

Every AI request offers the model one tool, no_reply (no parameters): when the model calls it, nothing is sent and the status line shows AI: nessuna risposta necessaria. The app never says when to use it: describe the cases in SOUL.md, e.g. "se il messaggio non richiede risposta, chiama il tool no_reply".

Upgrading from older versions

  • system_prompt in config.toml is no longer read: move the prompt to SOUL.md.
  • cooldown_seconds and group_cooldown_seconds are no longer read: use reply_delay_seconds.

How the autoreply works

Enable it per chat with F3 or for all chats with F4 (private chats and groups; broadcasts and newsletters are never answered).

  • Wait and batch: the first unanswered message of a chat starts a reply_delay_seconds wait; messages arriving meanwhile join the same reply. Groups behave like private chats.
  • Context: the AI receives the chat messages since yesterday's local midnight (at most 200), loaded when the reply starts, so it sees everything received during the wait.
  • You answered by hand: if your own message is the last one when the reply starts, or arrives while the AI is generating, the AI reply is dropped (Risposta AI scartata: hai già risposto).
  • Messages during a reply: the chat stays busy until its reply is sent; messages that arrive meanwhile and were not part of it start one new wait afterwards.
  • After a restart: with catch_up_seconds > 0, the last unanswered recent message of each private chat is answered once, at once.
  • Voice notes: received voice notes and audio files are transcribed to text before the reply (see Voice notes).

Images

  • Each image sent by a contact is described once by openai_vision_model and the description is stored in history.db; replies never send images to the AI, only their descriptions, in every later context too.
  • Images are described just before the reply that covers them, all in parallel; at most the 5 most recent images per reply are described, older ones of the same batch stay without description by design.
  • Each download is retried up to 3 times (60 s timeout each); an image that still fails stays without description, the others are not affected; the status line shows Descrizione immagine fallita (N): ....
  • Images received while the app was off are described for the catch-up reply, or on their own if they arrive after it.
  • VISION.md, next to SOUL.md, replaces the built-in request used to describe images; when missing or blank the built-in request (objective, concise, in Italian) is used. The vision model receives only this request and the image, never the conversation: tone and reply style belong in SOUL.md, which can react to what the description contains.

Voice notes

  • Received voice notes and audio files are transcribed and the text replaces the message content as [messaggio vocale] <trascrizione>: it shows in the TUI and is part of the AI reply context.
  • Each audio is transcribed once; only live audio and the pre-restart backlog are transcribed, never the history sync.
  • Transcription runs only for chats with autoreply enabled, right before the reply.
  • Off by default. Requires transcribe_key for any OpenAI-compatible /audio/transcriptions endpoint; nothing to install locally. The default is Groq whisper-large-v3-turbo (free tier: no credit card, 2000 requests/day; works well for Italian). The audio is sent to that endpoint: with the feature on, voice notes leave your server.
  • Enabled only when transcribe_key is set, in config.toml:

    # transcribe_key = "gsk_..."
    # transcribe_url = "https://api.groq.com/openai/v1/audio/transcriptions"
    # transcribe_model = "whisper-large-v3-turbo"
    # transcribe_language = "auto"
    
  • Audio over 25 MiB is skipped.

  • Transcription errors show in the status line and the reply continues without the transcription.

Keys

Key Action
Tab focus list/input
Enter open chat / send
↑/↓, PgUp/PgDn, Home/End (list focused) move selection by one row, by 10 rows, to first/last row
F2 chats/contacts
typing (list focused) filter names; Backspace edits, Esc clears
Canc/Delete twice delete chat locally (history only, not on WhatsApp)
F3 AI autoreply for the selected chat (list focus) or the open chat (input focus)
F4 AI autoreply for all chats (persistent; footer shortcut turns green when on)
F8 twice (any screen) delete the WhatsApp session and pair again: unlinks this device, empties session.db and shows a new QR without restarting (Esc cancels)
F10 quit (the screen is cleared, also on terminals without an alternate screen)

Only function keys are used for commands, so they work inside GNU screen, tmux and over SSH.

Web control panel

  • Enabled by web_port in config.toml (e.g. web_port = 8080); open http://<host>:<port>/.
  • Listens on 0.0.0.0 without authentication: use it only on a trusted network (e.g. a VPN).
  • The page (HTML/CSS/JS) is embedded in the binary: no extra files to deploy.
  • Features: "AI tutte" switch (same as F4), per-chat AI toggle grid (private chats and groups only), "Nuovo QR" button (same as F8, with confirmation), "Chiudi bot" button to quit. While WhatsApp is not connected the page shows the connection progress or the pairing QR code, to scan from the phone. The page refreshes every 5 seconds (2 while not connected); changes show up in the TUI too.
  • API (for scripts): GET /api/state (JSON {global_autoreply, chats:[{jid,name,autoreply}], status, screen, qr_svg}, screen = connecting/pairing/main, qr_svg = QR code as SVG while pairing, else null); POST /api/global; POST /api/chat with the jid as plain-text body; POST /api/relink; POST /api/quit. Every POST requires the header X-Wh-Request: 1 (otherwise 403), which blocks cross-site requests from other pages.

Example:

curl -X POST -H "X-Wh-Request: 1" http://host:8080/api/global