# wh Terminal WhatsApp client (ratatui) with per-chat and global AI autoreply via an OpenAI-compatible API. ## Build ```sh 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: ```sh 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](#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](#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](#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] `: 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`: ```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://:/`. - 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: ```sh curl -X POST -H "X-Wh-Request: 1" http://host:8080/api/global ```