--- type: workflow title: AI auto-reply workflow description: End-to-end trace of the AI autoreply — enabling it per chat (F3) or globally (F4 / web panel) for private chats and groups, the ReplyGate rules, the reply delay that batches messages into one reply, the drop when the owner answered by hand, image descriptions stored once and reused, the startup catch-up, building the AI context from SOUL.md, the no_reply tool, retries and sending the reply. tags: [ai, autoreply, workflow, vision, reply-gate, reply-delay, catch-up, image-description] verified: - by: openwiki/0.7.2 at: 2026-10-21T21:49:12.291Z sources: - id: openwiki-source-9fec9d1af408501c9c455161 resource: repo://src/app.rs - id: openwiki-source-043e6252badc57a1ee6bfb48 resource: repo://src/autoreply.rs - id: openwiki-source-2a737474d86fc75cc9d9694f resource: repo://src/config.rs - id: openwiki-source-ce0c4ea8cd3b15431fa691ce resource: repo://src/history.rs generated: { by: "claude-code", at: "2026-10-21T21:49:12.291Z" } --- # AI auto-reply workflow The autoreply answers incoming WhatsApp messages with an AI-generated text, only for chats where the user turned it on — one by one, or all at once with the global switch. Decision rules and context building are pure functions in `src/autoreply.rs` (no I/O, `src/autoreply.rs:1-4`); orchestration lives in [`App`](../architecture/app-state.md); the HTTP calls are done by the [AI client](../integrations/ai-client.md). ``` WaEvent::Message { message, image } -> History::save_message (new only) -> App::maybe_autoreply (enabled = global OR per-chat flag) ReplyGate::should_schedule? --yes--> mark_pending + timer(reply_delay_seconds) image from an allowed sender? ----> keep_image (pending_images / backlog_images) ... AppEvent::ReplyDue -> prepare_reply: reply_context (still enabled? AI? owner not answered last?) --no--> finish_reply (drop) images without description? --yes--> download + describe_image (parallel, max 5) -> AppEvent::ImagesDescribed -> store descriptions -> start_reply: messages_since(yesterday 00:00, 200) -> mark @[te] -> build_turns -> AiClient::complete (tools: [no_reply], up to ai_retries retries) -> AppEvent::AiReply NoReply -> status "AI: nessuna risposta necessaria" Text -> still enabled AND owner did not answer meanwhile? -> send_text -> stored is_ai = true -> finish_reply: newer message arrived meanwhile? -> schedule one new reply WaEvent::OfflineSyncCompleted (first time only) -> catch_up -> mark_pending + prepare_reply (no delay) per due private chat ``` ## 1. Enabling There are two independent switches, and a chat is **enabled** when either is on (`autoreply_enabled`, `src/app.rs:521-527`): - **Per chat** — F3 toggles the flag of the open chat (`toggle_autoreply` / `toggle_chat_autoreply`, `src/app.rs:976-1007`); the [web control panel](../integrations/web-control-panel.md) can toggle any listed chat the same way. Turning it off is always allowed; turning it on requires a configured AI (API key in `config.toml` and a non-blank `SOUL.md`, see [Configuration](../operations/configuration-and-data.md)) and a chat that supports autoreply (`can_enable_autoreply`, `src/app.rs:1403-1416`), otherwise the status shows `Autoreply disponibile solo nelle chat private e nei gruppi`. The flag is persisted in the `chats` table of the [history store](../concepts/history-store.md) (default off). - **Global ("AI tutte")** — F4 or the web panel toggles `global_autoreply` (`toggle_global_autoreply`, `src/app.rs:1052-1069`); enabling requires an AI client. It is persisted in the `settings` table, loaded at startup and does not change any per-chat flag, so turning it off restores the per-chat selection. The TUI marks it in the list title and footer ([Terminal UI](../architecture/terminal-ui.md)). The global switch does not bypass the chat-type rule: `meets_content_rules` still requires a supported chat, so broadcasts, status and newsletters are never answered. `supports_autoreply` (`src/autoreply.rs:99-107`) accepts private chats and groups: - private: JID server `s.whatsapp.net` or `lid` (`is_private_chat`, `src/autoreply.rs:76-86`); - group: JID server `g.us` (`is_group_chat`, `src/autoreply.rs:88-97`); - broadcasts (including `status@broadcast`), newsletters and malformed JIDs are excluded. ## 2. Trigger and ReplyGate rules Only messages that `History::save_message` reports as new reach `maybe_autoreply` (`on_message`, `src/app.rs:333-342`; `maybe_autoreply`, `src/app.rs:344-366`), so duplicates and history-sync re-deliveries of stored messages never trigger. `maybe_autoreply` needs an AI client and a system prompt (`ai_setup`, `src/app.rs:628-634`), refreshes the own JIDs if unknown and computes `enabled` (global or per chat). `ReplyGate::should_schedule(message, enabled)` (`src/autoreply.rs:182-191`) returns `true` only when **all** hold (`is_reply_candidate` and `meets_content_rules`, `src/autoreply.rs:241-265`): | Rule | Detail | |---|---| | Not pending | the chat has no reply already scheduled or in progress | | Enabled | global autoreply on, or the per-chat flag on | | Sender allowed | not sent by the user — except human-written messages in the self-chat ("Messaggi a te stesso"); AI messages never trigger (`sender_allowed`, `src/autoreply.rs:172-180`) | | Supported chat | private chat or group (`supports_autoreply`) | | No backlog | `timestamp >= started_at`, the process start time | | Has content | text not blank and not `[media]`/`[messaggio non supportato]`; voice notes and images do trigger | The self-chat is detected by comparing the chat JID with the account's own PN/LID JIDs, ignoring a `:N` device suffix (`is_self_chat`, `src/autoreply.rs:148-157`; `strip_device_suffix`, `src/autoreply.rs:293-309`). Groups follow exactly the same rules as private chats; mentions and quotes no longer change anything. ## 3. Reply delay and batching When `should_schedule` passes, `schedule_reply` (`src/app.rs:368-376`) marks the chat pending in the gate (`mark_pending`, `src/autoreply.rs:202-210`) and `spawn_reply_timer` (`src/app.rs:1199-1209`) emits `AppEvent::ReplyDue { chat_jid }` after `reply_delay_seconds` (default 30; `0` answers at once). The chat stays pending until `finish_reply`, so every message arriving during the wait — and while the AI is generating — gets no timer of its own: the reply is built from the context loaded when it starts, so it covers everything received during the wait. `handle_reply_due` (`src/app.rs:417-428`) takes the images kept for the chat and, if the chat is still pending (a local deletion removes the pending mark), calls `prepare_reply` (`src/app.rs:430-451`). Its `reply_context` (`src/app.rs:529-547`) drops the reply — and `finish_reply` ends the pending state — when: - the autoreply was turned off (both switches) during the wait; - the AI is not configured; - the chat has no message in the context window; - the latest message was written by hand by the owner outside the self-chat (`answered_by_owner`, `src/autoreply.rs:231-239`): the owner already answered, so the AI stays silent. Otherwise it describes the new images first (see §5) or calls `start_reply` at once. ### After the reply `AiReply` is always followed by `finish_reply` (`src/app.rs:499-519`), which clears the pending state and the `answered_up_to` snapshot. If a reply was started and the latest stored message is a different one (`arrived_after`, `src/app.rs:1307-1314`) that itself passes `should_schedule`, a **new** reply is scheduled (with a fresh delay, keeping the images received meanwhile); otherwise the kept images are dropped. So messages that arrive while a reply is being generated are answered once, afterwards, rather than being lost or triggering parallel replies. ## 4. Startup catch-up Messages received while the app was off are delivered on connection and are older than `started_at`, so the live rules treat them as backlog. When WhatsApp signals that the offline queue has been delivered (`WaEvent::OfflineSyncCompleted`), `on_offline_sync_completed` (`src/app.rs:567-577`) runs `catch_up` once per process (`catch_up_done`; later reconnections skip it). `catch_up` / `catch_up_chats` (`src/app.rs:579-626`) look at every enabled chat — all chats when the global autoreply is on, otherwise those with the per-chat flag — and take its last stored message. `ReplyGate::catch_up_due` (`src/autoreply.rs:267-290`) accepts it only when: - `catch_up_seconds` (default 7200) is positive — `0` disables the catch-up; - the chat is not a group; - the message is at most `catch_up_seconds` old and older than the app start (newer messages follow the live path); - it passes `meets_content_rules`, so a last message written by the user (already answered) never qualifies. Each due chat is marked pending and goes through `prepare_reply` **without** the reply delay, together with the images of its offline messages (`backlog_images`); offline images of other chats are dropped. When at least one reply went ahead the status shows `Risposte di recupero: N`. Offline images that arrive after the catch-up already ran are described on their own, without a reply (`describe_late_image`, `src/app.rs:400-415`). ## 5. Image descriptions Images are never sent to the text model. Each image sent by an allowed sender in an enabled chat is kept by `keep_image` (`src/app.rs:378-398`) and, just before the reply that covers it, described **once** by the vision model (`AiClient::describe_image`, request text from `vision.md` or the built-in one, see [AI client](../integrations/ai-client.md)). The description is stored in the `image_descriptions` table and reused in every later context. - `select_images_to_describe` (`src/app.rs:1265-1292`) keeps only the images whose message is in the reply context and that have neither a stored description nor one in progress (`describing`), and at most the **5 most recent** (`MAX_IMAGES_PER_REPLY`); older images of the same batch are dropped for good, to cap the cost of albums. Without `openai_vision_model` nothing is described (`vision_applies`). - `spawn_image_descriptions` (`src/app.rs:1231-1263`) describes all selected images in parallel. Each download (`download_image_with_retry`, `src/app.rs:1336-1362`) makes up to 3 attempts, each abandoned after 60 s, waiting 2 s × attempt between them; the bytes stay in memory. - `handle_images_described` (`src/app.rs:465-497`) stores each description whose message still exists (`History::message_exists`), so a chat deleted meanwhile gets no orphan rows. Failed images stay without description, the others are unaffected, and the status shows `Descrizione immagine fallita (N): `. If the chat is still pending, the reply then starts (re-checking `reply_context`). ## 6. Building the context `start_reply` (`src/app.rs:645-673`) loads the context with `History::messages_since(chat_jid, context_start(now), CONTEXT_MESSAGES)`: - `context_start` (`src/autoreply.rs:459-468`) is local midnight of **yesterday** (falls back to `now` minus two days if local time cannot be resolved); - `CONTEXT_MESSAGES` = 200 (`src/autoreply.rs:13-15`) caps the window, keeping the most recent messages in ascending order ([History store](../concepts/history-store.md)). It records the last context message in `answered_up_to`, replaces every `@` in the texts with `@[te]` (`mark_own_mentions` / `replace_mention`, `src/autoreply.rs:159-170`, `src/autoreply.rs:438-457`; a longer number is never matched) and calls `build_turns(system_prompt, messages, TurnContext)` (`src/autoreply.rs:482-503`) with the chat name and the stored image descriptions: - first a `System` turn (`system_turn_content`, `src/autoreply.rs:420-436`): the `SOUL.md` prompt as written, then — in normal chats and groups — a line `Chat privata con «name».` or `Chat di gruppo «name».` and `ROLE_NOTE_CHAT` (`assistant` messages were written by the account owner it answers for, `user` messages by the contact or, in groups, by the participants named in front); in the self-chat only `ROLE_NOTE_SELF_CHAT` (`user` is the owner, `assistant` its own previous replies). Parts are separated by a blank line; - then the last `CONTEXT_MESSAGES` messages, skipping blank texts; - roles (`turn_role`, `src/autoreply.rs:311-321`): in normal chats and groups own messages are `Assistant`; in the self-chat, where every message is `from_me`, only `is_ai` messages are `Assistant`; - in groups every `User` turn is prefixed with `": "`, where the sender is the trimmed push name or else the user part of the sender JID (`sender_label`, `message_to_turn`, `src/autoreply.rs:381-418`); - only `User` turns are rewritten (`user_content`, `src/autoreply.rs:364-379`), with plain facts — how to react is left to `SOUL.md`: - voice note → `[Il contatto ha inviato un messaggio vocale che non puoi ascoltare]`; - image with a stored description → `[Il contatto ha inviato un'immagine. Descrizione: ]`, without one → `[Il contatto ha inviato un'immagine che non puoi vedere]`; a caption is appended as `\nDidascalia: `; - any other text is kept as is. ## 7. Request, `no_reply` and retries `spawn_ai_request` (`src/app.rs:1211-1229`) calls `AiClient::complete`, which offers the model a single parameterless tool, `no_reply`. When the model calls it, the outcome is `Reply::NoReply`: nothing is sent and the status shows `AI: nessuna risposta necessaria`. The app never says when to use it — the cases belong in `SOUL.md`. Every AI call (text completion and image description) is retried by the [AI client](../integrations/ai-client.md) up to `ai_retries` times (default 2, waiting 2 s, 4 s, …) on transient errors — HTTP 429, 5xx, timeouts, transport errors, malformed or empty replies — while other 4xx errors fail at once. A reply can therefore arrive noticeably later than the trigger; the checks in §8 happen after all retries. The request task is awaited by a second task, so `AiReply` is always delivered and the chat never stays pending. When `ai_log = true`, every attempt is also appended to `ai_requests.log`. ## 8. Sending A text reply is handled by `send_ai_reply_if_enabled` (`src/app.rs:703-722`), which re-checks two things: - `autoreply_enabled`: if both the global and the per-chat switch are off by then, the reply is discarded with status `Risposta AI scartata: autoreply disattivato`; - `owner_answered_meanwhile` (`src/app.rs:724-735`): if a hand-written owner message follows the last message of the reply context (`manual_reply_after`), the reply is discarded with `Risposta AI scartata: hai già risposto`. Otherwise the text is sent with `spawn_send(..., is_ai = true)`; on success `handle_sent` (`src/app.rs:737-765`) stores it as an own message with `is_ai = true`, which keeps it from re-triggering the self-chat and from counting as a manual answer. Errors appear in the status line (`Errore AI: ...`, `Invio fallito: ...`). ## Tests The gate rules, pending tracking, catch-up, mention marking and turn building are covered by `src/autoreply.rs:505+`; enable rules, `web_chats`, image selection, `arrived_after`, `manual_reply_after` and the description failure status by `src/app.rs:1443+`; image descriptions and the global flag storage by `src/history.rs`; the `no_reply` tool, descriptions and the retry policy by `src/ai.rs`. See [Unit tests](../testing/unit-tests.md).