Browse Source

feat: update configuration management and introduce image description handling

- Refactor configuration loading to read system prompt from SOUL.md and image description from vision.md.
- Remove legacy system prompt handling from config.toml.
- Introduce new constants and fields for reply delay and image description prompts.
- Implement database schema changes to store image descriptions associated with messages.
- Add methods to save and retrieve image descriptions in the history module.
- Update main application to display build version and handle command-line version flags.
- Clean up unused code related to addressed JIDs in WhatsApp message handling.
- Add build script to embed the short git commit hash for versioning.
softword 1 day ago
parent
commit
e147fe6442
11 changed files with 1362 additions and 965 deletions
  1. 59 8
      README.md
  2. 33 0
      build.rs
  3. 4 6
      config.toml.global.template
  4. 171 116
      src/ai.rs
  5. 558 216
      src/app.rs
  6. 258 444
      src/autoreply.rs
  7. 116 61
      src/config.rs
  8. 91 2
      src/history.rs
  9. 47 5
      src/main.rs
  10. 25 3
      src/ui.rs
  11. 0 104
      src/whatsapp.rs

+ 59 - 8
README.md

@@ -8,6 +8,8 @@ Terminal WhatsApp client (ratatui) with per-chat and global AI autoreply via an
 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).
@@ -32,39 +34,88 @@ Verify on Linux:
 
 ```sh
 file wh   # ELF 64-bit x86-64, statically linked
-chmod +x wh && ./wh
+chmod +x wh && ./wh --version
 ```
 
 ### Data directory
 
-On Linux, config and databases live in `~/.config/wh/` (`config.toml`, `session.db`, `history.db`); on Windows in `%APPDATA%\wh`.
+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` and `system_prompt`. A missing file means defaults; without `openai_api_key` and `system_prompt` the app runs as a plain WhatsApp client.
+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 | vision model for images; unset disables image understanding |
-| `system_prompt` | unset | responder identity (required for AI) |
-| `ai_log` | `false` | append every AI request to `ai_requests.log` |
-| `cooldown_seconds` | `30` | min seconds between autoreplies in a private chat / to a group message addressed to you (0 = off) |
-| `group_cooldown_seconds` | `300` | same, group messages not addressed to you (0 = off) |
+| `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) |

+ 33 - 0
build.rs

@@ -0,0 +1,33 @@
+//! Build script: embeds the short git commit hash of the build as `WH_GIT_HASH`.
+
+use std::process::Command;
+
+/// Name of the compile-time environment variable holding the commit hash.
+const GIT_HASH_ENV: &str = "WH_GIT_HASH";
+/// Value used when the hash cannot be read (no git, not a repository).
+const UNKNOWN_HASH: &str = "unknown";
+
+/// Purpose: export the short commit hash to the crate and rebuild when the commit changes.
+/// Inputs: none (runs `git` in the package directory).
+/// Output: none.
+/// Side effects: prints cargo directives on stdout.
+fn main() {
+    println!("cargo:rerun-if-changed=.git/HEAD");
+    println!("cargo:rerun-if-changed=.git/refs");
+    println!("cargo:rerun-if-changed=.git/packed-refs");
+    let hash = git_short_hash().unwrap_or_else(|| UNKNOWN_HASH.to_owned());
+    println!("cargo:rustc-env={GIT_HASH_ENV}={hash}");
+}
+
+/// Purpose: read the short hash of the checked-out commit.
+/// Inputs: none.
+/// Output: the hash, or `None` when git is missing, fails or prints nothing.
+/// Side effects: runs `git rev-parse --short HEAD`.
+fn git_short_hash() -> Option<String> {
+    let output = Command::new("git").args(["rev-parse", "--short", "HEAD"]).output().ok()?;
+    if !output.status.success() {
+        return None;
+    }
+    let hash = String::from_utf8(output.stdout).ok()?.trim().to_owned();
+    (!hash.is_empty()).then_some(hash)
+}

+ 4 - 6
config.toml.global.template

@@ -13,11 +13,9 @@ openai_vision_model = "deepseek-v4.1-flash"
 # Append every AI request to ai_requests.log in the data directory.
 ai_log = false
 
-# Seconds between autoreplies in a private chat or to a group message addressed to you (0 = no cooldown).
-cooldown_seconds = 30
-
-# Seconds between autoreplies to group messages not addressed to you (0 = no cooldown).
-group_cooldown_seconds = 300
+# Seconds the autoreply waits after the first unanswered message of a chat; messages received
+# meanwhile are answered in the same reply, and none is sent if you answered by hand (0 = at once).
+reply_delay_seconds = 30
 
 # After a restart, answer the last unanswered private message if at most this many seconds old (0 = disabled).
 catch_up_seconds = 7200
@@ -30,4 +28,4 @@ ai_retries = 2
 # web_port = 8080
 
 # The system prompt (required for the AI autoreply) is not set here:
-# write it in soul.md, next to this file (see soul.md.template).
+# write it in SOUL.md, next to this file (see soul.md.template).

+ 171 - 116
src/ai.rs

@@ -18,6 +18,9 @@ use crate::config::Config;
 pub const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
 const MISSING_VISION_MODEL_MESSAGE: &str = "modello vision non configurato";
 const EMPTY_IMAGE_MESSAGE: &str = "immagine vuota";
+const EMPTY_REPLY_MESSAGE: &str = "risposta del servizio AI vuota";
+/// Request sent with an image to obtain its description when `vision.md` is missing or blank.
+const DEFAULT_IMAGE_DESCRIPTION_REQUEST: &str = "Descrivi in modo oggettivo e conciso, in italiano, il contenuto di questa immagine: soggetti, azioni, testo leggibile e contesto. Rispondi solo con la descrizione.";
 const TEXT_PART_TYPE: &str = "text";
 const IMAGE_PART_TYPE: &str = "image_url";
 const REQUEST_TIMEOUT_SECONDS: u64 = 60;
@@ -36,6 +39,23 @@ const DATA_URL_BASE64_MARKER: &str = ";base64,";
 const DATA_URL_PREFIX: &str = "data:";
 /// Base wait, in seconds, between attempts: the wait before retry N is N times this value.
 const RETRY_BASE_DELAY_SECONDS: u64 = 2;
+/// Name of the tool the model calls when no message must be sent; when to call it is up to
+/// the system prompt (`SOUL.md`).
+const NO_REPLY_TOOL_NAME: &str = "no_reply";
+/// Neutral description of the no-reply tool: it says what the tool does, never when to use it.
+const NO_REPLY_TOOL_DESCRIPTION: &str =
+    "Non invia nessun messaggio nella chat: chiamalo al posto di scrivere una risposta.";
+/// Text logged in place of the reply when the model called the no-reply tool.
+const LOG_NO_REPLY_TEXT: &str = "(tool no_reply: nessun messaggio inviato)";
+
+/// Successful outcome of a completion.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Reply {
+    /// Text to send to the chat.
+    Text(String),
+    /// The model called the `no_reply` tool: nothing must be sent.
+    NoReply,
+}
 
 /// Author of a single conversation turn sent to the model.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -74,6 +94,8 @@ pub struct AiClient {
     api_key: String,
     model: String,
     vision_model: Option<String>,
+    /// Request sent with each image to describe it: `vision.md`, else `DEFAULT_IMAGE_DESCRIPTION_REQUEST`.
+    description_request: String,
     /// File receiving one record per request; `None` when `ai_log` is disabled.
     log_path: Option<PathBuf>,
     /// Number of retries after a failed attempt.
@@ -165,6 +187,10 @@ impl AiClient {
             api_key,
             model: config.openai_model.clone(),
             vision_model: config.openai_vision_model.clone(),
+            description_request: config
+                .image_description_prompt
+                .clone()
+                .unwrap_or_else(|| DEFAULT_IMAGE_DESCRIPTION_REQUEST.to_owned()),
             log_path: config.ai_log.then(|| config.ai_log_path()),
             retries: config.ai_retries,
         })
@@ -173,43 +199,40 @@ impl AiClient {
     /// Requests a completion for the given conversation.
     ///
     /// Inputs: `turns`, the ordered conversation (system prompt included).
-    /// Output: the first choice's message content, trimmed; an error on
-    /// transport failure, non-2xx status (status code and truncated body, never
-    /// the key), malformed JSON or empty reply.
+    /// Output: the reply (see `parse_reply`); an error on transport failure,
+    /// non-2xx status (status code and truncated body, never the key), malformed
+    /// JSON or empty reply.
     /// Side effects: performs up to `1 + retries` HTTPS POST requests.
-    pub async fn complete(&self, turns: &[ChatTurn]) -> anyhow::Result<String> {
+    pub async fn complete(&self, turns: &[ChatTurn]) -> anyhow::Result<Reply> {
         self.send_with_retry(&build_request_body(&self.model, turns)).await
     }
 
     /// Tells whether a vision model is configured.
     ///
-    /// Inputs: the client. Output: `true` if `complete_with_image` can be used.
+    /// Inputs: the client. Output: `true` if `describe_image` can be used.
     /// Side effects: none.
     pub fn has_vision(&self) -> bool {
         self.vision_model.is_some()
     }
 
-    /// Requests a completion for the conversation with an attached image.
+    /// Asks the vision model for a description of one image, stored and used in every context.
     ///
-    /// Inputs: `turns`, the ordered conversation; `image`, attached to the last
-    /// user turn (or to a new user turn if there is none).
-    /// Output: the reply text; an error if no vision model is configured, the
-    /// image is empty or larger than `MAX_IMAGE_BYTES`, or on any failure
-    /// described in `complete`. Errors never contain the image data.
-    /// Side effects: performs up to `1 + retries` HTTPS POST requests.
-    pub async fn complete_with_image(
-        &self,
-        turns: &[ChatTurn],
-        image: &ImageInput,
-    ) -> anyhow::Result<String> {
+    /// Inputs: `image`, the image to describe.
+    /// Output: the description text; an error if no vision model is configured,
+    /// the image is invalid (see `validate_image`) or on any failure described in
+    /// `complete`. Errors never contain the image data.
+    /// Side effects: performs up to `1 + retries` HTTPS POST requests (no tools offered).
+    pub async fn describe_image(&self, image: &ImageInput) -> anyhow::Result<String> {
         let model = self
             .vision_model
             .as_deref()
             .ok_or_else(|| anyhow!(MISSING_VISION_MODEL_MESSAGE))?;
         validate_image(image)?;
 
-        self.send_with_retry(&build_vision_request_body(model, turns, image))
-            .await
+        match self.send_with_retry(&build_description_body(model, &self.description_request, image)).await? {
+            Reply::Text(description) => Ok(description),
+            Reply::NoReply => bail!(EMPTY_REPLY_MESSAGE),
+        }
     }
 
     /// Sends a prepared request body, retrying transient failures.
@@ -219,7 +242,7 @@ impl AiClient {
     /// last attempt (returned immediately when not retryable or retries are exhausted).
     /// Side effects: performs up to `1 + retries` HTTPS POST requests (each one
     /// logged when the AI log is enabled); sleeps `retry_delay` before each retry.
-    async fn send_with_retry(&self, request_body: &Value) -> anyhow::Result<String> {
+    async fn send_with_retry(&self, request_body: &Value) -> anyhow::Result<Reply> {
         let mut attempt = 0;
 
         loop {
@@ -239,13 +262,13 @@ impl AiClient {
     /// log is enabled, records the request together with its outcome.
     ///
     /// Inputs: `request_body`, the JSON payload for the chat completions endpoint.
-    /// Output: the first choice's message content, trimmed; an error on
-    /// transport failure, non-2xx status (status code and truncated body, never
-    /// the key), malformed JSON or empty reply.
+    /// Output: the reply (see `parse_reply`); an error on transport failure,
+    /// non-2xx status (status code and truncated body, never the key), malformed
+    /// JSON or empty reply.
     /// Side effects: performs one HTTPS POST request; appends one record to the
     /// log file if enabled. Log write failures are deliberately ignored: logging
     /// must never make a reply fail, and the TUI must not be written over.
-    async fn send_body(&self, request_body: &Value) -> anyhow::Result<String> {
+    async fn send_body(&self, request_body: &Value) -> anyhow::Result<Reply> {
         let outcome = self.request_reply(request_body).await;
 
         if let Some(path) = &self.log_path {
@@ -256,13 +279,13 @@ impl AiClient {
         outcome
     }
 
-    /// Performs the HTTPS POST and extracts the reply text.
+    /// Performs the HTTPS POST and extracts the reply.
     ///
     /// Inputs: `request_body`, the JSON payload for the chat completions endpoint.
-    /// Output: the first choice's message content, trimmed; an error on
+    /// Output: the reply (see `parse_reply`); an error on
     /// transport failure, non-2xx status, malformed JSON or empty reply.
     /// Side effects: performs one HTTPS POST request.
-    async fn request_reply(&self, request_body: &Value) -> anyhow::Result<String> {
+    async fn request_reply(&self, request_body: &Value) -> anyhow::Result<Reply> {
         let response = self
             .http
             .post(&self.endpoint)
@@ -320,10 +343,26 @@ fn retry_delay(attempt: u32) -> Duration {
 /// Builds the JSON request body for the chat completions endpoint.
 ///
 /// Inputs: `model` name and the conversation `turns`.
-/// Output: `{"model": ..., "messages": [{"role": ..., "content": ...}]}`.
+/// Output: `{"model": ..., "messages": [{"role": ..., "content": ...}], "tools": [no_reply]}`.
 /// Side effects: none.
 fn build_request_body(model: &str, turns: &[ChatTurn]) -> Value {
-    json!({ "model": model, "messages": build_messages(turns) })
+    json!({ "model": model, "messages": build_messages(turns), "tools": build_tools() })
+}
+
+/// Builds the tool list offered to the model: only the parameterless `no_reply` function.
+///
+/// Inputs: none.
+/// Output: `[{"type":"function","function":{"name":"no_reply","description":...,"parameters":{...}}}]`.
+/// Side effects: none.
+fn build_tools() -> Value {
+    json!([{
+        "type": "function",
+        "function": {
+            "name": NO_REPLY_TOOL_NAME,
+            "description": NO_REPLY_TOOL_DESCRIPTION,
+            "parameters": { "type": "object", "properties": {} },
+        },
+    }])
 }
 
 /// Converts conversation turns into wire-format messages.
@@ -338,29 +377,18 @@ fn build_messages(turns: &[ChatTurn]) -> Vec<Value> {
         .collect()
 }
 
-/// Builds the request body for a vision completion.
+/// Builds the request body asking for the description of one image.
 ///
-/// Inputs: the vision `model` name, the conversation `turns` and the `image`.
-/// Output: like `build_request_body`, but the last user turn's content becomes
-/// `[text part, image_url part]`; with no user turn, a user message holding
-/// only the image part is appended.
+/// Inputs: the vision `model` name, the `request` text and the `image`.
+/// Output: `{"model": ..., "messages": [{"role": "user", "content": [request text, image part]}]}`,
+/// with no tools.
 /// Side effects: none.
-fn build_vision_request_body(model: &str, turns: &[ChatTurn], image: &ImageInput) -> Value {
-    let mut messages = build_messages(turns);
-    let image_part = build_image_part(image);
-
-    match turns.iter().rposition(|turn| turn.role == Role::User) {
-        Some(index) => {
-            let text_part = json!({ "type": TEXT_PART_TYPE, "text": turns[index].content });
-            messages[index]["content"] = json!([text_part, image_part]);
-        }
-        None => messages.push(json!({
-            "role": Role::User.as_wire_str(),
-            "content": [image_part],
-        })),
-    }
-
-    json!({ "model": model, "messages": messages })
+fn build_description_body(model: &str, request: &str, image: &ImageInput) -> Value {
+    let text_part = json!({ "type": TEXT_PART_TYPE, "text": request });
+    json!({
+        "model": model,
+        "messages": [{ "role": Role::User.as_wire_str(), "content": [text_part, build_image_part(image)] }],
+    })
 }
 
 /// Builds the `image_url` content part carrying the image as a data URL.
@@ -396,18 +424,23 @@ fn validate_image(image: &ImageInput) -> anyhow::Result<()> {
     Ok(())
 }
 
-/// Extracts the reply text from a chat completions response body.
+/// Extracts the reply from a chat completions response body.
 ///
 /// Inputs: the parsed JSON `body`.
-/// Output: the first choice's message content, trimmed; an error if there are
-/// no choices, the content is missing, or it is empty after trimming.
+/// Output: `Reply::NoReply` if the first choice calls the `no_reply` tool (any
+/// text next to the call is ignored); otherwise `Reply::Text` with the first
+/// choice's message content, trimmed; an error if there are no choices, the
+/// content is missing, or it is empty after trimming.
 /// Side effects: none.
-fn parse_reply(body: &Value) -> anyhow::Result<String> {
+fn parse_reply(body: &Value) -> anyhow::Result<Reply> {
     let first_choice = body
         .get("choices")
         .and_then(Value::as_array)
         .and_then(|choices| choices.first())
         .ok_or_else(|| anyhow!("risposta del servizio AI senza choices"))?;
+    if calls_no_reply(first_choice) {
+        return Ok(Reply::NoReply);
+    }
     let content = first_choice
         .pointer("/message/content")
         .and_then(Value::as_str)
@@ -415,10 +448,26 @@ fn parse_reply(body: &Value) -> anyhow::Result<String> {
         .unwrap_or_default();
 
     if content.is_empty() {
-        bail!("risposta del servizio AI vuota");
+        bail!(EMPTY_REPLY_MESSAGE);
     }
 
-    Ok(content.to_string())
+    Ok(Reply::Text(content.to_string()))
+}
+
+/// Tells whether a response choice calls the `no_reply` tool.
+///
+/// Inputs: `choice`, one element of the response `choices`.
+/// Output: `true` if any of its `message.tool_calls` is a function call named `no_reply`.
+/// Side effects: none.
+fn calls_no_reply(choice: &Value) -> bool {
+    choice
+        .pointer("/message/tool_calls")
+        .and_then(Value::as_array)
+        .is_some_and(|calls| {
+            calls.iter().any(|call| {
+                call.pointer("/function/name").and_then(Value::as_str) == Some(NO_REPLY_TOOL_NAME)
+            })
+        })
 }
 
 /// Replaces a base64 data URL with a placeholder that omits the payload.
@@ -481,13 +530,14 @@ fn format_log_record(
     timestamp: &str,
     endpoint: &str,
     body: &Value,
-    outcome: &anyhow::Result<String>,
+    outcome: &anyhow::Result<Reply>,
 ) -> String {
     let model = body.get("model").and_then(Value::as_str).unwrap_or_default();
     let redacted = redact_images(body);
     let pretty = serde_json::to_string_pretty(&redacted).unwrap_or_else(|_| redacted.to_string());
     let (label, text) = match outcome {
-        Ok(reply) => (LOG_REPLY_LABEL, reply.clone()),
+        Ok(Reply::Text(reply)) => (LOG_REPLY_LABEL, reply.clone()),
+        Ok(Reply::NoReply) => (LOG_REPLY_LABEL, LOG_NO_REPLY_TEXT.to_owned()),
         Err(error) => (LOG_ERROR_LABEL, format!("{error:#}")),
     };
 
@@ -539,9 +589,9 @@ mod tests {
             openai_base_url: "https://example.test/v1".to_string(),
             openai_model: TEST_MODEL.to_string(),
             system_prompt: Some("prompt".to_string()),
+            image_description_prompt: None,
             ai_log: false,
-            cooldown_seconds: 30,
-            group_cooldown_seconds: 300,
+            reply_delay_seconds: crate::config::DEFAULT_REPLY_DELAY_SECONDS,
             catch_up_seconds: 7200,
             data_dir: PathBuf::from("data"),
             openai_vision_model: None,
@@ -580,78 +630,51 @@ mod tests {
     }
 
     #[test]
-    fn vision_body_replaces_last_user_turn_with_text_and_image_parts() {
-        let turns = [
-            ChatTurn { role: Role::System, content: "be nice".into() },
-            ChatTurn { role: Role::User, content: "first".into() },
-            ChatTurn { role: Role::Assistant, content: "ok".into() },
-            ChatTurn { role: Role::User, content: "look".into() },
-        ];
-        let image = test_image(vec![1, 2, 3]);
-
-        let body = build_vision_request_body("vision-model", &turns, &image);
+    fn description_body_sends_request_text_and_image_without_tools() {
+        let body = build_description_body("vision-model", DEFAULT_IMAGE_DESCRIPTION_REQUEST, &test_image(vec![1, 2, 3]));
 
         assert_eq!(
             body,
             json!({
                 "model": "vision-model",
-                "messages": [
-                    {"role": "system", "content": "be nice"},
-                    {"role": "user", "content": "first"},
-                    {"role": "assistant", "content": "ok"},
-                    {"role": "user", "content": [
-                        {"type": "text", "text": "look"},
-                        {"type": "image_url", "image_url": {"url": "data:image/png;base64,AQID"}},
-                    ]},
-                ]
+                "messages": [{"role": "user", "content": [
+                    {"type": "text", "text": DEFAULT_IMAGE_DESCRIPTION_REQUEST},
+                    {"type": "image_url", "image_url": {"url": "data:image/png;base64,AQID"}},
+                ]}],
             })
         );
     }
 
     #[test]
-    fn vision_body_appends_image_only_user_turn_when_none_present() {
-        let turns = [ChatTurn { role: Role::System, content: "be nice".into() }];
-
-        let body = build_vision_request_body("vision-model", &turns, &test_image(vec![1, 2, 3]));
+    fn description_request_comes_from_vision_md_or_falls_back_to_default() {
+        let default_client = AiClient::new(&test_config(Some(TEST_API_KEY))).unwrap();
+        let mut config = test_config(Some(TEST_API_KEY));
+        config.image_description_prompt = Some("Describe in English.\n".to_string());
+        let custom_client = AiClient::new(&config).unwrap();
 
-        assert_eq!(
-            body["messages"][1],
-            json!({"role": "user", "content": [
-                {"type": "image_url", "image_url": {"url": "data:image/png;base64,AQID"}},
-            ]})
-        );
-        assert_eq!(body["messages"].as_array().unwrap().len(), 2);
+        assert_eq!(default_client.description_request, DEFAULT_IMAGE_DESCRIPTION_REQUEST);
+        assert_eq!(custom_client.description_request, "Describe in English.\n");
     }
 
     #[test]
-    fn complete_with_image_fails_without_vision_model() {
-        let client = test_client(None);
-
-        let error = poll_ready(client.complete_with_image(&[], &test_image(vec![1]))).unwrap_err();
+    fn describe_image_fails_without_vision_model_or_on_empty_image() {
+        let without_vision = poll_ready(test_client(None).describe_image(&test_image(vec![1]))).unwrap_err();
+        let empty = poll_ready(test_client(Some("vision-model")).describe_image(&test_image(Vec::new()))).unwrap_err();
 
-        assert_eq!(error.to_string(), MISSING_VISION_MODEL_MESSAGE);
+        assert_eq!(without_vision.to_string(), MISSING_VISION_MODEL_MESSAGE);
+        assert_eq!(empty.to_string(), EMPTY_IMAGE_MESSAGE);
     }
 
     #[test]
-    fn complete_with_image_fails_on_oversize_image_without_leaking_data() {
+    fn describe_image_fails_on_oversize_image_without_leaking_data() {
         let client = test_client(Some("vision-model"));
-        let image = test_image(vec![7; MAX_IMAGE_BYTES + 1]);
 
-        let error = poll_ready(client.complete_with_image(&[], &image)).unwrap_err();
+        let error = poll_ready(client.describe_image(&test_image(vec![7; MAX_IMAGE_BYTES + 1]))).unwrap_err();
 
         assert!(error.to_string().contains(&(MAX_IMAGE_BYTES + 1).to_string()));
         assert!(!error.to_string().contains("base64"));
     }
 
-    #[test]
-    fn complete_with_image_fails_on_empty_image() {
-        let client = test_client(Some("vision-model"));
-
-        let error = poll_ready(client.complete_with_image(&[], &test_image(Vec::new()))).unwrap_err();
-
-        assert_eq!(error.to_string(), EMPTY_IMAGE_MESSAGE);
-    }
-
     #[test]
     fn validate_image_accepts_exactly_max_size() {
         assert!(validate_image(&test_image(vec![0; MAX_IMAGE_BYTES])).is_ok());
@@ -692,11 +715,22 @@ mod tests {
                     {"role": "system", "content": "be nice"},
                     {"role": "user", "content": "hi"},
                     {"role": "assistant", "content": "hello"},
-                ]
+                ],
+                "tools": build_tools(),
             })
         );
     }
 
+    #[test]
+    fn tools_offer_only_the_parameterless_no_reply_function() {
+        let tools = build_tools();
+
+        assert_eq!(tools.as_array().unwrap().len(), 1);
+        assert_eq!(tools[0]["type"], "function");
+        assert_eq!(tools[0]["function"]["name"], NO_REPLY_TOOL_NAME);
+        assert_eq!(tools[0]["function"]["parameters"], json!({"type": "object", "properties": {}}));
+    }
+
     #[test]
     fn parse_reply_returns_trimmed_first_choice() {
         let body = json!({"choices": [
@@ -704,7 +738,27 @@ mod tests {
             {"message": {"content": "other"}},
         ]});
 
-        assert_eq!(parse_reply(&body).unwrap(), "ciao");
+        assert_eq!(parse_reply(&body).unwrap(), Reply::Text("ciao".into()));
+    }
+
+    /// Builds a response whose first choice calls the tool `name` next to `content`.
+    fn tool_call_response(name: &str, content: Value) -> Value {
+        json!({"choices": [{"message": {
+            "content": content,
+            "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": name, "arguments": "{}"}}],
+        }}]})
+    }
+
+    #[test]
+    fn parse_reply_detects_no_reply_tool_call_even_with_text() {
+        assert_eq!(parse_reply(&tool_call_response(NO_REPLY_TOOL_NAME, Value::Null)).unwrap(), Reply::NoReply);
+        assert_eq!(parse_reply(&tool_call_response(NO_REPLY_TOOL_NAME, json!("ok"))).unwrap(), Reply::NoReply);
+    }
+
+    #[test]
+    fn parse_reply_ignores_other_tool_calls() {
+        assert_eq!(parse_reply(&tool_call_response("other", json!("ciao"))).unwrap(), Reply::Text("ciao".into()));
+        assert!(parse_reply(&tool_call_response("other", Value::Null)).is_err());
     }
 
     #[test]
@@ -737,15 +791,14 @@ mod tests {
 
     #[test]
     fn redact_images_replaces_base64_and_keeps_text() {
-        let turns = [ChatTurn { role: Role::User, content: "look".into() }];
-        let body = build_vision_request_body("vision-model", &turns, &test_image(vec![1, 2, 3]));
+        let body = build_description_body("vision-model", DEFAULT_IMAGE_DESCRIPTION_REQUEST, &test_image(vec![1, 2, 3]));
 
         let redacted = redact_images(&body);
 
         assert_eq!(
             redacted["messages"][0]["content"],
             json!([
-                {"type": "text", "text": "look"},
+                {"type": "text", "text": DEFAULT_IMAGE_DESCRIPTION_REQUEST},
                 {"type": "image_url", "image_url": {"url": "data:image/png;base64,<omessi 4 byte>"}},
             ])
         );
@@ -769,21 +822,23 @@ mod tests {
         let turns = [ChatTurn { role: Role::User, content: "ciao".into() }];
         let body = build_request_body(TEST_MODEL, &turns);
 
-        let ok = format_log_record("T0", "https://example.test/v1/chat/completions", &body, &Ok("risposta".into()));
+        let reply = Ok(Reply::Text("risposta".into()));
+        let ok = format_log_record("T0", "https://example.test/v1/chat/completions", &body, &reply);
+        let silent = format_log_record("T2", "ep", &body, &Ok(Reply::NoReply));
         let failed = format_log_record("T1", "ep", &body, &Err(anyhow!("servizio AI ha risposto 500")));
 
         assert!(ok.contains(TEST_MODEL) && ok.contains("ciao") && ok.contains("T0"));
         assert!(ok.contains(LOG_REPLY_LABEL) && ok.contains("risposta"));
+        assert!(silent.contains(LOG_REPLY_LABEL) && silent.contains(LOG_NO_REPLY_TEXT));
         assert!(failed.contains(LOG_ERROR_LABEL) && failed.contains("servizio AI ha risposto 500"));
         assert!(!ok.contains(TEST_API_KEY) && !failed.contains(TEST_API_KEY));
     }
 
     #[test]
     fn log_record_never_contains_image_base64() {
-        let turns = [ChatTurn { role: Role::User, content: "look".into() }];
-        let body = build_vision_request_body("vision-model", &turns, &test_image(vec![1, 2, 3]));
+        let body = build_description_body("vision-model", DEFAULT_IMAGE_DESCRIPTION_REQUEST, &test_image(vec![1, 2, 3]));
 
-        let record = format_log_record("T", "ep", &body, &Ok("ok".into()));
+        let record = format_log_record("T", "ep", &body, &Ok(Reply::Text("ok".into())));
 
         assert!(!record.contains("AQID") && record.contains("<omessi 4 byte>"));
     }

File diff suppressed because it is too large
+ 558 - 216
src/app.rs


File diff suppressed because it is too large
+ 258 - 444
src/autoreply.rs


+ 116 - 61
src/config.rs

@@ -2,15 +2,13 @@
 //!
 //! All settings come from the TOML file `config.toml` inside the data directory
 //! (`<platform config dir>/wh`, i.e. `%APPDATA%\wh` on Windows), falling back to built-in
-//! defaults for missing or blank keys (except `system_prompt`, which has no built-in
-//! default). No environment variable is ever read.
+//! defaults for missing or blank keys. No environment variable is ever read.
 //! Supported file keys: `openai_api_key`, `openai_base_url`, `openai_model`,
-//! `openai_vision_model`, `system_prompt`, `ai_log` (boolean, default `false`: when `true`
+//! `openai_vision_model`, `ai_log` (boolean, default `false`: when `true`
 //! every request sent to the AI model is appended to `ai_requests.log` in the data
-//! directory), `cooldown_seconds` (non-negative integer, default 30: minimum seconds between
-//! autoreplies in a private chat or to a group message addressed to the user),
-//! `group_cooldown_seconds` (non-negative integer, default 300: same, for group messages not
-//! addressed to the user); 0 disables the respective cooldown, `catch_up_seconds`
+//! directory), `reply_delay_seconds` (non-negative integer, default 30: seconds the autoreply
+//! waits after the first unanswered message of a chat before answering, in one reply, every
+//! message received meanwhile; 0 answers at once), `catch_up_seconds`
 //! (non-negative integer, default 7200: after a restart, the last unanswered message of each
 //! private chat is answered once if it is at most this old; 0 disables the catch-up),
 //! `ai_retries` (non-negative integer, default 2: how many times a failed AI request is
@@ -21,8 +19,10 @@
 //! all defaults; an unreadable or invalid file is an error. The data directory is fixed and
 //! cannot be changed from the file.
 //!
-//! The prompt defining the responder's identity lives only in `config.toml` (`system_prompt`);
-//! the binary embeds no prompt text. Without it the AI is not configured.
+//! The prompt defining the responder's identity lives only in `SOUL.md`, next to
+//! `config.toml`, read as written; the binary embeds no prompt text. A missing or blank
+//! `SOUL.md` means the AI is not configured; an unreadable one is an error. The optional
+//! `vision.md`, next to it, replaces the built-in request used to describe images.
 
 use std::fmt;
 use std::io::ErrorKind;
@@ -35,10 +35,8 @@ use serde::Deserialize;
 const DEFAULT_BASE_URL: &str = "https://ollama.com/v1";
 /// Default model used for autoreplies.
 pub const DEFAULT_MODEL: &str = "deepseek-v4.1-flash";
-/// Default cooldown, in seconds, for private chats and group messages addressed to the user.
-pub const DEFAULT_COOLDOWN_SECONDS: i64 = 30;
-/// Default cooldown, in seconds, for group messages not addressed to the user.
-pub const DEFAULT_GROUP_COOLDOWN_SECONDS: i64 = 300;
+/// Default wait, in seconds, between the first unanswered message of a chat and the autoreply.
+pub const DEFAULT_REPLY_DELAY_SECONDS: u64 = 30;
 /// Default maximum age, in seconds, of a message answered by the catch-up after a restart.
 pub const DEFAULT_CATCH_UP_SECONDS: i64 = 7200;
 /// Default number of retries of a failed AI request (0 disables retrying).
@@ -54,6 +52,10 @@ const HISTORY_DB_FILE: &str = "history.db";
 const AI_LOG_FILE: &str = "ai_requests.log";
 /// File name of the optional TOML configuration file inside the data directory.
 const CONFIG_FILE: &str = "config.toml";
+/// File name of the system prompt (the responder's identity) inside the data directory.
+const SOUL_FILE: &str = "SOUL.md";
+/// File name of the optional request used to describe images, inside the data directory.
+const VISION_FILE: &str = "vision.md";
 /// Name of the file key holding the base URL, used in validation messages.
 const KEY_BASE_URL: &str = "openai_base_url";
 /// Name of the file key holding the web control panel port, used in validation messages.
@@ -78,17 +80,17 @@ pub struct Config {
     /// Vision model name from `openai_vision_model` (file), trimmed; `None` if unset or
     /// blank, which disables image understanding.
     pub openai_vision_model: Option<String>,
-    /// System prompt from `system_prompt` (file), as written (not trimmed); `None` if unset
+    /// System prompt from `SOUL.md`, as written (not trimmed); `None` if the file is missing
     /// or blank, meaning the AI is not configured. There is no built-in default.
     pub system_prompt: Option<String>,
+    /// Request sent with each image to obtain its description, from `vision.md`, as written;
+    /// `None` if the file is missing or blank, meaning the built-in request is used.
+    pub image_description_prompt: Option<String>,
     /// Whether to log every request sent to the AI model, from `ai_log` (file); default `false`.
     pub ai_log: bool,
-    /// Autoreply cooldown in seconds for private chats and addressed group messages, from
-    /// `cooldown_seconds` (file); default 30, 0 means no cooldown.
-    pub cooldown_seconds: i64,
-    /// Autoreply cooldown in seconds for group messages not addressed to the user, from
-    /// `group_cooldown_seconds` (file); default 300, 0 means no cooldown.
-    pub group_cooldown_seconds: i64,
+    /// Seconds the autoreply waits after the first unanswered message of a chat, from
+    /// `reply_delay_seconds` (file); default 30, 0 means no wait.
+    pub reply_delay_seconds: u64,
     /// Maximum age in seconds of a message answered by the catch-up after a restart, from
     /// `catch_up_seconds` (file); default 7200, 0 disables the catch-up.
     pub catch_up_seconds: i64,
@@ -114,14 +116,10 @@ struct FileConfig {
     openai_model: Option<String>,
     /// Vision model name (unset or blank disables image understanding).
     openai_vision_model: Option<String>,
-    /// System prompt for autoreplies.
-    system_prompt: Option<String>,
     /// Enables the AI request log (unset means disabled).
     ai_log: Option<bool>,
-    /// Cooldown for private chats and addressed group messages (unsigned: negatives are rejected).
-    cooldown_seconds: Option<u32>,
-    /// Cooldown for group messages not addressed to the user (unsigned: negatives are rejected).
-    group_cooldown_seconds: Option<u32>,
+    /// Wait before the autoreply (unsigned: negatives are rejected).
+    reply_delay_seconds: Option<u32>,
     /// Catch-up window after a restart (unsigned: negatives are rejected).
     catch_up_seconds: Option<u32>,
     /// Retries of a failed AI request (unsigned: negatives are rejected).
@@ -148,9 +146,9 @@ impl fmt::Debug for Config {
             .field("openai_model", &self.openai_model)
             .field("openai_vision_model", &self.openai_vision_model)
             .field("system_prompt", &self.system_prompt)
+            .field("image_description_prompt", &self.image_description_prompt)
             .field("ai_log", &self.ai_log)
-            .field("cooldown_seconds", &self.cooldown_seconds)
-            .field("group_cooldown_seconds", &self.group_cooldown_seconds)
+            .field("reply_delay_seconds", &self.reply_delay_seconds)
             .field("catch_up_seconds", &self.catch_up_seconds)
             .field("ai_retries", &self.ai_retries)
             .field("web_port", &self.web_port)
@@ -211,6 +209,19 @@ fn read_config_file(path: &Path) -> Result<FileConfig> {
         .with_context(|| format!("invalid config file {}", path.display()))
 }
 
+/// Purpose: load a prompt file (`SOUL.md` or `vision.md`) at `path`.
+/// Inputs: `path`, the location of the file.
+/// Output: the content as written (a leading UTF-8 BOM removed); `None` if the file does not
+/// exist or is blank; error naming the path if unreadable or not UTF-8.
+/// Side effects: reads the file from disk.
+fn read_prompt_file(path: &Path) -> Result<Option<String>> {
+    match std::fs::read_to_string(path) {
+        Ok(content) => Ok(non_blank(Some(content.trim_start_matches(UTF8_BOM).to_owned()))),
+        Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
+        Err(e) => Err(e).with_context(|| format!("cannot read prompt file {}", path.display())),
+    }
+}
+
 /// Purpose: discard blank (empty or whitespace-only) values.
 /// Inputs: `value`, an optional raw file value.
 /// Output: `value` unchanged if it contains non-whitespace characters, else `None`.
@@ -252,19 +263,27 @@ fn resolve_web_port(raw: Option<u16>) -> Result<Option<u16>> {
 }
 
 impl Config {
-    /// Purpose: load the configuration from `<data_dir>/config.toml` and built-in defaults.
+    /// Purpose: load the configuration from `<data_dir>/config.toml`, the system prompt from
+    /// `<data_dir>/SOUL.md` and built-in defaults.
     /// Inputs: none (the data dir is `<platform config dir>/wh`).
-    /// Output: validated `Config`, or an error if the config dir is unavailable, the file is unreadable/invalid, or validation fails.
-    /// Side effects: reads the config file from disk; never reads environment variables.
+    /// Output: validated `Config`, or an error if the config dir is unavailable, a file is unreadable/invalid, or validation fails.
+    /// Side effects: reads both files from disk; never reads environment variables.
     pub fn load() -> Result<Config> {
         let data_dir = default_data_dir()?;
         let file = read_config_file(&data_dir.join(CONFIG_FILE))?;
-        Self::from_file(file, data_dir)
+        let system_prompt = read_prompt_file(&data_dir.join(SOUL_FILE))?;
+        let image_description_prompt = read_prompt_file(&data_dir.join(VISION_FILE))?;
+        Ok(Config {
+            system_prompt,
+            image_description_prompt,
+            ..Self::from_file(file, data_dir)?
+        })
     }
 
     /// Purpose: build a validated configuration from parsed file settings.
     /// Inputs: `file`, the parsed settings; `data_dir`, the directory to record in the config.
-    /// Output: validated `Config` with defaults for missing/blank keys, or an error for an invalid base URL.
+    /// Output: validated `Config` with defaults for missing/blank keys and no system prompt
+    /// (it comes from `SOUL.md`), or an error for an invalid base URL.
     /// Side effects: none.
     fn from_file(file: FileConfig, data_dir: PathBuf) -> Result<Config> {
         Ok(Config {
@@ -273,12 +292,12 @@ impl Config {
             openai_model: trimmed_non_blank(file.openai_model)
                 .unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
             openai_vision_model: trimmed_non_blank(file.openai_vision_model),
-            system_prompt: non_blank(file.system_prompt),
+            system_prompt: None,
+            image_description_prompt: None,
             ai_log: file.ai_log.unwrap_or_default(),
-            cooldown_seconds: file.cooldown_seconds.map_or(DEFAULT_COOLDOWN_SECONDS, i64::from),
-            group_cooldown_seconds: file
-                .group_cooldown_seconds
-                .map_or(DEFAULT_GROUP_COOLDOWN_SECONDS, i64::from),
+            reply_delay_seconds: file
+                .reply_delay_seconds
+                .map_or(DEFAULT_REPLY_DELAY_SECONDS, u64::from),
             catch_up_seconds: file.catch_up_seconds.map_or(DEFAULT_CATCH_UP_SECONDS, i64::from),
             ai_retries: file.ai_retries.unwrap_or(DEFAULT_AI_RETRIES),
             web_port: resolve_web_port(file.web_port)?,
@@ -370,34 +389,68 @@ mod tests {
     fn full_file_values_are_used() {
         let cfg = ok_config(
             "openai_api_key = \"sk-file\"\nopenai_base_url = \"http://file/v1\"\n\
-             openai_model = \"file-model\"\nopenai_vision_model = \"file-vision\"\n\
-             system_prompt = \"file prompt\"\n",
+             openai_model = \"file-model\"\nopenai_vision_model = \"file-vision\"\n",
         );
         assert_eq!(cfg.openai_api_key.as_deref(), Some("sk-file"));
         assert_eq!(cfg.openai_base_url, "http://file/v1");
         assert_eq!(cfg.openai_model, "file-model");
         assert_eq!(cfg.openai_vision_model.as_deref(), Some("file-vision"));
-        assert_eq!(cfg.system_prompt.as_deref(), Some("file prompt"));
+    }
+
+    #[test]
+    fn system_prompt_key_in_config_is_ignored() {
+        let cfg = ok_config("system_prompt = \"legacy prompt\"\n");
+        assert_eq!(cfg.system_prompt, None);
     }
 
     #[test]
     fn blank_values_fall_back_to_defaults_or_none() {
         let cfg = ok_config(
             "openai_api_key = \"   \"\nopenai_base_url = \"  \"\nopenai_model = \"\"\n\
-             openai_vision_model = \" \\t \"\nsystem_prompt = \"  \"\n",
+             openai_vision_model = \" \\t \"\n",
         );
         assert_eq!(cfg.openai_api_key, None);
         assert_eq!(cfg.openai_base_url, DEFAULT_BASE_URL);
         assert_eq!(cfg.openai_model, DEFAULT_MODEL);
         assert_eq!(cfg.openai_vision_model, None);
-        assert_eq!(cfg.system_prompt, None);
     }
 
     #[test]
-    fn multiline_system_prompt_is_preserved() {
-        const PROMPT: &str = "Line one.\n  Line two, indented.\nLine three.\n";
-        let cfg = ok_config(&format!("system_prompt = \"\"\"\n{PROMPT}\"\"\"\n"));
-        assert_eq!(cfg.system_prompt.as_deref(), Some(PROMPT));
+    fn missing_soul_file_yields_no_prompt() {
+        let path = scratch_path("soul-missing");
+        assert_eq!(read_prompt_file(&path).expect("missing file is not an error"), None);
+    }
+
+    #[test]
+    fn soul_file_is_read_as_written_without_bom() {
+        const PROMPT: &str = "# Identity\r\n  Line two, indented.\nLine three.\n";
+        let path = scratch_path("soul-multiline");
+        std::fs::write(&path, format!("{UTF8_BOM}{PROMPT}")).expect("temp file must be writable");
+        let result = read_prompt_file(&path);
+        std::fs::remove_file(&path).expect("temp file must be removable");
+        assert_eq!(result.expect("file must be read").as_deref(), Some(PROMPT));
+    }
+
+    #[test]
+    fn blank_soul_file_yields_no_prompt() {
+        let path = scratch_path("soul-blank");
+        std::fs::write(&path, " \r\n\t\n").expect("temp file must be writable");
+        let result = read_prompt_file(&path);
+        std::fs::remove_file(&path).expect("temp file must be removable");
+        assert_eq!(result.expect("file must be read"), None);
+    }
+
+    #[test]
+    fn unreadable_soul_file_error_names_path() {
+        const INVALID_UTF8: [u8; 3] = [0xff, 0xfe, 0xfd];
+        let path = scratch_path("soul-utf8");
+        std::fs::write(&path, INVALID_UTF8).expect("temp file must be writable");
+        let result = read_prompt_file(&path);
+        std::fs::remove_file(&path).expect("temp file must be removable");
+        let Err(err) = result else {
+            panic!("non-UTF-8 content must be an error");
+        };
+        assert!(format!("{err:#}").contains(&path.display().to_string()));
     }
 
     #[test]
@@ -486,25 +539,27 @@ mod tests {
     }
 
     #[test]
-    fn cooldowns_default_when_unset() {
+    fn reply_delay_defaults_when_unset() {
         let cfg = ok_config("");
-        assert_eq!(cfg.cooldown_seconds, DEFAULT_COOLDOWN_SECONDS);
-        assert_eq!(cfg.group_cooldown_seconds, DEFAULT_GROUP_COOLDOWN_SECONDS);
-        let out = format!("{cfg:?}");
-        assert!(out.contains("cooldown_seconds") && out.contains("group_cooldown_seconds"));
+        assert_eq!(cfg.reply_delay_seconds, DEFAULT_REPLY_DELAY_SECONDS);
+        assert!(format!("{cfg:?}").contains("reply_delay_seconds"));
     }
 
     #[test]
-    fn cooldowns_use_custom_values_including_zero() {
-        let cfg = ok_config("cooldown_seconds = 5\ngroup_cooldown_seconds = 0\n");
-        assert_eq!(cfg.cooldown_seconds, 5);
-        assert_eq!(cfg.group_cooldown_seconds, 0);
+    fn reply_delay_uses_custom_value_including_zero() {
+        assert_eq!(ok_config("reply_delay_seconds = 5\n").reply_delay_seconds, 5);
+        assert_eq!(ok_config("reply_delay_seconds = 0\n").reply_delay_seconds, 0);
     }
 
     #[test]
-    fn negative_cooldown_is_rejected() {
-        assert!(config_from("cooldown_seconds = -1\n").is_err());
-        assert!(config_from("group_cooldown_seconds = -1\n").is_err());
+    fn negative_reply_delay_is_rejected() {
+        assert!(config_from("reply_delay_seconds = -1\n").is_err());
+    }
+
+    #[test]
+    fn removed_cooldown_keys_are_ignored() {
+        let cfg = ok_config("cooldown_seconds = 5\ngroup_cooldown_seconds = 0\n");
+        assert_eq!(cfg.reply_delay_seconds, DEFAULT_REPLY_DELAY_SECONDS);
     }
 
     #[test]
@@ -558,9 +613,9 @@ mod tests {
 
     #[test]
     fn bom_and_crlf_are_tolerated() {
-        let cfg = ok_config("\u{feff}openai_model = \"m\"\r\nsystem_prompt = \"p\"\r\n");
+        let cfg = ok_config("\u{feff}openai_model = \"m\"\r\nai_log = true\r\n");
         assert_eq!(cfg.openai_model, "m");
-        assert_eq!(cfg.system_prompt.as_deref(), Some("p"));
+        assert!(cfg.ai_log);
     }
 
     #[test]

+ 91 - 2
src/history.rs

@@ -1,5 +1,6 @@
 //! SQLite-backed store for message history, chats (with autoreply flag), contacts and settings.
 
+use std::collections::HashMap;
 use std::path::Path;
 
 use anyhow::{Context, Result};
@@ -9,6 +10,7 @@ const TABLE_MESSAGES: &str = "messages";
 const TABLE_CHATS: &str = "chats";
 const TABLE_CONTACTS: &str = "contacts";
 const TABLE_SETTINGS: &str = "settings";
+const TABLE_IMAGE_DESCRIPTIONS: &str = "image_descriptions";
 const INDEX_MESSAGES_BY_CHAT: &str = "idx_messages_chat_timestamp";
 
 /// Default `chats.last_timestamp` for chats that have no message yet.
@@ -314,20 +316,75 @@ impl History {
         Ok(enabled.unwrap_or(DEFAULT_GLOBAL_AUTOREPLY))
     }
 
+    /// Purpose: tell whether a message is stored.
+    /// Inputs: `message_id`.
+    /// Output: `true` if the history holds a message with that id.
+    /// Side effects: none (read only).
+    pub fn message_exists(&self, message_id: &str) -> Result<bool> {
+        let sql = format!("SELECT 1 FROM {TABLE_MESSAGES} WHERE message_id = ?1");
+        let found: Option<i64> = self
+            .connection
+            .query_row(&sql, params![message_id], |row| row.get(0))
+            .optional()
+            .with_context(|| format!("look up message {message_id}"))?;
+
+        Ok(found.is_some())
+    }
+
+    /// Purpose: store the description of an image message, replacing any previous one.
+    /// Inputs: `message_id` of the image message, its `chat_jid` and the `description`.
+    /// Output: `()`.
+    /// Side effects: upserts a row of the image descriptions table.
+    pub fn save_image_description(&self, message_id: &str, chat_jid: &str, description: &str) -> Result<()> {
+        let sql = format!(
+            "INSERT INTO {TABLE_IMAGE_DESCRIPTIONS} (message_id, chat_jid, description) VALUES (?1, ?2, ?3) \
+             ON CONFLICT(message_id) DO UPDATE SET description = excluded.description"
+        );
+
+        self.connection
+            .execute(&sql, params![message_id, chat_jid, description])
+            .with_context(|| format!("save image description of message {message_id}"))?;
+
+        Ok(())
+    }
+
+    /// Purpose: read the stored image descriptions of a chat.
+    /// Inputs: `chat_jid`.
+    /// Output: the descriptions keyed by message id (empty when there are none).
+    /// Side effects: none (read only).
+    pub fn image_descriptions(&self, chat_jid: &str) -> Result<HashMap<String, String>> {
+        let context = || format!("read image descriptions of chat {chat_jid}");
+        let sql = format!("SELECT message_id, description FROM {TABLE_IMAGE_DESCRIPTIONS} WHERE chat_jid = ?1");
+
+        let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
+        let descriptions = statement
+            .query_map(params![chat_jid], |row| Ok((row.get(0)?, row.get(1)?)))
+            .with_context(context)?
+            .collect::<rusqlite::Result<HashMap<_, _>>>()
+            .with_context(context)?;
+
+        Ok(descriptions)
+    }
+
     /// Purpose: delete a chat from the local history.
     /// Inputs: `chat_jid`.
     /// Output: `()` (also when the chat does not exist).
-    /// Side effects: removes the chat messages and the chat row (including its per-chat
-    /// autoreply flag), atomically; the contact, if any, is kept; a later message recreates the chat.
+    /// Side effects: removes the chat messages, their image descriptions and the chat row
+    /// (including its per-chat autoreply flag), atomically; the contact, if any, is kept; a later
+    /// message recreates the chat.
     pub fn delete_chat(&self, chat_jid: &str) -> Result<()> {
         let context = || format!("delete chat {chat_jid}");
         let delete_messages = format!("DELETE FROM {TABLE_MESSAGES} WHERE chat_jid = ?1");
+        let delete_descriptions = format!("DELETE FROM {TABLE_IMAGE_DESCRIPTIONS} WHERE chat_jid = ?1");
         let delete_chat = format!("DELETE FROM {TABLE_CHATS} WHERE jid = ?1");
         let transaction = self.connection.unchecked_transaction().with_context(context)?;
 
         transaction
             .execute(&delete_messages, params![chat_jid])
             .with_context(context)?;
+        transaction
+            .execute(&delete_descriptions, params![chat_jid])
+            .with_context(context)?;
         transaction
             .execute(&delete_chat, params![chat_jid])
             .with_context(context)?;
@@ -368,6 +425,11 @@ fn schema_sql() -> String {
         CREATE TABLE IF NOT EXISTS {TABLE_SETTINGS} (
             key   TEXT PRIMARY KEY,
             value INTEGER NOT NULL
+        );
+        CREATE TABLE IF NOT EXISTS {TABLE_IMAGE_DESCRIPTIONS} (
+            message_id  TEXT PRIMARY KEY,
+            chat_jid    TEXT NOT NULL,
+            description TEXT NOT NULL
         );",
         default_autoreply = i64::from(DEFAULT_AUTOREPLY),
     )
@@ -691,6 +753,33 @@ mod tests {
         assert_eq!(chats[0].last_timestamp, 10);
     }
 
+    #[test]
+    fn message_exists_only_for_stored_messages_until_the_chat_is_deleted() {
+        let history = History::open_in_memory().unwrap();
+        history.save_message(&message("a1", CHAT_A, 10)).unwrap();
+
+        assert!(history.message_exists("a1").unwrap());
+        assert!(!history.message_exists("missing").unwrap());
+        history.delete_chat(CHAT_A).unwrap();
+        assert!(!history.message_exists("a1").unwrap());
+    }
+
+    #[test]
+    fn image_descriptions_are_stored_per_chat_replaced_and_deleted_with_the_chat() {
+        let history = History::open_in_memory().unwrap();
+        history.save_image_description("a1", CHAT_A, "un gatto").unwrap();
+        history.save_image_description("a1", CHAT_A, "un gatto nero").unwrap();
+        history.save_image_description("b1", CHAT_B, "una torta").unwrap();
+
+        let descriptions = history.image_descriptions(CHAT_A).unwrap();
+        assert_eq!(descriptions.len(), 1);
+        assert_eq!(descriptions.get("a1").map(String::as_str), Some("un gatto nero"));
+
+        history.delete_chat(CHAT_A).unwrap();
+        assert!(history.image_descriptions(CHAT_A).unwrap().is_empty());
+        assert_eq!(history.image_descriptions(CHAT_B).unwrap().len(), 1);
+    }
+
     #[test]
     fn delete_chat_removes_messages_and_row_but_keeps_contact_and_other_chats() {
         let history = History::open_in_memory().unwrap();

+ 47 - 5
src/main.rs

@@ -21,14 +21,24 @@ use crate::whatsapp::WaEvent;
 
 /// Name of the thread that reads terminal input events.
 const INPUT_THREAD_NAME: &str = "wh-input";
-
-/// Purpose: program entry point; builds the application and runs it until quit.
-/// Inputs: none (reads config.toml and terminal events).
+/// Build version: package version and short git commit hash (see `build.rs`).
+pub const BUILD_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("WH_GIT_HASH"), ")");
+/// Command-line flags that print the build version and exit.
+const VERSION_FLAGS: [&str; 2] = ["--version", "-V"];
+
+/// Purpose: program entry point; prints the version for `--version`, otherwise builds the
+/// application and runs it until quit.
+/// Inputs: the command-line arguments; reads config.toml and terminal events.
 /// Output: `Ok(())` on a clean exit, otherwise the first fatal error.
-/// Side effects: creates the data directory and databases, connects to WhatsApp, takes over the terminal,
-/// and starts the optional web control panel when `web_port` is configured.
+/// Side effects: with `--version`/`-V` only prints `wh <BUILD_VERSION>`; otherwise creates the data
+/// directory and databases, connects to WhatsApp, takes over the terminal, and starts the
+/// optional web control panel when `web_port` is configured.
 #[tokio::main]
 async fn main() -> Result<()> {
+    if wants_version(std::env::args().skip(1)) {
+        println!("{} {BUILD_VERSION}", env!("CARGO_PKG_NAME"));
+        return Ok(());
+    }
     let config = Config::load().context("cannot load configuration")?;
     config.ensure_data_dir()?;
     let history = History::open(&config.history_db_path()).context("cannot open history database")?;
@@ -53,6 +63,14 @@ async fn main() -> Result<()> {
     run_tui(app, app_rx).await
 }
 
+/// Purpose: tell whether the command line asks for the version.
+/// Inputs: `args`, the command-line arguments without the program name.
+/// Output: `true` if any of them is one of `VERSION_FLAGS`.
+/// Side effects: none.
+fn wants_version(mut args: impl Iterator<Item = String>) -> bool {
+    args.any(|arg| VERSION_FLAGS.contains(&arg.as_str()))
+}
+
 /// Purpose: forward WhatsApp events into the application event channel.
 /// Inputs: `wa_rx`, the receiver of WhatsApp events; `app_tx`, the application event sender.
 /// Output: none.
@@ -164,3 +182,27 @@ async fn event_loop(
     }
     Ok(())
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Converts string literals into owned command-line arguments.
+    fn args(values: &[&str]) -> std::vec::IntoIter<String> {
+        values.iter().map(|value| (*value).to_owned()).collect::<Vec<_>>().into_iter()
+    }
+
+    #[test]
+    fn wants_version_only_for_version_flags() {
+        assert!(wants_version(args(&["--version"])));
+        assert!(wants_version(args(&["-V"])));
+        assert!(!wants_version(args(&[])));
+        assert!(!wants_version(args(&["-v", "version"])));
+    }
+
+    #[test]
+    fn build_version_starts_with_package_version_and_names_a_commit() {
+        assert!(BUILD_VERSION.starts_with(env!("CARGO_PKG_VERSION")));
+        assert!(BUILD_VERSION.ends_with(')') && BUILD_VERSION.contains(" ("));
+    }
+}

+ 25 - 3
src/ui.rs

@@ -59,8 +59,10 @@ const KEY_HELP_GLOBAL: &str = "F4:AI tutte";
 const KEY_HELP_AFTER_GLOBAL: &str = "  F10:esci";
 /// Gap between the status text and the key help.
 const FOOTER_GAP: &str = "   ";
-/// Leading space of the footer line.
+/// Leading space of the footer line (also trailing space after the build version).
 const FOOTER_MARGIN: &str = " ";
+/// Text shown in front of the build version at the bottom right.
+const VERSION_PREFIX: &str = "v";
 
 /// Text of the connecting screen.
 const TEXT_CONNECTING: &str = "Connessione a WhatsApp in corso...";
@@ -540,11 +542,16 @@ fn key_help_spans(global_autoreply: bool) -> Vec<Span<'static>> {
     ]
 }
 
-/// Purpose: draw the bottom line with the status and the key help.
+/// Purpose: draw the bottom line with the status and the key help on the left and the build
+/// version on the right.
 /// Inputs: frame, the footer area and the app.
 /// Output: none.
 /// Side effects: writes to the frame buffer.
 fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
+    let version = version_label();
+    let version_width = u16::try_from(version.chars().count()).unwrap_or(u16::MAX);
+    let [left, right] =
+        Layout::horizontal([Constraint::Fill(1), Constraint::Length(version_width)]).areas(area);
     let mut spans = vec![
         Span::raw(FOOTER_MARGIN),
         Span::styled(app.status.clone(), Style::new().add_modifier(Modifier::BOLD)),
@@ -552,7 +559,17 @@ fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
     ];
     spans.extend(key_help_spans(app.global_autoreply));
 
-    frame.render_widget(Paragraph::new(Line::from(spans)), area);
+    frame.render_widget(Paragraph::new(Line::from(spans)), left);
+    let dim = Style::new().add_modifier(Modifier::DIM);
+    frame.render_widget(Paragraph::new(Span::styled(version, dim)), right);
+}
+
+/// Purpose: build the build-version text shown at the bottom right.
+/// Inputs: none.
+/// Output: `VERSION_PREFIX`, `BUILD_VERSION` and `FOOTER_MARGIN`.
+/// Side effects: none.
+fn version_label() -> String {
+    format!("{VERSION_PREFIX}{}{FOOTER_MARGIN}", crate::BUILD_VERSION)
 }
 
 #[cfg(test)]
@@ -605,6 +622,11 @@ mod tests {
         assert!(global_off.style.add_modifier.contains(Modifier::DIM));
     }
 
+    #[test]
+    fn version_label_shows_build_version_with_prefix_and_margin() {
+        assert_eq!(version_label(), format!("v{} ", crate::BUILD_VERSION));
+    }
+
     #[test]
     fn incoming_message_uses_sender_name() {
         let line = format_message_line_in(&incoming("ciao"), &Utc);

+ 0 - 104
src/whatsapp.rs

@@ -99,9 +99,6 @@ pub enum WaEvent {
         message: StoredMessage,
         /// Reference to download the image bytes on demand.
         image: Option<ImageRef>,
-        /// Raw JIDs this message directly addresses: `ContextInfo.mentioned_jid` plus
-        /// the `ContextInfo.participant` of a quoted message. Empty for history sync.
-        addressed_to: Vec<String>,
     },
     /// Contact name learned from contact sync, history sync or push names.
     Contact(Contact),
@@ -520,7 +517,6 @@ impl Translator {
                 is_ai: false,
             },
             image,
-            addressed_to: addressed_jids(&inbound.message),
         });
     }
 
@@ -646,7 +642,6 @@ impl Translator {
                     is_ai: false,
                 },
                 image: None,
-                addressed_to: Vec::new(),
             });
         }
     }
@@ -898,41 +893,6 @@ fn describe_other(base: &wa::Message) -> Option<String> {
     Some(UNSUPPORTED_PLACEHOLDER.to_owned())
 }
 
-/// Finds the context info (mentions, quoted message) carried by a message.
-///
-/// Inputs: the unwrapped base message.
-/// Output: the context info of the first content kind that carries one
-/// (extended text, image, video, audio, document, sticker), or `None`.
-/// Side effects: none.
-fn context_info_of(base: &wa::Message) -> Option<&wa::ContextInfo> {
-    [
-        base.extended_text_message.as_option().and_then(|m| m.context_info.as_option()),
-        base.image_message.as_option().and_then(|m| m.context_info.as_option()),
-        base.video_message.as_option().and_then(|m| m.context_info.as_option()),
-        base.audio_message.as_option().and_then(|m| m.context_info.as_option()),
-        base.document_message.as_option().and_then(|m| m.context_info.as_option()),
-        base.sticker_message.as_option().and_then(|m| m.context_info.as_option()),
-    ]
-    .into_iter()
-    .flatten()
-    .next()
-}
-
-/// Lists the JIDs a message directly addresses.
-///
-/// Inputs: the decoded message.
-/// Output: the mentioned JIDs, followed by the author of the quoted message
-/// (only when the message actually quotes one); empty when there are none.
-/// Side effects: none.
-fn addressed_jids(message: &wa::Message) -> Vec<String> {
-    let Some(context) = context_info_of(message.get_base_message()) else {
-        return Vec::new();
-    };
-    let is_quote = context.quoted_message.as_option().is_some() || context.stanza_id.is_some();
-    let quoted_author = context.participant.clone().filter(|_| is_quote);
-    context.mentioned_jid.iter().cloned().chain(quoted_author).collect()
-}
-
 /// Extracts a downloadable reference when the message is an image.
 ///
 /// Inputs: the decoded message.
@@ -1025,76 +985,12 @@ mod tests {
         }
     }
 
-    const MENTIONED_JID: &str = "393331234567@s.whatsapp.net";
-    const QUOTED_AUTHOR_JID: &str = "393339999999@s.whatsapp.net";
-    const QUOTED_STANZA_ID: &str = "3EB0QUOTED";
-
-    /// Builds an extended text message carrying the given context info.
-    fn text_with_context(context_info: wa::ContextInfo) -> wa::Message {
-        wa::Message {
-            extended_text_message: MessageField::some(wa::message::ExtendedTextMessage {
-                text: Some("ciao".to_owned()),
-                context_info: MessageField::some(context_info),
-                ..Default::default()
-            }),
-            ..Default::default()
-        }
-    }
-
     #[test]
     fn is_fatal_sync_failure_only_for_non_empty_fatal() {
         assert!(!is_fatal_sync_failure(&[]));
         assert!(is_fatal_sync_failure(&["regular_high".to_owned()]));
     }
 
-    #[test]
-    fn addressed_jids_extracts_mentions_from_extended_text() {
-        let message = text_with_context(wa::ContextInfo {
-            mentioned_jid: vec![MENTIONED_JID.to_owned()],
-            ..Default::default()
-        });
-        assert_eq!(addressed_jids(&message), vec![MENTIONED_JID.to_owned()]);
-    }
-
-    #[test]
-    fn addressed_jids_extracts_quoted_participant() {
-        let message = text_with_context(wa::ContextInfo {
-            stanza_id: Some(QUOTED_STANZA_ID.to_owned()),
-            participant: Some(QUOTED_AUTHOR_JID.to_owned()),
-            ..Default::default()
-        });
-        assert_eq!(addressed_jids(&message), vec![QUOTED_AUTHOR_JID.to_owned()]);
-    }
-
-    #[test]
-    fn addressed_jids_ignores_participant_without_quote() {
-        let message = text_with_context(wa::ContextInfo {
-            participant: Some(QUOTED_AUTHOR_JID.to_owned()),
-            ..Default::default()
-        });
-        assert!(addressed_jids(&message).is_empty());
-    }
-
-    #[test]
-    fn addressed_jids_reads_media_context_info() {
-        let message = wa::Message {
-            image_message: MessageField::some(wa::message::ImageMessage {
-                context_info: MessageField::some(wa::ContextInfo {
-                    mentioned_jid: vec![MENTIONED_JID.to_owned()],
-                    ..Default::default()
-                }),
-                ..Default::default()
-            }),
-            ..Default::default()
-        };
-        assert_eq!(addressed_jids(&message), vec![MENTIONED_JID.to_owned()]);
-    }
-
-    #[test]
-    fn addressed_jids_is_empty_for_plain_text() {
-        assert!(addressed_jids(&text_message("ciao")).is_empty());
-    }
-
     #[test]
     fn plain_text_is_extracted() {
         assert_eq!(

Some files were not shown because too many files changed in this diff