ai-autoreply.md 13 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 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-9fec9d1af4 resource: repo://src/app.rs
  • id: openwiki-source-043e6252ba resource: repo://src/autoreply.rs
  • id: openwiki-source-ce0c4ea8cd 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; the HTTP call is done by the AI client.

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

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

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

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 "<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: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;
  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 (<vision error>; Fallback cortesia fallito: <error>, 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 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.