# 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, aws-lc-sys, ring). 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. ## 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 | ### 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`. The file is read only at startup: restart `wh` after editing it. 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. ### 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. ## 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 open chat | | `F4` | AI autoreply for all chats (persistent; footer shortcut turns green when on) | | `F10` | quit | 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), "Chiudi bot" button to quit. The page refreshes every 5 seconds; changes show up in the TUI too. - API (for scripts): `GET /api/state` (JSON `{global_autoreply, chats:[{jid,name,autoreply}], status}`); `POST /api/global`; `POST /api/chat` with the jid as plain-text body; `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 ```