--- 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 and cooldowns, building the AI context, deferred replies, the startup catch-up, the vision path with its courtesy fallback, retries, and sending the reply. tags: [ai, autoreply, workflow, vision, reply-gate, cooldown, catch-up] sources: - id: openwiki-source-9fec9d1af408501c9c455161 resource: repo://src/app.rs - id: openwiki-source-043e6252badc57a1ee6bfb48 resource: repo://src/autoreply.rs - id: openwiki-source-ce0c4ea8cd3b15431fa691ce resource: repo://src/history.rs generated: { by: "claude-code", at: "2026-10-20T20:27:06.515Z" } verified: - by: openwiki/0.7.2 at: 2026-10-20T20:27:06.515Z --- # 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 call is done by the [AI client](../integrations/ai-client.md). ``` WaEvent::Message { message, image, addressed_to } -> History::save_message (new only) -> App::maybe_autoreply (enabled = global OR per-chat flag) ReplyGate::should_reply? --yes--> start_reply else ReplyGate::deferral_due? --yes--> mark_pending + timer -> AppEvent::CooldownElapsed -> take_pending && enabled? -> start_reply (text-only) else stop WaEvent::OfflineSyncCompleted (first time only) -> App::catch_up -> start_reply (text-only) per due chat start_reply: ReplyGate::record_reply (clears pending) History::messages_since(context_start(now), 200) -> build_turns image + vision model? -> vision request (fallback: text request) else -> text request (each with up to ai_retries retries) -> AppEvent::AiReply -> still enabled (global OR per-chat)? --no--> discard -> WaHandle::send_text -> AppEvent::Sent -> stored with is_ai = true ``` ## 1. Enabling There are two independent switches, and a chat is **enabled** when either is on (`autoreply_enabled`, `src/app.rs:366-372`): - **Per chat** — F3 toggles the flag of the open chat (`toggle_autoreply` / `toggle_chat_autoreply`, `src/app.rs:763-794`); 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 and `system_prompt`, see [Configuration](../operations/configuration-and-data.md)) and a chat that supports autoreply (`can_enable_autoreply`, `src/app.rs:1127-1140`), 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:839-856`); 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: `should_reply` still requires a supported chat, so broadcasts, status and newsletters are never answered. `supports_autoreply` (`src/autoreply.rs:89-97`) accepts private chats and groups: - private: JID server `s.whatsapp.net` or `lid` (`is_private_chat`, `src/autoreply.rs:66-76`); - group: JID server `g.us` (`is_group_chat`, `src/autoreply.rs:78-87`); - broadcasts (including `status@broadcast`), newsletters and malformed JIDs are excluded. ## 2. Trigger Only messages that `History::save_message` reports as new reach `maybe_autoreply` (`on_message`, `src/app.rs:304-313`; `maybe_autoreply`, `src/app.rs:324-346`), 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:430-436`), refreshes the own JIDs if unknown and computes `enabled` (global or per chat). It also receives `addressed_to`, the raw JIDs the message mentions or quotes (filled by `addressed_jids` in the [WhatsApp client](../integrations/whatsapp-client.md)). ## 3. ReplyGate rules `ReplyGate::should_reply(message, enabled, addressed_to, now)` (`src/autoreply.rs:176-194`) returns `true` only when **all** hold (`is_reply_candidate` and `meets_content_rules`, `src/autoreply.rs:258-282`): | Rule | Detail | |---|---| | 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 (`src/autoreply.rs:166-174`) | | 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 | | Cooldown | enough time since the last reply in that chat (see below) | ### Cooldowns `Cooldowns { private, group }` (`src/autoreply.rs:99-107`) comes from the configuration keys `cooldown_seconds` (default 30) and `group_cooldown_seconds` (default 300), built in `App::new` (`src/app.rs:197-200`); `0` disables a cooldown. The last reply instant is tracked per chat. `cooldown_for` (`src/autoreply.rs:334-346`) picks: - `group` for a group message **not** addressed to the user; - `private` for private chats and for group messages addressed to the user. A group message is addressed to the user when one of its `addressed_to` JIDs — an `@`-mention or the author of a quoted message — equals one of the own JIDs, ignoring a `:N` device suffix (`is_addressed`, `src/autoreply.rs:322-332`). Mentioning someone else does not shorten the cooldown. In private chats mentions make no difference. The self-chat is detected by comparing the chat JID with the account's own PN/LID JIDs, ignoring a device suffix (`src/autoreply.rs:145-164`, `strip_device_suffix`, `src/autoreply.rs:349-365`). `record_reply` is called **before** the AI request is spawned (in `start_reply`, `src/app.rs:438-457`), so a second message arriving during the request cannot cause a double reply; it also clears any pending deferral of that chat (`src/autoreply.rs:246-256`). ## 4. Deferred reply (private chats) When a message in a private chat (including the self-chat) passes every rule except the cooldown, it is not dropped: `deferral_due` (`src/autoreply.rs:196-224`) returns the instant the cooldown ends, provided a reply was already recorded for the chat, the cooldown is still running and no deferral is pending. `maybe_autoreply` then calls `mark_pending` and `spawn_cooldown_timer` (`src/app.rs:986-997`), a tokio task that sleeps until that instant and sends `AppEvent::CooldownElapsed { chat_jid }`. `handle_cooldown_elapsed` (`src/app.rs:348-364`) consumes the deferral with `take_pending`; if it was still pending, the chat is still enabled (global or per chat) and the AI is configured, it starts **one** text-only reply (no image is downloaded) built from the latest context, so several messages received during the cooldown get a single answer. If a live reply happened in the meantime, `record_reply` already removed the pending mark and nothing is sent. Deleting the chat locally also drops its pending deferral. Groups never defer: a group message blocked by the cooldown is simply not answered. ## 5. 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:374-384`) runs `catch_up` once per process (`catch_up_done`; later reconnections skip it). `catch_up` / `catch_up_chats` (`src/app.rs:386-428`) 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:284-307`) 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 gets one text-only reply through `start_reply`; when at least one was started the status shows `Risposte di recupero: N`. ## 6. Building the context `start_reply` loads the context with `History::messages_since(chat_jid, context_start(now), CONTEXT_MESSAGES)`: - `context_start` (`src/autoreply.rs:476-497`) 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)). `build_turns(system_prompt, messages, context)` (`src/autoreply.rs:499-526`): - first a `System` turn: the configured prompt followed by a role note (`role_note`, `src/autoreply.rs:20-27`, `src/autoreply.rs:467-474`). `ROLE_NOTE_CHAT` tells the model that `assistant` messages were written by the account owner it answers for and `user` messages by the contact or, in groups, by the participants named in front; `ROLE_NOTE_SELF_CHAT` says that in the self-chat `user` is the owner and `assistant` its own previous replies; - then the last `CONTEXT_MESSAGES` messages, skipping blank texts; the last one is the newest message of the chat; - roles (`turn_role`, `src/autoreply.rs:367-377`): 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:427-465`); - only `User` turns are rewritten (`src/autoreply.rs:388-425`): - voice note: the triggering one becomes an instruction to reply courteously that it cannot be listened to and ask to write it; older ones become a short history note; - image: the triggering one becomes "reply to the attached image" when vision is used, otherwise an instruction to say it cannot be seen and ask for a description; older ones become a history note; a caption is appended as `Didascalia: ...`; - any other text is kept as is. `TurnContext { self_chat, vision }` (`src/autoreply.rs:379-386`) carries the two switches. ## 7. Vision path, fallback and retries The image is kept only for a live triggering image when a vision model is configured (`vision_applies`); deferred and catch-up replies never carry one. In that case `spawn_reply` (`src/app.rs:459-490`) builds two turn lists — with `vision: true` and, as fallback, `vision: false` — and runs one background task that: 1. downloads the image into memory via the [WhatsApp client](../integrations/whatsapp-client.md); 2. calls `complete_with_image` with the vision model; 3. on any failure, tries plain `complete` using the fallback turns (the courtesy "cannot see it" reply). If both fail, the error combines both messages (`; Fallback cortesia fallito: `, `src/app.rs:1035-1064`). Without a usable image, a plain text request is spawned. Every AI call (text, vision and the courtesy fallback) 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 "still enabled" check in step 8 happens after all retries. When `ai_log = true`, every attempt is also appended to `ai_requests.log`. ## 8. Sending `AppEvent::AiReply` is handled by `send_ai_reply_if_enabled` (`src/app.rs:519-534`), which re-checks `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`. Otherwise the text is sent with `spawn_send(..., is_ai = true)`; on success `handle_sent` (`src/app.rs:536-564`) stores it as an own message with `is_ai = true`, which keeps it from re-triggering the self-chat. Errors appear in the status line (`Errore AI: ...`, `Invio fallito: ...`). ## Tests The rules, cooldowns, deferral, catch-up and turn building are covered by 77 tests in `src/autoreply.rs:528+`; enable rules, `web_chats` and vision applicability by `src/app.rs:1167+`; the global flag storage by `src/history.rs`; the retry policy by `src/ai.rs`. See [Unit tests](../testing/unit-tests.md).