--- type: workflow title: AI auto-reply workflow description: End-to-end trace of the per-chat AI autoreply — enabling it, the ReplyGate rules, building the AI context, the vision path with its courtesy fallback, and sending the reply. tags: [ai, autoreply, workflow, vision, reply-gate] verified: - by: openwiki/0.7.2 at: 2026-10-19T18:58:46.219Z 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-19T18:58:46.219Z" } --- # 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 -> History::save_message (new only) -> App::maybe_autoreply ReplyGate::should_reply? --no--> stop ReplyGate::record_reply History::recent_messages(20) -> 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:477-491`). 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 private chat (`can_enable_autoreply`, `src/app.rs:743-751`). The flag is persisted in the [history store](../concepts/history-store.md) (`set_autoreply`, default off). A chat is private when its JID server is `s.whatsapp.net` or `lid`; groups, broadcasts, newsletters and malformed JIDs are not (`is_private_chat`, `src/autoreply.rs:48-58`). ## 2. Trigger Only messages that `History::save_message` reports as new reach `maybe_autoreply` (`src/app.rs:251-284`), so duplicates and history-sync re-deliveries of stored messages never trigger. `maybe_autoreply` needs an AI client and a system prompt, refreshes the own JIDs if unknown and reads the chat flag. ## 3. ReplyGate rules `ReplyGate::should_reply(message, enabled, now)` (`src/autoreply.rs:130-137`) returns `true` only when **all** hold: | 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:115-117`) | | Private chat | `is_private_chat` | | 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 | at least `COOLDOWN_SECONDS` = 30 s since the last reply in that chat (`src/autoreply.rs:155-159`) | The self-chat is detected by comparing the chat JID with the account's own PN/LID JIDs, ignoring a `:N` device suffix (`src/autoreply.rs:94-107`, `src/autoreply.rs:168-178`). `record_reply` is called **before** the AI request is spawned, so a second message arriving during the request cannot cause a double reply (`src/autoreply.rs:145-147`). ## 4. Building the context `build_turns(system_prompt, messages, context)` (`src/autoreply.rs:268-280`): - first a `System` turn with the configured prompt; - then the last `CONTEXT_MESSAGES` = 20 messages (`src/autoreply.rs:15`), skipping blank texts; the last one is the triggering message; - roles (`turn_role`, `src/autoreply.rs:187-190`): in normal chats own messages are `Assistant`; in the self-chat, where every message is `from_me`, only `is_ai` messages are `Assistant`; - only `User` turns are rewritten (`src/autoreply.rs:229-254`): - 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: ...` (`src/autoreply.rs:209-219`); - any other text is kept as is. `TurnContext { self_chat, vision }` (`src/autoreply.rs:193-199`) carries the two switches. ## 5. Vision path and fallback The image is kept only when the triggering message is a live image and a vision model is configured (`vision_applies`). In that case `spawn_reply` (`src/app.rs:293-316`) 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:653-676`). Without a usable image, a plain text request is spawned. ## 6. Sending `AppEvent::AiReply` is handled by `send_ai_reply_if_enabled` (`src/app.rs:338-359`), 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:365-389`) 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 and turn building are covered by 41 tests in `src/autoreply.rs:282+`; enable rules and vision applicability by `src/app.rs:762+`. See [Unit tests](../testing/unit-tests.md).