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:
9fec9d1af4
resource: repo://src/app.rs043e6252ba
resource: repo://src/autoreply.rs2a737474d8
resource: repo://src/config.rsce0c4ea8cd
resource: repo://src/history.rs
generated: { by: "claude-code", at: "2026-10-21T21:49:12.291Z" }
---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
There are two independent switches, and a chat is enabled when either is
on (autoreply_enabled, src/app.rs:521-527):
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_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:
s.whatsapp.net or lid (is_private_chat,
src/autoreply.rs:76-86);g.us (is_group_chat, src/autoreply.rs:88-97);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: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.
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:
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.
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.
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;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 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).
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).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:
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;CONTEXT_MESSAGES messages, skipping blank texts;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;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);User turns are rewritten (user_content,
src/autoreply.rs:364-379), with plain facts — how to react is left to
SOUL.md:
[Il contatto ha inviato un messaggio vocale che non puoi ascoltare];[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>;no_reply and retriesspawn_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.
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: ...).
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.