ai-autoreply.md 11 KB


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

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

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

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

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.