--- type: workflow title: AI auto-reply workflow description: End-to-end trace of the per-chat AI autoreply — enabling it in 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, 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 generated: { by: "claude-code", at: "2026-10-19T19:53:56.480Z" } verified: - by: openwiki/0.7.2 at: 2026-10-19T19:53:56.480Z --- # AI auto-reply workflow The autoreply answers incoming WhatsApp messages with an AI-generated text, per chat and only when the user turned it on. 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 ReplyGate::should_reply? --yes--> start_reply else ReplyGate::deferral_due? --yes--> mark_pending + timer -> AppEvent::CooldownElapsed -> take_pending? -> 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 -> AppEvent::AiReply -> autoreply still enabled? --no--> discard -> WaHandle::send_text -> AppEvent::Sent -> stored with is_ai = true ``` ## 1. Enabling per chat `Ctrl+A` toggles the flag of the open chat (`toggle_autoreply`, `src/app.rs:605-624`). 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:885-898`), otherwise the status shows `Autoreply disponibile solo nelle chat private e nei gruppi`. The flag is persisted in the [history store](../concepts/history-store.md) (`set_autoreply`, default off). `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` (`src/app.rs:267-315`), 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:389-395`), refreshes the own JIDs if unknown and reads the chat flag. 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 | per-chat flag is 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:170-173`); `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:397-416`), 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:744-755`), a tokio task that sleeps until that instant and sends `AppEvent::CooldownElapsed { chat_jid }`. `handle_cooldown_elapsed` (`src/app.rs:317-332`) consumes the deferral with `take_pending`; if it was still pending, the chat still has autoreply on 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. 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:334-344`) runs `catch_up` once per process (`catch_up_done`; later reconnections skip it). `catch_up` / `catch_up_chats` (`src/app.rs:346-387`) look at every chat with autoreply enabled 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 and fallback 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:418-449`) 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, retries with 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:793-822`). Without a usable image, a plain text request is spawned. When `ai_log = true`, every request is also appended to `ai_requests.log` (see [AI client](../integrations/ai-client.md)). ## 8. Sending `AppEvent::AiReply` is handled by `send_ai_reply_if_enabled` (`src/app.rs:478-492`), which re-reads the chat flag: if autoreply was turned off while the request was running, 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:494-522`) 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 and vision applicability by `src/app.rs:909+`. See [Unit tests](../testing/unit-tests.md).