No Description

softword e147fe6442 feat: update configuration management and introduce image description handling 1 day ago
openwiki f311c2a3d7 docs(openwiki): refresh wiki for web panel, function keys and global autoreply 2 days ago
src e147fe6442 feat: update configuration management and introduce image description handling 1 day ago
.gitignore cde0f55fa4 feat: add soul.md for system prompt configuration and update .gitignore 1 day ago
AGENTS.md 484f1487ed feat: add initial configuration template for OpenAI API and system prompts 2 days ago
Cargo.lock ec7ea4eb1d feat(web): add optional web control panel enabled by web_port 2 days ago
Cargo.toml ec7ea4eb1d feat(web): add optional web control panel enabled by web_port 2 days ago
README.md e147fe6442 feat: update configuration management and introduce image description handling 1 day ago
build.rs e147fe6442 feat: update configuration management and introduce image description handling 1 day ago
config.toml.global.template e147fe6442 feat: update configuration management and introduce image description handling 1 day ago
soul.md.template cde0f55fa4 feat: add soul.md for system prompt configuration and update .gitignore 1 day ago

README.md

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, 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:

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); 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://<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), "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:

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