ai-autoreply.md 15 KB


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-9fec9d1af4 resource: repo://src/app.rs
  • id: openwiki-source-043e6252ba resource: repo://src/autoreply.rs
  • id: openwiki-source-2a737474d8 resource: repo://src/config.rs
  • id: openwiki-source-ce0c4ea8cd 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; the HTTP calls are done by the AI client.

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 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) 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 (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).

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). 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): <first error>. 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).

It records the last context message in answered_up_to, replaces every @<own number or LID> 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 "<sender>: ", 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: <text>], without one → [Il contatto ha inviato un'immagine che non puoi vedere]; a caption is appended as \nDidascalia: <caption>;
    • 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 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.