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:
9fec9d1af4
resource: repo://src/app.rs043e6252ba
resource: repo://src/autoreply.rsce0c4ea8cd
resource: repo://src/history.rs
generated: { by: "claude-code", at: "2026-10-20T20:27:06.515Z" }
verified: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
There are two independent switches, and a chat is enabled when either is
on (autoreply_enabled, src/app.rs:366-372):
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_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:
s.whatsapp.net or lid (is_private_chat,
src/autoreply.rs:66-76);g.us (is_group_chat, src/autoreply.rs:78-87);status@broadcast), newsletters and malformed JIDs
are excluded.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).
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 { 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).
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.
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;catch_up_seconds old and older than the app start
(newer messages follow the live path);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.
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):
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;CONTEXT_MESSAGES messages, skipping blank texts; the last
one is the newest message of the chat;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;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);User turns are rewritten (src/autoreply.rs:388-425):
Didascalia: ...;TurnContext { self_chat, vision } (src/autoreply.rs:379-386) carries
the two switches.
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:
complete_with_image with the vision model;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.
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: ...).
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.