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:
9fec9d1af4
resource: repo://src/app.rs043e6252ba
resource: repo://src/autoreply.rs
generated: { by: "claude-code", at: "2026-10-19T19:53:56.480Z" }
verified: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
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:
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 (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).
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 { 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).
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.
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;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:418-449) 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: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).
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: ...).
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.