|
|
@@ -1,17 +1,17 @@
|
|
|
---
|
|
|
type: workflow
|
|
|
title: AI auto-reply workflow
|
|
|
-description: End-to-end trace of the per-chat AI autoreply — enabling it, the ReplyGate rules, building the AI context, the vision path with its courtesy fallback, and sending the reply.
|
|
|
-tags: [ai, autoreply, workflow, vision, reply-gate]
|
|
|
-verified:
|
|
|
- - by: openwiki/0.7.2
|
|
|
- at: 2026-10-19T18:58:46.219Z
|
|
|
+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-9fec9d1af408501c9c455161
|
|
|
resource: repo://src/app.rs
|
|
|
- id: openwiki-source-043e6252badc57a1ee6bfb48
|
|
|
resource: repo://src/autoreply.rs
|
|
|
-generated: { by: "claude-code", at: "2026-10-19T18:58:46.219Z" }
|
|
|
+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
|
|
|
@@ -24,14 +24,22 @@ building are pure functions in `src/autoreply.rs` (no I/O,
|
|
|
[AI client](../integrations/ai-client.md).
|
|
|
|
|
|
```
|
|
|
-WaEvent::Message
|
|
|
+WaEvent::Message { message, image, addressed_to }
|
|
|
-> History::save_message (new only)
|
|
|
-> App::maybe_autoreply
|
|
|
- ReplyGate::should_reply? --no--> stop
|
|
|
- ReplyGate::record_reply
|
|
|
- History::recent_messages(20) -> build_turns
|
|
|
- image + vision model? -> vision request (fallback: text request)
|
|
|
- else -> text request
|
|
|
+ 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
|
|
|
@@ -40,77 +48,169 @@ WaEvent::Message
|
|
|
## 1. Enabling per chat
|
|
|
|
|
|
`Ctrl+A` toggles the flag of the open chat (`toggle_autoreply`,
|
|
|
-`src/app.rs:477-491`). Turning it off is always allowed; turning it on
|
|
|
+`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](../operations/configuration-and-data.md)) and a private
|
|
|
-chat (`can_enable_autoreply`, `src/app.rs:743-751`). The flag is persisted in
|
|
|
-the [history store](../concepts/history-store.md) (`set_autoreply`,
|
|
|
-default off).
|
|
|
+[Configuration](../operations/configuration-and-data.md)) 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](../concepts/history-store.md)
|
|
|
+(`set_autoreply`, default off).
|
|
|
+
|
|
|
+`supports_autoreply` (`src/autoreply.rs:89-97`) accepts private chats and
|
|
|
+groups:
|
|
|
|
|
|
-A chat is private when its JID server is `s.whatsapp.net` or `lid`; groups,
|
|
|
-broadcasts, newsletters and malformed JIDs are not
|
|
|
-(`is_private_chat`, `src/autoreply.rs:48-58`).
|
|
|
+- 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:251-284`), so duplicates and history-sync
|
|
|
+`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, refreshes the own JIDs if unknown and reads
|
|
|
-the chat flag.
|
|
|
+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](../integrations/whatsapp-client.md)).
|
|
|
|
|
|
## 3. ReplyGate rules
|
|
|
|
|
|
-`ReplyGate::should_reply(message, enabled, now)`
|
|
|
-(`src/autoreply.rs:130-137`) returns `true` only when **all** hold:
|
|
|
+`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:115-117`) |
|
|
|
-| Private chat | `is_private_chat` |
|
|
|
+| 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 | at least `COOLDOWN_SECONDS` = 30 s since the last reply in that chat (`src/autoreply.rs:155-159`) |
|
|
|
+| Cooldown | enough time since the last reply in that chat (see below) |
|
|
|
|
|
|
-The self-chat is detected by comparing the chat JID with the account's own
|
|
|
-PN/LID JIDs, ignoring a `:N` device suffix (`src/autoreply.rs:94-107`,
|
|
|
-`src/autoreply.rs:168-178`).
|
|
|
+### 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:
|
|
|
|
|
|
-`record_reply` is called **before** the AI request is spawned, so a second
|
|
|
-message arriving during the request cannot cause a double reply
|
|
|
-(`src/autoreply.rs:145-147`).
|
|
|
+- `group` for a group message **not** addressed to the user;
|
|
|
+- `private` for private chats and for group messages addressed to the user.
|
|
|
|
|
|
-## 4. Building the context
|
|
|
+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](../concepts/history-store.md)).
|
|
|
|
|
|
`build_turns(system_prompt, messages, context)`
|
|
|
-(`src/autoreply.rs:268-280`):
|
|
|
-
|
|
|
-- first a `System` turn with the configured prompt;
|
|
|
-- then the last `CONTEXT_MESSAGES` = 20 messages (`src/autoreply.rs:15`),
|
|
|
- skipping blank texts; the last one is the triggering message;
|
|
|
-- roles (`turn_role`, `src/autoreply.rs:187-190`): in normal chats own
|
|
|
- messages are `Assistant`; in the self-chat, where every message is
|
|
|
- `from_me`, only `is_ai` messages are `Assistant`;
|
|
|
-- only `User` turns are rewritten (`src/autoreply.rs:229-254`):
|
|
|
+(`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: ...` (`src/autoreply.rs:209-219`);
|
|
|
+ appended as `Didascalia: ...`;
|
|
|
- any other text is kept as is.
|
|
|
|
|
|
-`TurnContext { self_chat, vision }` (`src/autoreply.rs:193-199`) carries
|
|
|
+`TurnContext { self_chat, vision }` (`src/autoreply.rs:379-386`) carries
|
|
|
the two switches.
|
|
|
|
|
|
-## 5. Vision path and fallback
|
|
|
+## 7. Vision path and fallback
|
|
|
|
|
|
-The image is kept only when the triggering message is a live image and a
|
|
|
-vision model is configured (`vision_applies`). In that case `spawn_reply`
|
|
|
-(`src/app.rs:293-316`) builds two turn lists — with `vision: true` and,
|
|
|
-as fallback, `vision: false` — and runs one background task that:
|
|
|
+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](../integrations/whatsapp-client.md);
|
|
|
@@ -120,22 +220,23 @@ as fallback, `vision: false` — and runs one background task that:
|
|
|
|
|
|
If both fail, the error combines both messages
|
|
|
(`<vision error>; Fallback cortesia fallito: <error>`,
|
|
|
-`src/app.rs:653-676`). Without a usable image, a plain text request is
|
|
|
-spawned.
|
|
|
+`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](../integrations/ai-client.md)).
|
|
|
|
|
|
-## 6. Sending
|
|
|
+## 8. Sending
|
|
|
|
|
|
`AppEvent::AiReply` is handled by `send_ai_reply_if_enabled`
|
|
|
-(`src/app.rs:338-359`), which re-reads the chat flag: if autoreply was
|
|
|
+(`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:365-389`) stores it as an own message with `is_ai = true`,
|
|
|
+(`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 and turn building are covered by 41 tests in
|
|
|
-`src/autoreply.rs:282+`; enable rules and vision applicability by
|
|
|
-`src/app.rs:762+`. See [Unit tests](../testing/unit-tests.md).
|
|
|
+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](../testing/unit-tests.md).
|