|
@@ -3,16 +3,28 @@
|
|
|
//! No I/O, no network, no persistence: callers feed messages and a clock value in,
|
|
//! No I/O, no network, no persistence: callers feed messages and a clock value in,
|
|
|
//! and get a decision or a list of chat turns out.
|
|
//! and get a decision or a list of chat turns out.
|
|
|
|
|
|
|
|
-use std::collections::HashMap;
|
|
|
|
|
|
|
+use std::collections::{HashMap, HashSet};
|
|
|
|
|
+
|
|
|
|
|
+use chrono::{Days, Local, TimeZone};
|
|
|
|
|
|
|
|
use crate::ai::{ChatTurn, Role};
|
|
use crate::ai::{ChatTurn, Role};
|
|
|
use crate::history::StoredMessage;
|
|
use crate::history::StoredMessage;
|
|
|
|
|
|
|
|
-/// Minimum number of seconds between two automatic replies in the same chat.
|
|
|
|
|
-pub const COOLDOWN_SECONDS: i64 = 30;
|
|
|
|
|
|
|
+/// Maximum number of history messages included in the AI context (cap of the
|
|
|
|
|
+/// since-yesterday window, see `context_start`).
|
|
|
|
|
+pub const CONTEXT_MESSAGES: usize = 200;
|
|
|
|
|
+
|
|
|
|
|
+/// Number of seconds in a day, used when local midnight cannot be computed.
|
|
|
|
|
+const SECONDS_PER_DAY: i64 = 86_400;
|
|
|
|
|
+
|
|
|
|
|
+/// Role explanation appended to the system prompt in normal chats.
|
|
|
|
|
+const ROLE_NOTE_CHAT: &str = "Nella conversazione che segue, i messaggi con ruolo assistant sono quelli scritti dal titolare dell'account, per conto del quale rispondi; i messaggi con ruolo user sono del contatto o, nei gruppi, dei partecipanti, indicati con il nome davanti.";
|
|
|
|
|
+
|
|
|
|
|
+/// Role explanation appended to the system prompt in the account owner's self-chat.
|
|
|
|
|
+const ROLE_NOTE_SELF_CHAT: &str = "Questa è la chat personale del titolare dell'account: i messaggi con ruolo user sono scritti da lui, quelli con ruolo assistant sono tue risposte precedenti.";
|
|
|
|
|
|
|
|
-/// Maximum number of history messages included in the AI context.
|
|
|
|
|
-pub const CONTEXT_MESSAGES: usize = 20;
|
|
|
|
|
|
|
+/// Separator between the system prompt and the role explanation.
|
|
|
|
|
+const ROLE_NOTE_SEPARATOR: &str = "\n\n";
|
|
|
|
|
|
|
|
/// Instruction replacing a voice note that triggers the reply.
|
|
/// Instruction replacing a voice note that triggers the reply.
|
|
|
const VOICE_NOTE_INSTRUCTION: &str = "[Il contatto ha inviato un messaggio vocale che non puoi ascoltare. Rispondi con una frase breve e cortese, formulata ogni volta in modo diverso dalle risposte precedenti, dicendo che ora non puoi ascoltare e chiedendo gentilmente di scrivere il messaggio.]";
|
|
const VOICE_NOTE_INSTRUCTION: &str = "[Il contatto ha inviato un messaggio vocale che non puoi ascoltare. Rispondi con una frase breve e cortese, formulata ogni volta in modo diverso dalle risposte precedenti, dicendo che ora non puoi ascoltare e chiedendo gentilmente di scrivere il messaggio.]";
|
|
@@ -39,6 +51,12 @@ const SERVER_USER: &str = "s.whatsapp.net";
|
|
|
/// JID server part identifying one-to-one chats by LID.
|
|
/// JID server part identifying one-to-one chats by LID.
|
|
|
const SERVER_LID: &str = "lid";
|
|
const SERVER_LID: &str = "lid";
|
|
|
|
|
|
|
|
|
|
+/// JID server part identifying group chats.
|
|
|
|
|
+const SERVER_GROUP: &str = "g.us";
|
|
|
|
|
+
|
|
|
|
|
+/// Separator between the sender label and the text in group chat user turns.
|
|
|
|
|
+const SENDER_SEPARATOR: &str = ": ";
|
|
|
|
|
+
|
|
|
/// Separator between the user part and the server part of a JID.
|
|
/// Separator between the user part and the server part of a JID.
|
|
|
const JID_SERVER_SEPARATOR: char = '@';
|
|
const JID_SERVER_SEPARATOR: char = '@';
|
|
|
|
|
|
|
@@ -57,31 +75,70 @@ pub fn is_private_chat(jid: &str) -> bool {
|
|
|
.is_some_and(|(_, server)| server == SERVER_USER || server == SERVER_LID)
|
|
.is_some_and(|(_, server)| server == SERVER_USER || server == SERVER_LID)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/// Returns whether the JID identifies a group chat.
|
|
|
|
|
+///
|
|
|
|
|
+/// Inputs: `jid`, a WhatsApp JID such as `1203630...@g.us`.
|
|
|
|
|
+/// Output: `true` only when the server part is `g.us`; `false` for private
|
|
|
|
|
+/// chats, broadcasts, newsletters, malformed JIDs and any other server.
|
|
|
|
|
+/// Side effects: none.
|
|
|
|
|
+pub fn is_group_chat(jid: &str) -> bool {
|
|
|
|
|
+ jid.rsplit_once(JID_SERVER_SEPARATOR)
|
|
|
|
|
+ .is_some_and(|(_, server)| server == SERVER_GROUP)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Returns whether the autoreply feature can run in the chat.
|
|
|
|
|
+///
|
|
|
|
|
+/// Inputs: `jid`, the chat JID.
|
|
|
|
|
+/// Output: `true` for private chats and groups (see `is_private_chat` and
|
|
|
|
|
+/// `is_group_chat`); `false` for broadcasts, newsletters and anything else.
|
|
|
|
|
+/// Side effects: none.
|
|
|
|
|
+pub fn supports_autoreply(jid: &str) -> bool {
|
|
|
|
|
+ is_private_chat(jid) || is_group_chat(jid)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Minimum number of seconds between two automatic replies in the same chat (both come
|
|
|
|
|
+/// from the configuration, see `Config`).
|
|
|
|
|
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
+pub struct Cooldowns {
|
|
|
|
|
+ /// Cooldown for private chats and for group messages that address the user.
|
|
|
|
|
+ pub private: i64,
|
|
|
|
|
+ /// Cooldown for group messages that do not address the user.
|
|
|
|
|
+ pub group: i64,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/// Decides when an incoming message deserves an automatic AI reply.
|
|
/// Decides when an incoming message deserves an automatic AI reply.
|
|
|
///
|
|
///
|
|
|
-/// Tracks the process start time (to ignore backlog) and the last reply
|
|
|
|
|
-/// instant per chat (to enforce the cooldown).
|
|
|
|
|
|
|
+/// Tracks the process start time (to ignore backlog), the last reply
|
|
|
|
|
+/// instant per chat (to enforce the cooldown) and the private chats that have
|
|
|
|
|
+/// a deferred reply scheduled at the end of the cooldown.
|
|
|
#[derive(Debug, Clone)]
|
|
#[derive(Debug, Clone)]
|
|
|
pub struct ReplyGate {
|
|
pub struct ReplyGate {
|
|
|
/// Unix seconds at which the application started; older messages are backlog.
|
|
/// Unix seconds at which the application started; older messages are backlog.
|
|
|
started_at: i64,
|
|
started_at: i64,
|
|
|
|
|
+ /// Configured minimum seconds between two replies in the same chat.
|
|
|
|
|
+ cooldowns: Cooldowns,
|
|
|
/// Unix seconds of the last triggered reply, keyed by chat JID.
|
|
/// Unix seconds of the last triggered reply, keyed by chat JID.
|
|
|
last_reply_at: HashMap<String, i64>,
|
|
last_reply_at: HashMap<String, i64>,
|
|
|
/// Own JIDs of the logged-in user, stored without device suffix.
|
|
/// Own JIDs of the logged-in user, stored without device suffix.
|
|
|
own_jids: Vec<String>,
|
|
own_jids: Vec<String>,
|
|
|
|
|
+ /// Chat JIDs with a deferred reply already scheduled.
|
|
|
|
|
+ pending: HashSet<String>,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
impl ReplyGate {
|
|
impl ReplyGate {
|
|
|
/// Creates a gate with no recorded replies and no known own JIDs.
|
|
/// Creates a gate with no recorded replies and no known own JIDs.
|
|
|
///
|
|
///
|
|
|
- /// Inputs: `started_at`, unix seconds of application start.
|
|
|
|
|
|
|
+ /// Inputs: `started_at`, unix seconds of application start; `cooldowns`, the
|
|
|
|
|
+ /// configured minimum seconds between replies in the same chat.
|
|
|
/// Output: a new `ReplyGate`.
|
|
/// Output: a new `ReplyGate`.
|
|
|
/// Side effects: none.
|
|
/// Side effects: none.
|
|
|
- pub fn new(started_at: i64) -> ReplyGate {
|
|
|
|
|
|
|
+ pub fn new(started_at: i64, cooldowns: Cooldowns) -> ReplyGate {
|
|
|
ReplyGate {
|
|
ReplyGate {
|
|
|
started_at,
|
|
started_at,
|
|
|
|
|
+ cooldowns,
|
|
|
last_reply_at: HashMap::new(),
|
|
last_reply_at: HashMap::new(),
|
|
|
own_jids: Vec::new(),
|
|
own_jids: Vec::new(),
|
|
|
|
|
+ pending: HashSet::new(),
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -119,43 +176,173 @@ impl ReplyGate {
|
|
|
/// Decides whether `message` must trigger an automatic reply.
|
|
/// Decides whether `message` must trigger an automatic reply.
|
|
|
///
|
|
///
|
|
|
/// Inputs: the incoming `message`, the per-chat `autoreply_enabled` flag,
|
|
/// Inputs: the incoming `message`, the per-chat `autoreply_enabled` flag,
|
|
|
- /// and `now` as unix seconds.
|
|
|
|
|
- /// Output: `true` only if autoreply is enabled, the message is not from
|
|
|
|
|
- /// the user (except human-written messages in the self-chat), the chat is
|
|
|
|
|
- /// private, the message is not older than the start time, the text is
|
|
|
|
|
- /// neither blank nor a media/unsupported placeholder (see
|
|
|
|
|
- /// `crate::whatsapp::is_placeholder`; voice notes and images are not
|
|
|
|
|
- /// placeholders and do trigger), and the chat cooldown has elapsed.
|
|
|
|
|
|
|
+ /// `addressed_to`, the raw JIDs the message mentions or quotes, and `now`
|
|
|
|
|
+ /// as unix seconds.
|
|
|
|
|
+ /// Output: `true` only if every rule of `is_reply_candidate` holds and the
|
|
|
|
|
+ /// chat cooldown has elapsed: `Cooldowns::private`, or `Cooldowns::group`
|
|
|
|
|
+ /// in groups unless the message addresses the user (see `is_addressed`).
|
|
|
/// Side effects: none (call `record_reply` when a reply is triggered).
|
|
/// Side effects: none (call `record_reply` when a reply is triggered).
|
|
|
- pub fn should_reply(&self, message: &StoredMessage, autoreply_enabled: bool, now: i64) -> bool {
|
|
|
|
|
- autoreply_enabled
|
|
|
|
|
- && self.sender_allowed(message)
|
|
|
|
|
- && is_private_chat(&message.chat_jid)
|
|
|
|
|
- && message.timestamp >= self.started_at
|
|
|
|
|
- && !(message.text.trim().is_empty() || crate::whatsapp::is_placeholder(&message.text))
|
|
|
|
|
- && self.cooldown_elapsed(&message.chat_jid, now)
|
|
|
|
|
|
|
+ pub fn should_reply(
|
|
|
|
|
+ &self,
|
|
|
|
|
+ message: &StoredMessage,
|
|
|
|
|
+ autoreply_enabled: bool,
|
|
|
|
|
+ addressed_to: &[String],
|
|
|
|
|
+ now: i64,
|
|
|
|
|
+ ) -> bool {
|
|
|
|
|
+ self.is_reply_candidate(message, autoreply_enabled)
|
|
|
|
|
+ && self.cooldown_elapsed(&message.chat_jid, self.cooldown_for(message, addressed_to), now)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Decides whether `message` deserves a reply deferred to the end of the cooldown.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Inputs: the incoming `message`, the per-chat `autoreply_enabled` flag,
|
|
|
|
|
+ /// `addressed_to`, the raw JIDs the message mentions or quotes, and `now`
|
|
|
|
|
+ /// as unix seconds.
|
|
|
|
|
+ /// Output: `Some(due)`, the unix seconds at which the cooldown ends, only if
|
|
|
|
|
+ /// the message passes every rule of `should_reply` except the cooldown, the
|
|
|
|
|
+ /// chat is not a group, a reply was already recorded for the chat, the
|
|
|
|
|
+ /// cooldown is still running and no deferral is pending for the chat;
|
|
|
|
|
+ /// `None` otherwise (including when `should_reply` would already be `true`).
|
|
|
|
|
+ /// Side effects: none (call `mark_pending` when the deferral is scheduled).
|
|
|
|
|
+ pub fn deferral_due(
|
|
|
|
|
+ &self,
|
|
|
|
|
+ message: &StoredMessage,
|
|
|
|
|
+ autoreply_enabled: bool,
|
|
|
|
|
+ addressed_to: &[String],
|
|
|
|
|
+ now: i64,
|
|
|
|
|
+ ) -> Option<i64> {
|
|
|
|
|
+ let chat_jid = &message.chat_jid;
|
|
|
|
|
+ if is_group_chat(chat_jid)
|
|
|
|
|
+ || self.pending.contains(chat_jid)
|
|
|
|
|
+ || !self.is_reply_candidate(message, autoreply_enabled)
|
|
|
|
|
+ {
|
|
|
|
|
+ return None;
|
|
|
|
|
+ }
|
|
|
|
|
+ let last = self.last_reply_at.get(chat_jid)?;
|
|
|
|
|
+ let due = last.saturating_add(self.cooldown_for(message, addressed_to));
|
|
|
|
|
+ (now < due).then_some(due)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Marks a chat as having a deferred reply scheduled.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Inputs: `chat_jid`, the chat whose reply is deferred.
|
|
|
|
|
+ /// Output: none.
|
|
|
|
|
+ /// Side effects: adds the chat to the pending set, so `deferral_due` returns
|
|
|
|
|
+ /// `None` for it until the deferral is taken or a reply is recorded.
|
|
|
|
|
+ pub fn mark_pending(&mut self, chat_jid: &str) {
|
|
|
|
|
+ self.pending.insert(chat_jid.to_owned());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Consumes the deferred reply of a chat, if any.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Inputs: `chat_jid`, the chat whose deferral timer elapsed.
|
|
|
|
|
+ /// Output: `true` if a deferral was pending (the reply must be sent),
|
|
|
|
|
+ /// `false` if it was already cancelled by `record_reply`.
|
|
|
|
|
+ /// Side effects: removes the chat from the pending set.
|
|
|
|
|
+ pub fn take_pending(&mut self, chat_jid: &str) -> bool {
|
|
|
|
|
+ self.pending.remove(chat_jid)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Records that a reply has been triggered for a chat.
|
|
/// Records that a reply has been triggered for a chat.
|
|
|
///
|
|
///
|
|
|
/// Inputs: `chat_jid`, the chat being answered; `now`, unix seconds.
|
|
/// Inputs: `chat_jid`, the chat being answered; `now`, unix seconds.
|
|
|
/// Output: none.
|
|
/// Output: none.
|
|
|
- /// Side effects: updates the last-reply instant of the chat; call it
|
|
|
|
|
|
|
+ /// Side effects: updates the last-reply instant of the chat and cancels its
|
|
|
|
|
+ /// pending deferral (a fresh reply covers the latest messages); call it
|
|
|
/// before the AI request to prevent double replies.
|
|
/// before the AI request to prevent double replies.
|
|
|
pub fn record_reply(&mut self, chat_jid: &str, now: i64) {
|
|
pub fn record_reply(&mut self, chat_jid: &str, now: i64) {
|
|
|
self.last_reply_at.insert(chat_jid.to_owned(), now);
|
|
self.last_reply_at.insert(chat_jid.to_owned(), now);
|
|
|
|
|
+ self.pending.remove(chat_jid);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Checks every reply rule that does not depend on the cooldown.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Inputs: the incoming `message`; the per-chat `autoreply_enabled` flag.
|
|
|
|
|
+ /// Output: `true` only if every rule of `meets_content_rules` holds and the
|
|
|
|
|
+ /// message is not older than the start time (older messages are backlog).
|
|
|
|
|
+ /// Side effects: none.
|
|
|
|
|
+ fn is_reply_candidate(&self, message: &StoredMessage, autoreply_enabled: bool) -> bool {
|
|
|
|
|
+ message.timestamp >= self.started_at && self.meets_content_rules(message, autoreply_enabled)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Checks every reply rule that depends neither on time nor on the cooldown.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Inputs: the incoming `message`; the per-chat `autoreply_enabled` flag.
|
|
|
|
|
+ /// Output: `true` only if autoreply is enabled, the message is not from
|
|
|
|
|
+ /// the user (except human-written messages in the self-chat), the chat is
|
|
|
|
|
+ /// private or a group, and the text is neither blank nor a media/unsupported
|
|
|
|
|
+ /// placeholder (see `crate::whatsapp::is_placeholder`; voice notes and
|
|
|
|
|
+ /// images are not placeholders and do trigger).
|
|
|
|
|
+ /// Side effects: none.
|
|
|
|
|
+ fn meets_content_rules(&self, message: &StoredMessage, autoreply_enabled: bool) -> bool {
|
|
|
|
|
+ autoreply_enabled
|
|
|
|
|
+ && self.sender_allowed(message)
|
|
|
|
|
+ && supports_autoreply(&message.chat_jid)
|
|
|
|
|
+ && !(message.text.trim().is_empty() || crate::whatsapp::is_placeholder(&message.text))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Decides whether the last message of a chat deserves a one-off catch-up reply.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Inputs: `last`, the latest stored message of the chat; the per-chat
|
|
|
|
|
+ /// `autoreply_enabled` flag; `now`, unix seconds; `window`, the maximum age
|
|
|
|
|
+ /// in seconds of the message (0 disables the catch-up).
|
|
|
|
|
+ /// Output: `true` only if the chat is not a group, `window` is positive, the
|
|
|
|
|
+ /// message is not older than `window`, it arrived before the application
|
|
|
|
|
+ /// start (later messages follow the live path of `should_reply`) and every
|
|
|
|
|
+ /// rule of `meets_content_rules` holds, so a last message written by the
|
|
|
|
|
+ /// user (already answered) never qualifies.
|
|
|
|
|
+ /// Side effects: none (call `record_reply` when a reply is triggered).
|
|
|
|
|
+ pub fn catch_up_due(
|
|
|
|
|
+ &self,
|
|
|
|
|
+ last: &StoredMessage,
|
|
|
|
|
+ autoreply_enabled: bool,
|
|
|
|
|
+ now: i64,
|
|
|
|
|
+ window: i64,
|
|
|
|
|
+ ) -> bool {
|
|
|
|
|
+ window > 0
|
|
|
|
|
+ && !is_group_chat(&last.chat_jid)
|
|
|
|
|
+ && last.timestamp >= now.saturating_sub(window)
|
|
|
|
|
+ && last.timestamp < self.started_at
|
|
|
|
|
+ && self.meets_content_rules(last, autoreply_enabled)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Checks whether the cooldown for a chat has elapsed.
|
|
/// Checks whether the cooldown for a chat has elapsed.
|
|
|
///
|
|
///
|
|
|
- /// Inputs: `chat_jid`, the chat to check; `now`, unix seconds.
|
|
|
|
|
|
|
+ /// Inputs: `chat_jid`, the chat to check; `cooldown_seconds`, the minimum
|
|
|
|
|
+ /// distance between two replies; `now`, unix seconds.
|
|
|
/// Output: `true` if the chat never got a reply or at least
|
|
/// Output: `true` if the chat never got a reply or at least
|
|
|
- /// `COOLDOWN_SECONDS` have passed since the last one.
|
|
|
|
|
|
|
+ /// `cooldown_seconds` have passed since the last one.
|
|
|
/// Side effects: none.
|
|
/// Side effects: none.
|
|
|
- fn cooldown_elapsed(&self, chat_jid: &str, now: i64) -> bool {
|
|
|
|
|
|
|
+ fn cooldown_elapsed(&self, chat_jid: &str, cooldown_seconds: i64, now: i64) -> bool {
|
|
|
self.last_reply_at
|
|
self.last_reply_at
|
|
|
.get(chat_jid)
|
|
.get(chat_jid)
|
|
|
- .is_none_or(|last| now.saturating_sub(*last) >= COOLDOWN_SECONDS)
|
|
|
|
|
|
|
+ .is_none_or(|last| now.saturating_sub(*last) >= cooldown_seconds)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Returns whether a message directly addresses the logged-in user.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Inputs: `addressed_to`, the raw JIDs mentioned or quoted by the message.
|
|
|
|
|
+ /// Output: `true` if any of them equals one of the own JIDs, ignoring a
|
|
|
|
|
+ /// device suffix; `false` when none matches or no own JIDs are known.
|
|
|
|
|
+ /// Side effects: none.
|
|
|
|
|
+ pub fn is_addressed(&self, addressed_to: &[String]) -> bool {
|
|
|
|
|
+ addressed_to
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .any(|jid| self.own_jids.contains(&strip_device_suffix(jid)))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Picks the cooldown that applies to a message.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Inputs: the incoming `message`; `addressed_to`, the raw JIDs it mentions or quotes.
|
|
|
|
|
+ /// Output: `Cooldowns::group` for group messages not addressed to the
|
|
|
|
|
+ /// user, `Cooldowns::private` otherwise.
|
|
|
|
|
+ /// Side effects: none.
|
|
|
|
|
+ fn cooldown_for(&self, message: &StoredMessage, addressed_to: &[String]) -> i64 {
|
|
|
|
|
+ if is_group_chat(&message.chat_jid) && !self.is_addressed(addressed_to) {
|
|
|
|
|
+ self.cooldowns.group
|
|
|
|
|
+ } else {
|
|
|
|
|
+ self.cooldowns.private
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -237,22 +424,78 @@ fn user_content(text: &str, is_last: bool, vision: bool) -> String {
|
|
|
text.to_owned()
|
|
text.to_owned()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/// Names the author of a message, so the AI can tell group participants apart.
|
|
|
|
|
+///
|
|
|
|
|
+/// Inputs: `message`, the history message.
|
|
|
|
|
+/// Output: the trimmed `sender_name` when not blank, otherwise the user part
|
|
|
|
|
+/// of `sender_jid` (without server part and device suffix).
|
|
|
|
|
+/// Side effects: none.
|
|
|
|
|
+fn sender_label(message: &StoredMessage) -> String {
|
|
|
|
|
+ let name = message.sender_name.as_deref().map(str::trim).filter(|name| !name.is_empty());
|
|
|
|
|
+ match name {
|
|
|
|
|
+ Some(name) => name.to_owned(),
|
|
|
|
|
+ None => strip_device_suffix(&message.sender_jid)
|
|
|
|
|
+ .split(JID_SERVER_SEPARATOR)
|
|
|
|
|
+ .next()
|
|
|
|
|
+ .unwrap_or_default()
|
|
|
|
|
+ .to_owned(),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/// Converts a history message into an AI turn.
|
|
/// Converts a history message into an AI turn.
|
|
|
///
|
|
///
|
|
|
/// Inputs: `message`, the history message; `is_last`, whether it is the
|
|
/// Inputs: `message`, the history message; `is_last`, whether it is the
|
|
|
/// triggering message; `context`, the chat description.
|
|
/// triggering message; `context`, the chat description.
|
|
|
/// Output: a turn with the mapped role; only `User` turns get voice/image
|
|
/// Output: a turn with the mapped role; only `User` turns get voice/image
|
|
|
-/// content transformation, `Assistant` turns keep their text.
|
|
|
|
|
|
|
+/// content transformation, `Assistant` turns keep their text. In group chats
|
|
|
|
|
+/// `User` turns are prefixed with `"{sender_label}: "`, since many people
|
|
|
|
|
+/// share the `User` role there.
|
|
|
/// Side effects: none.
|
|
/// Side effects: none.
|
|
|
fn message_to_turn(message: &StoredMessage, is_last: bool, context: TurnContext) -> ChatTurn {
|
|
fn message_to_turn(message: &StoredMessage, is_last: bool, context: TurnContext) -> ChatTurn {
|
|
|
let role = turn_role(message, context.self_chat);
|
|
let role = turn_role(message, context.self_chat);
|
|
|
let content = match role {
|
|
let content = match role {
|
|
|
|
|
+ Role::User if is_group_chat(&message.chat_jid) => {
|
|
|
|
|
+ let text = user_content(&message.text, is_last, context.vision);
|
|
|
|
|
+ format!("{}{SENDER_SEPARATOR}{text}", sender_label(message))
|
|
|
|
|
+ }
|
|
|
Role::User => user_content(&message.text, is_last, context.vision),
|
|
Role::User => user_content(&message.text, is_last, context.vision),
|
|
|
_ => message.text.clone(),
|
|
_ => message.text.clone(),
|
|
|
};
|
|
};
|
|
|
ChatTurn { role, content }
|
|
ChatTurn { role, content }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/// Picks the role explanation for the system turn.
|
|
|
|
|
+///
|
|
|
|
|
+/// Inputs: `self_chat`, whether the chat is the account owner's self-chat.
|
|
|
|
|
+/// Output: the matching note (`ROLE_NOTE_SELF_CHAT` or `ROLE_NOTE_CHAT`).
|
|
|
|
|
+/// Side effects: none.
|
|
|
|
|
+fn role_note(self_chat: bool) -> &'static str {
|
|
|
|
|
+ if self_chat { ROLE_NOTE_SELF_CHAT } else { ROLE_NOTE_CHAT }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Computes the start of the AI context window: local midnight of yesterday.
|
|
|
|
|
+///
|
|
|
|
|
+/// Inputs: `now`, the current time in unix seconds.
|
|
|
|
|
+/// Output: unix seconds of 00:00 (local time) of the day before the local date
|
|
|
|
|
+/// of `now`; falls back to `now` minus two days when the local time cannot be
|
|
|
|
|
+/// resolved (invalid timestamp, DST gap at midnight).
|
|
|
|
|
+/// Side effects: none.
|
|
|
|
|
+pub fn context_start(now: i64) -> i64 {
|
|
|
|
|
+ local_yesterday_midnight(now).unwrap_or(now - 2 * SECONDS_PER_DAY)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Resolves local midnight of the day before `now`, or `None` if it does not exist.
|
|
|
|
|
+///
|
|
|
|
|
+/// Inputs: `now`, the current time in unix seconds.
|
|
|
|
|
+/// Output: unix seconds of yesterday's local midnight.
|
|
|
|
|
+/// Side effects: none.
|
|
|
|
|
+fn local_yesterday_midnight(now: i64) -> Option<i64> {
|
|
|
|
|
+ let today = Local.timestamp_opt(now, 0).single()?.date_naive();
|
|
|
|
|
+ let midnight = today.checked_sub_days(Days::new(1))?.and_hms_opt(0, 0, 0)?;
|
|
|
|
|
+ let start = Local.from_local_datetime(&midnight).earliest()?;
|
|
|
|
|
+ Some(start.timestamp())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/// Builds the ordered list of turns sent to the AI.
|
|
/// Builds the ordered list of turns sent to the AI.
|
|
|
///
|
|
///
|
|
|
/// Inputs: `system_prompt`, the instructions for the assistant; `messages`,
|
|
/// Inputs: `system_prompt`, the instructions for the assistant; `messages`,
|
|
@@ -260,16 +503,19 @@ fn message_to_turn(message: &StoredMessage, is_last: bool, context: TurnContext)
|
|
|
/// chat is the user's self-chat (where `from_me` is true for both the human
|
|
/// chat is the user's self-chat (where `from_me` is true for both the human
|
|
|
/// and the AI, so `is_ai` decides the role) and whether the triggering image
|
|
/// and the AI, so `is_ai` decides the role) and whether the triggering image
|
|
|
/// is sent to a vision-capable model.
|
|
/// is sent to a vision-capable model.
|
|
|
-/// Output: a `System` turn first, then the last `CONTEXT_MESSAGES` messages
|
|
|
|
|
-/// mapped to `Assistant` or `User` (see `turn_role`), skipping messages with
|
|
|
|
|
|
|
+/// Output: a `System` turn first (the prompt followed by a note explaining who
|
|
|
|
|
+/// writes `assistant` and `user` messages, see `role_note`), then the last
|
|
|
|
|
+/// `CONTEXT_MESSAGES` messages mapped to `Assistant` or `User` (see `turn_role`), skipping messages with
|
|
|
/// blank text. User voice notes and images become instructions (see
|
|
/// blank text. User voice notes and images become instructions (see
|
|
|
/// `user_content`); the final message of the window is the triggering one.
|
|
/// `user_content`); the final message of the window is the triggering one.
|
|
|
|
|
+/// In group chats every `User` turn is prefixed with its sender (see
|
|
|
|
|
+/// `message_to_turn`).
|
|
|
/// Side effects: none.
|
|
/// Side effects: none.
|
|
|
pub fn build_turns(system_prompt: &str, messages: &[StoredMessage], context: TurnContext) -> Vec<ChatTurn> {
|
|
pub fn build_turns(system_prompt: &str, messages: &[StoredMessage], context: TurnContext) -> Vec<ChatTurn> {
|
|
|
let window = &messages[messages.len().saturating_sub(CONTEXT_MESSAGES)..];
|
|
let window = &messages[messages.len().saturating_sub(CONTEXT_MESSAGES)..];
|
|
|
let system = ChatTurn {
|
|
let system = ChatTurn {
|
|
|
role: Role::System,
|
|
role: Role::System,
|
|
|
- content: system_prompt.to_owned(),
|
|
|
|
|
|
|
+ content: format!("{system_prompt}{ROLE_NOTE_SEPARATOR}{}", role_note(context.self_chat)),
|
|
|
};
|
|
};
|
|
|
let history = window
|
|
let history = window
|
|
|
.iter()
|
|
.iter()
|
|
@@ -281,6 +527,8 @@ pub fn build_turns(system_prompt: &str, messages: &[StoredMessage], context: Tur
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
#[cfg(test)]
|
|
|
mod tests {
|
|
mod tests {
|
|
|
|
|
+ use chrono::Timelike;
|
|
|
|
|
+
|
|
|
use super::*;
|
|
use super::*;
|
|
|
|
|
|
|
|
const PRIVATE_JID: &str = "393331234567@s.whatsapp.net";
|
|
const PRIVATE_JID: &str = "393331234567@s.whatsapp.net";
|
|
@@ -291,6 +539,10 @@ mod tests {
|
|
|
const NEWSLETTER_JID: &str = "120363111111111111@newsletter";
|
|
const NEWSLETTER_JID: &str = "120363111111111111@newsletter";
|
|
|
const STARTED_AT: i64 = 1_000;
|
|
const STARTED_AT: i64 = 1_000;
|
|
|
const NOW: i64 = 2_000;
|
|
const NOW: i64 = 2_000;
|
|
|
|
|
+ const TEST_COOLDOWN_SECONDS: i64 = 30;
|
|
|
|
|
+ const TEST_GROUP_COOLDOWN_SECONDS: i64 = 300;
|
|
|
|
|
+ const TEST_COOLDOWNS: Cooldowns =
|
|
|
|
|
+ Cooldowns { private: TEST_COOLDOWN_SECONDS, group: TEST_GROUP_COOLDOWN_SECONDS };
|
|
|
const SYSTEM_PROMPT: &str = "be nice";
|
|
const SYSTEM_PROMPT: &str = "be nice";
|
|
|
const SELF_CHAT: TurnContext = TurnContext { self_chat: true, vision: false };
|
|
const SELF_CHAT: TurnContext = TurnContext { self_chat: true, vision: false };
|
|
|
const WITH_VISION: TurnContext = TurnContext { self_chat: false, vision: true };
|
|
const WITH_VISION: TurnContext = TurnContext { self_chat: false, vision: true };
|
|
@@ -339,28 +591,51 @@ mod tests {
|
|
|
assert!(!is_private_chat(""));
|
|
assert!(!is_private_chat(""));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn group_chat_accepts_only_group_server() {
|
|
|
|
|
+ assert!(is_group_chat(GROUP_JID));
|
|
|
|
|
+ assert!(!is_group_chat(PRIVATE_JID));
|
|
|
|
|
+ assert!(!is_group_chat(LID_JID));
|
|
|
|
|
+ assert!(!is_group_chat(STATUS_JID));
|
|
|
|
|
+ assert!(!is_group_chat(NEWSLETTER_JID));
|
|
|
|
|
+ assert!(!is_group_chat("no-separator"));
|
|
|
|
|
+ assert!(!is_group_chat(""));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn supports_autoreply_accepts_private_and_group_chats() {
|
|
|
|
|
+ assert!(supports_autoreply(PRIVATE_JID));
|
|
|
|
|
+ assert!(supports_autoreply(LID_JID));
|
|
|
|
|
+ assert!(supports_autoreply(GROUP_JID));
|
|
|
|
|
+ assert!(!supports_autoreply(STATUS_JID));
|
|
|
|
|
+ assert!(!supports_autoreply(NEWSLETTER_JID));
|
|
|
|
|
+ assert!(!supports_autoreply("123@other.example"));
|
|
|
|
|
+ assert!(!supports_autoreply("no-separator"));
|
|
|
|
|
+ assert!(!supports_autoreply(""));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#[test]
|
|
#[test]
|
|
|
fn should_reply_happy_path() {
|
|
fn should_reply_happy_path() {
|
|
|
- let gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
- assert!(gate.should_reply(&incoming(), true, NOW));
|
|
|
|
|
|
|
+ let gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
|
|
+ assert!(gate.should_reply(&incoming(), true, &[], NOW));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn should_reply_false_when_disabled() {
|
|
fn should_reply_false_when_disabled() {
|
|
|
- let gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
- assert!(!gate.should_reply(&incoming(), false, NOW));
|
|
|
|
|
|
|
+ let gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
|
|
+ assert!(!gate.should_reply(&incoming(), false, &[], NOW));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn should_reply_false_when_from_me() {
|
|
fn should_reply_false_when_from_me() {
|
|
|
- let gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
|
|
+ let gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
let message = StoredMessage { from_me: true, ..incoming() };
|
|
let message = StoredMessage { from_me: true, ..incoming() };
|
|
|
- assert!(!gate.should_reply(&message, true, NOW));
|
|
|
|
|
|
|
+ assert!(!gate.should_reply(&message, true, &[], NOW));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Builds a gate that knows `PRIVATE_JID` as the user's own JID.
|
|
/// Builds a gate that knows `PRIVATE_JID` as the user's own JID.
|
|
|
fn gate_with_own_jid() -> ReplyGate {
|
|
fn gate_with_own_jid() -> ReplyGate {
|
|
|
- let mut gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
|
|
+ let mut gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
gate.set_own_jids(vec![PRIVATE_JID.to_owned()]);
|
|
gate.set_own_jids(vec![PRIVATE_JID.to_owned()]);
|
|
|
gate
|
|
gate
|
|
|
}
|
|
}
|
|
@@ -372,7 +647,7 @@ mod tests {
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn is_self_chat_ignores_device_suffix() {
|
|
fn is_self_chat_ignores_device_suffix() {
|
|
|
- let mut gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
|
|
+ let mut gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
gate.set_own_jids(vec!["393331234567:12@s.whatsapp.net".to_owned()]);
|
|
gate.set_own_jids(vec!["393331234567:12@s.whatsapp.net".to_owned()]);
|
|
|
assert!(gate.is_self_chat(PRIVATE_JID));
|
|
assert!(gate.is_self_chat(PRIVATE_JID));
|
|
|
assert!(gate_with_own_jid().is_self_chat("393331234567:7@s.whatsapp.net"));
|
|
assert!(gate_with_own_jid().is_self_chat("393331234567:7@s.whatsapp.net"));
|
|
@@ -380,7 +655,7 @@ mod tests {
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn is_self_chat_false_without_own_jids() {
|
|
fn is_self_chat_false_without_own_jids() {
|
|
|
- assert!(!ReplyGate::new(STARTED_AT).is_self_chat(PRIVATE_JID));
|
|
|
|
|
|
|
+ assert!(!ReplyGate::new(STARTED_AT, TEST_COOLDOWNS).is_self_chat(PRIVATE_JID));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
@@ -393,7 +668,7 @@ mod tests {
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn is_self_chat_matches_any_of_several_own_jids() {
|
|
fn is_self_chat_matches_any_of_several_own_jids() {
|
|
|
- let mut gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
|
|
+ let mut gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
gate.set_own_jids(vec![PRIVATE_JID.to_owned(), LID_JID.to_owned()]);
|
|
gate.set_own_jids(vec![PRIVATE_JID.to_owned(), LID_JID.to_owned()]);
|
|
|
assert!(gate.is_self_chat(LID_JID));
|
|
assert!(gate.is_self_chat(LID_JID));
|
|
|
assert!(gate.is_self_chat(PRIVATE_JID));
|
|
assert!(gate.is_self_chat(PRIVATE_JID));
|
|
@@ -402,13 +677,13 @@ mod tests {
|
|
|
#[test]
|
|
#[test]
|
|
|
fn should_reply_true_for_human_from_me_in_self_chat() {
|
|
fn should_reply_true_for_human_from_me_in_self_chat() {
|
|
|
let message = StoredMessage { from_me: true, ..incoming() };
|
|
let message = StoredMessage { from_me: true, ..incoming() };
|
|
|
- assert!(gate_with_own_jid().should_reply(&message, true, NOW));
|
|
|
|
|
|
|
+ assert!(gate_with_own_jid().should_reply(&message, true, &[], NOW));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn should_reply_false_for_ai_message_in_self_chat() {
|
|
fn should_reply_false_for_ai_message_in_self_chat() {
|
|
|
let message = StoredMessage { from_me: true, is_ai: true, ..incoming() };
|
|
let message = StoredMessage { from_me: true, is_ai: true, ..incoming() };
|
|
|
- assert!(!gate_with_own_jid().should_reply(&message, true, NOW));
|
|
|
|
|
|
|
+ assert!(!gate_with_own_jid().should_reply(&message, true, &[], NOW));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
@@ -418,95 +693,362 @@ mod tests {
|
|
|
chat_jid: OTHER_PRIVATE_JID.to_owned(),
|
|
chat_jid: OTHER_PRIVATE_JID.to_owned(),
|
|
|
..incoming()
|
|
..incoming()
|
|
|
};
|
|
};
|
|
|
- assert!(!gate_with_own_jid().should_reply(&message, true, NOW));
|
|
|
|
|
|
|
+ assert!(!gate_with_own_jid().should_reply(&message, true, &[], NOW));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn should_reply_self_chat_still_honors_other_rules() {
|
|
fn should_reply_self_chat_still_honors_other_rules() {
|
|
|
let message = StoredMessage { from_me: true, ..incoming() };
|
|
let message = StoredMessage { from_me: true, ..incoming() };
|
|
|
let mut gate = gate_with_own_jid();
|
|
let mut gate = gate_with_own_jid();
|
|
|
- assert!(!gate.should_reply(&message, false, NOW));
|
|
|
|
|
|
|
+ assert!(!gate.should_reply(&message, false, &[], NOW));
|
|
|
gate.record_reply(PRIVATE_JID, NOW);
|
|
gate.record_reply(PRIVATE_JID, NOW);
|
|
|
- assert!(!gate.should_reply(&message, true, NOW));
|
|
|
|
|
|
|
+ assert!(!gate.should_reply(&message, true, &[], NOW));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
- fn should_reply_false_for_non_private_chats() {
|
|
|
|
|
- let gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
- for jid in [GROUP_JID, STATUS_JID, NEWSLETTER_JID] {
|
|
|
|
|
|
|
+ fn should_reply_accepts_groups_but_not_broadcasts_or_newsletters() {
|
|
|
|
|
+ let gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
|
|
+ let group = StoredMessage { chat_jid: GROUP_JID.to_owned(), ..incoming() };
|
|
|
|
|
+ assert!(gate.should_reply(&group, true, &[], NOW));
|
|
|
|
|
+ for jid in [STATUS_JID, NEWSLETTER_JID] {
|
|
|
let message = StoredMessage { chat_jid: jid.to_owned(), ..incoming() };
|
|
let message = StoredMessage { chat_jid: jid.to_owned(), ..incoming() };
|
|
|
- assert!(!gate.should_reply(&message, true, NOW), "jid {jid}");
|
|
|
|
|
|
|
+ assert!(!gate.should_reply(&message, true, &[], NOW), "jid {jid}");
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn should_reply_group_without_mention_waits_group_cooldown() {
|
|
|
|
|
+ let mut gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
|
|
+ let group = StoredMessage { chat_jid: GROUP_JID.to_owned(), ..incoming() };
|
|
|
|
|
+ gate.record_reply(GROUP_JID, NOW);
|
|
|
|
|
+ assert!(!gate.should_reply(&group, true, &[], NOW));
|
|
|
|
|
+ assert!(!gate.should_reply(&group, true, &[], NOW + TEST_COOLDOWN_SECONDS));
|
|
|
|
|
+ assert!(!gate.should_reply(&group, true, &[], NOW + TEST_GROUP_COOLDOWN_SECONDS - 1));
|
|
|
|
|
+ assert!(gate.should_reply(&group, true, &[], NOW + TEST_GROUP_COOLDOWN_SECONDS));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn should_reply_group_mention_of_own_jid_uses_short_cooldown() {
|
|
|
|
|
+ let mut gate = gate_with_own_jid();
|
|
|
|
|
+ let group = StoredMessage { chat_jid: GROUP_JID.to_owned(), ..incoming() };
|
|
|
|
|
+ let mention = [format!("393331234567:5{JID_SERVER_SEPARATOR}{SERVER_USER}")];
|
|
|
|
|
+ gate.record_reply(GROUP_JID, NOW);
|
|
|
|
|
+ assert!(!gate.should_reply(&group, true, &mention, NOW + TEST_COOLDOWN_SECONDS - 1));
|
|
|
|
|
+ assert!(gate.should_reply(&group, true, &mention, NOW + TEST_COOLDOWN_SECONDS));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn should_reply_group_mention_of_someone_else_does_not_bypass() {
|
|
|
|
|
+ let mut gate = gate_with_own_jid();
|
|
|
|
|
+ let group = StoredMessage { chat_jid: GROUP_JID.to_owned(), ..incoming() };
|
|
|
|
|
+ let mention = [OTHER_PRIVATE_JID.to_owned()];
|
|
|
|
|
+ gate.record_reply(GROUP_JID, NOW);
|
|
|
|
|
+ assert!(!gate.should_reply(&group, true, &mention, NOW + TEST_COOLDOWN_SECONDS));
|
|
|
|
|
+ assert!(gate.should_reply(&group, true, &mention, NOW + TEST_GROUP_COOLDOWN_SECONDS));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn should_reply_private_chat_keeps_short_cooldown_regardless_of_mentions() {
|
|
|
|
|
+ let mut gate = gate_with_own_jid();
|
|
|
|
|
+ gate.record_reply(PRIVATE_JID, NOW);
|
|
|
|
|
+ assert!(!gate.should_reply(&incoming(), true, &[], NOW + TEST_COOLDOWN_SECONDS - 1));
|
|
|
|
|
+ assert!(gate.should_reply(&incoming(), true, &[], NOW + TEST_COOLDOWN_SECONDS));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn is_addressed_matches_own_jids_ignoring_device_suffix() {
|
|
|
|
|
+ let gate = gate_with_own_jid();
|
|
|
|
|
+ assert!(gate.is_addressed(&[OTHER_PRIVATE_JID.to_owned(), "393331234567:5@s.whatsapp.net".to_owned()]));
|
|
|
|
|
+ assert!(!gate.is_addressed(&[OTHER_PRIVATE_JID.to_owned()]));
|
|
|
|
|
+ assert!(!gate.is_addressed(&[]));
|
|
|
|
|
+ assert!(!ReplyGate::new(STARTED_AT, TEST_COOLDOWNS).is_addressed(&[PRIVATE_JID.to_owned()]));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#[test]
|
|
#[test]
|
|
|
fn should_reply_false_for_backlog_timestamp() {
|
|
fn should_reply_false_for_backlog_timestamp() {
|
|
|
- let gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
|
|
+ let gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
let message = StoredMessage { timestamp: STARTED_AT - 1, ..incoming() };
|
|
let message = StoredMessage { timestamp: STARTED_AT - 1, ..incoming() };
|
|
|
- assert!(!gate.should_reply(&message, true, NOW));
|
|
|
|
|
|
|
+ assert!(!gate.should_reply(&message, true, &[], NOW));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn should_reply_true_at_exact_start_timestamp() {
|
|
fn should_reply_true_at_exact_start_timestamp() {
|
|
|
- let gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
|
|
+ let gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
let message = StoredMessage { timestamp: STARTED_AT, ..incoming() };
|
|
let message = StoredMessage { timestamp: STARTED_AT, ..incoming() };
|
|
|
- assert!(gate.should_reply(&message, true, NOW));
|
|
|
|
|
|
|
+ assert!(gate.should_reply(&message, true, &[], NOW));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn should_reply_false_for_blank_text() {
|
|
fn should_reply_false_for_blank_text() {
|
|
|
- let gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
|
|
+ let gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
for text in ["", " ", "\n\t "] {
|
|
for text in ["", " ", "\n\t "] {
|
|
|
let message = StoredMessage { text: text.to_owned(), ..incoming() };
|
|
let message = StoredMessage { text: text.to_owned(), ..incoming() };
|
|
|
- assert!(!gate.should_reply(&message, true, NOW), "text {text:?}");
|
|
|
|
|
|
|
+ assert!(!gate.should_reply(&message, true, &[], NOW), "text {text:?}");
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn should_reply_false_for_placeholder_text() {
|
|
fn should_reply_false_for_placeholder_text() {
|
|
|
- let gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
|
|
+ let gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
let message = StoredMessage { text: "[media]".to_owned(), ..incoming() };
|
|
let message = StoredMessage { text: "[media]".to_owned(), ..incoming() };
|
|
|
- assert!(!gate.should_reply(&message, true, NOW));
|
|
|
|
|
|
|
+ assert!(!gate.should_reply(&message, true, &[], NOW));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn should_reply_true_for_voice_note_and_image() {
|
|
fn should_reply_true_for_voice_note_and_image() {
|
|
|
- let gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
|
|
+ let gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
for text in [VOICE_TEXT, IMAGE_TEXT, IMAGE_WITH_CAPTION] {
|
|
for text in [VOICE_TEXT, IMAGE_TEXT, IMAGE_WITH_CAPTION] {
|
|
|
let message = StoredMessage { text: text.to_owned(), ..incoming() };
|
|
let message = StoredMessage { text: text.to_owned(), ..incoming() };
|
|
|
- assert!(gate.should_reply(&message, true, NOW), "text {text:?}");
|
|
|
|
|
|
|
+ assert!(gate.should_reply(&message, true, &[], NOW), "text {text:?}");
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn should_reply_false_for_unsupported_placeholders() {
|
|
fn should_reply_false_for_unsupported_placeholders() {
|
|
|
- let gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
|
|
+ let gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
for text in ["[media]", "[messaggio non supportato]"] {
|
|
for text in ["[media]", "[messaggio non supportato]"] {
|
|
|
let message = StoredMessage { text: text.to_owned(), ..incoming() };
|
|
let message = StoredMessage { text: text.to_owned(), ..incoming() };
|
|
|
- assert!(!gate.should_reply(&message, true, NOW), "text {text:?}");
|
|
|
|
|
|
|
+ assert!(!gate.should_reply(&message, true, &[], NOW), "text {text:?}");
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn should_reply_false_during_cooldown() {
|
|
fn should_reply_false_during_cooldown() {
|
|
|
- let mut gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
|
|
+ let mut gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
gate.record_reply(PRIVATE_JID, NOW);
|
|
gate.record_reply(PRIVATE_JID, NOW);
|
|
|
- assert!(!gate.should_reply(&incoming(), true, NOW));
|
|
|
|
|
- assert!(!gate.should_reply(&incoming(), true, NOW + COOLDOWN_SECONDS - 1));
|
|
|
|
|
|
|
+ assert!(!gate.should_reply(&incoming(), true, &[], NOW));
|
|
|
|
|
+ assert!(!gate.should_reply(&incoming(), true, &[], NOW + TEST_COOLDOWN_SECONDS - 1));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn should_reply_true_once_cooldown_elapsed() {
|
|
fn should_reply_true_once_cooldown_elapsed() {
|
|
|
- let mut gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
|
|
+ let mut gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
|
|
+ gate.record_reply(PRIVATE_JID, NOW);
|
|
|
|
|
+ assert!(gate.should_reply(&incoming(), true, &[], NOW + TEST_COOLDOWN_SECONDS));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Builds a gate with a reply recorded at `NOW` in the private chat.
|
|
|
|
|
+ fn gate_in_cooldown() -> ReplyGate {
|
|
|
|
|
+ let mut gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
gate.record_reply(PRIVATE_JID, NOW);
|
|
gate.record_reply(PRIVATE_JID, NOW);
|
|
|
- assert!(gate.should_reply(&incoming(), true, NOW + COOLDOWN_SECONDS));
|
|
|
|
|
|
|
+ gate
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn deferral_due_returns_cooldown_end_in_private_chat() {
|
|
|
|
|
+ let gate = gate_in_cooldown();
|
|
|
|
|
+ assert_eq!(gate.deferral_due(&incoming(), true, &[], NOW), Some(NOW + TEST_COOLDOWN_SECONDS));
|
|
|
|
|
+ let last_second = NOW + TEST_COOLDOWN_SECONDS - 1;
|
|
|
|
|
+ assert_eq!(gate.deferral_due(&incoming(), true, &[], last_second), Some(NOW + TEST_COOLDOWN_SECONDS));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn deferral_due_works_in_self_chat() {
|
|
|
|
|
+ let mut gate = gate_with_own_jid();
|
|
|
|
|
+ gate.record_reply(PRIVATE_JID, NOW);
|
|
|
|
|
+ let message = StoredMessage { from_me: true, ..incoming() };
|
|
|
|
|
+ assert_eq!(gate.deferral_due(&message, true, &[], NOW), Some(NOW + TEST_COOLDOWN_SECONDS));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn deferral_due_none_in_groups() {
|
|
|
|
|
+ let mut gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
|
|
+ gate.record_reply(GROUP_JID, NOW);
|
|
|
|
|
+ let group = StoredMessage { chat_jid: GROUP_JID.to_owned(), ..incoming() };
|
|
|
|
|
+ assert_eq!(gate.deferral_due(&group, true, &[], NOW), None);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn deferral_due_none_when_cooldown_elapsed() {
|
|
|
|
|
+ let gate = gate_in_cooldown();
|
|
|
|
|
+ let later = NOW + TEST_COOLDOWN_SECONDS;
|
|
|
|
|
+ assert_eq!(gate.deferral_due(&incoming(), true, &[], later), None);
|
|
|
|
|
+ assert!(gate.should_reply(&incoming(), true, &[], later));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn deferral_due_none_without_previous_reply() {
|
|
|
|
|
+ let gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
|
|
+ assert_eq!(gate.deferral_due(&incoming(), true, &[], NOW), None);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn deferral_due_none_when_already_pending() {
|
|
|
|
|
+ let mut gate = gate_in_cooldown();
|
|
|
|
|
+ gate.mark_pending(PRIVATE_JID);
|
|
|
|
|
+ assert_eq!(gate.deferral_due(&incoming(), true, &[], NOW), None);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn deferral_due_ignores_pending_of_other_chats() {
|
|
|
|
|
+ let mut gate = gate_in_cooldown();
|
|
|
|
|
+ gate.mark_pending(OTHER_PRIVATE_JID);
|
|
|
|
|
+ assert!(gate.deferral_due(&incoming(), true, &[], NOW).is_some());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn deferral_due_none_when_disabled() {
|
|
|
|
|
+ assert_eq!(gate_in_cooldown().deferral_due(&incoming(), false, &[], NOW), None);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn deferral_due_none_for_from_me_message() {
|
|
|
|
|
+ let message = StoredMessage { from_me: true, ..incoming() };
|
|
|
|
|
+ assert_eq!(gate_in_cooldown().deferral_due(&message, true, &[], NOW), None);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn deferral_due_none_for_placeholder_and_blank_text() {
|
|
|
|
|
+ let gate = gate_in_cooldown();
|
|
|
|
|
+ for text in ["[media]", "[messaggio non supportato]", " "] {
|
|
|
|
|
+ let message = StoredMessage { text: text.to_owned(), ..incoming() };
|
|
|
|
|
+ assert_eq!(gate.deferral_due(&message, true, &[], NOW), None, "text {text:?}");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn deferral_due_none_for_backlog_timestamp() {
|
|
|
|
|
+ let message = StoredMessage { timestamp: STARTED_AT - 1, ..incoming() };
|
|
|
|
|
+ assert_eq!(gate_in_cooldown().deferral_due(&message, true, &[], NOW), None);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn deferral_due_none_for_unsupported_chat() {
|
|
|
|
|
+ let mut gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
|
|
+ gate.record_reply(STATUS_JID, NOW);
|
|
|
|
|
+ let message = StoredMessage { chat_jid: STATUS_JID.to_owned(), ..incoming() };
|
|
|
|
|
+ assert_eq!(gate.deferral_due(&message, true, &[], NOW), None);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const TEST_CATCH_UP_WINDOW: i64 = 600;
|
|
|
|
|
+ /// Start time of the gate in the catch-up tests, far from `STARTED_AT` to leave room for the window.
|
|
|
|
|
+ const CATCH_UP_STARTED_AT: i64 = 10_000;
|
|
|
|
|
+ const CATCH_UP_NOW: i64 = CATCH_UP_STARTED_AT + 60;
|
|
|
|
|
+
|
|
|
|
|
+ /// Builds an unanswered incoming message received shortly before the start.
|
|
|
|
|
+ fn backlog_message() -> StoredMessage {
|
|
|
|
|
+ StoredMessage { timestamp: CATCH_UP_STARTED_AT - 60, ..incoming() }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Builds a gate started at `CATCH_UP_STARTED_AT`.
|
|
|
|
|
+ fn catch_up_gate() -> ReplyGate {
|
|
|
|
|
+ ReplyGate::new(CATCH_UP_STARTED_AT, TEST_COOLDOWNS)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Runs `catch_up_due` with the test clock and window.
|
|
|
|
|
+ fn catch_up(gate: &ReplyGate, last: &StoredMessage, enabled: bool) -> bool {
|
|
|
|
|
+ gate.catch_up_due(last, enabled, CATCH_UP_NOW, TEST_CATCH_UP_WINDOW)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn catch_up_due_true_for_recent_incoming_private_message_before_start() {
|
|
|
|
|
+ assert!(catch_up(&catch_up_gate(), &backlog_message(), true));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn catch_up_due_false_when_last_message_is_from_me() {
|
|
|
|
|
+ let message = StoredMessage { from_me: true, ..backlog_message() };
|
|
|
|
|
+ assert!(!catch_up(&catch_up_gate(), &message, true));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn catch_up_due_false_in_groups() {
|
|
|
|
|
+ let message = StoredMessage { chat_jid: GROUP_JID.to_owned(), ..backlog_message() };
|
|
|
|
|
+ assert!(!catch_up(&catch_up_gate(), &message, true));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn catch_up_due_false_when_older_than_window() {
|
|
|
|
|
+ let gate = catch_up_gate();
|
|
|
|
|
+ let edge = StoredMessage { timestamp: CATCH_UP_NOW - TEST_CATCH_UP_WINDOW, ..incoming() };
|
|
|
|
|
+ let stale = StoredMessage { timestamp: CATCH_UP_NOW - TEST_CATCH_UP_WINDOW - 1, ..incoming() };
|
|
|
|
|
+ assert!(catch_up(&gate, &edge, true));
|
|
|
|
|
+ assert!(!catch_up(&gate, &stale, true));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn catch_up_due_false_when_window_is_zero() {
|
|
|
|
|
+ let due = catch_up_gate().catch_up_due(&backlog_message(), true, CATCH_UP_NOW, 0);
|
|
|
|
|
+ assert!(!due);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn catch_up_due_false_when_message_is_not_before_start() {
|
|
|
|
|
+ let gate = catch_up_gate();
|
|
|
|
|
+ for timestamp in [CATCH_UP_STARTED_AT, CATCH_UP_NOW] {
|
|
|
|
|
+ let message = StoredMessage { timestamp, ..incoming() };
|
|
|
|
|
+ assert!(!catch_up(&gate, &message, true), "timestamp {timestamp}");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn catch_up_due_false_for_placeholder_and_blank_text() {
|
|
|
|
|
+ for text in ["[media]", "[messaggio non supportato]", " "] {
|
|
|
|
|
+ let message = StoredMessage { text: text.to_owned(), ..backlog_message() };
|
|
|
|
|
+ assert!(!catch_up(&catch_up_gate(), &message, true), "text {text:?}");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn catch_up_due_false_when_disabled_or_unsupported_chat() {
|
|
|
|
|
+ assert!(!catch_up(&catch_up_gate(), &backlog_message(), false));
|
|
|
|
|
+ let status = StoredMessage { chat_jid: STATUS_JID.to_owned(), ..backlog_message() };
|
|
|
|
|
+ assert!(!catch_up(&catch_up_gate(), &status, true));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn catch_up_due_honours_self_chat_rule() {
|
|
|
|
|
+ let mut gate = catch_up_gate();
|
|
|
|
|
+ gate.set_own_jids(vec![PRIVATE_JID.to_owned()]);
|
|
|
|
|
+ let human = StoredMessage { from_me: true, ..backlog_message() };
|
|
|
|
|
+ let ai = StoredMessage { is_ai: true, ..human.clone() };
|
|
|
|
|
+ assert!(catch_up(&gate, &human, true));
|
|
|
|
|
+ assert!(!catch_up(&gate, &ai, true));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn record_reply_clears_pending() {
|
|
|
|
|
+ let mut gate = gate_in_cooldown();
|
|
|
|
|
+ gate.mark_pending(PRIVATE_JID);
|
|
|
|
|
+ gate.record_reply(PRIVATE_JID, NOW + TEST_COOLDOWN_SECONDS);
|
|
|
|
|
+ assert!(!gate.take_pending(PRIVATE_JID));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn record_reply_keeps_pending_of_other_chats() {
|
|
|
|
|
+ let mut gate = gate_in_cooldown();
|
|
|
|
|
+ gate.mark_pending(OTHER_PRIVATE_JID);
|
|
|
|
|
+ gate.record_reply(PRIVATE_JID, NOW);
|
|
|
|
|
+ assert!(gate.take_pending(OTHER_PRIVATE_JID));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn take_pending_consumes_the_deferral_once() {
|
|
|
|
|
+ let mut gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
|
|
+ assert!(!gate.take_pending(PRIVATE_JID));
|
|
|
|
|
+ gate.mark_pending(PRIVATE_JID);
|
|
|
|
|
+ assert!(gate.take_pending(PRIVATE_JID));
|
|
|
|
|
+ assert!(!gate.take_pending(PRIVATE_JID));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn cooldown_is_tracked_per_chat() {
|
|
fn cooldown_is_tracked_per_chat() {
|
|
|
- let mut gate = ReplyGate::new(STARTED_AT);
|
|
|
|
|
|
|
+ let mut gate = ReplyGate::new(STARTED_AT, TEST_COOLDOWNS);
|
|
|
gate.record_reply(OTHER_PRIVATE_JID, NOW);
|
|
gate.record_reply(OTHER_PRIVATE_JID, NOW);
|
|
|
- assert!(gate.should_reply(&incoming(), true, NOW));
|
|
|
|
|
|
|
+ assert!(gate.should_reply(&incoming(), true, &[], NOW));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn custom_cooldowns_are_honoured_including_zero() {
|
|
|
|
|
+ const CUSTOM: Cooldowns = Cooldowns { private: 10, group: 0 };
|
|
|
|
|
+ let mut gate = ReplyGate::new(STARTED_AT, CUSTOM);
|
|
|
|
|
+ gate.record_reply(PRIVATE_JID, NOW);
|
|
|
|
|
+ assert!(!gate.should_reply(&incoming(), true, &[], NOW + CUSTOM.private - 1));
|
|
|
|
|
+ assert!(gate.should_reply(&incoming(), true, &[], NOW + CUSTOM.private));
|
|
|
|
|
+
|
|
|
|
|
+ let group = StoredMessage { chat_jid: GROUP_JID.to_owned(), ..incoming() };
|
|
|
|
|
+ gate.record_reply(GROUP_JID, NOW);
|
|
|
|
|
+ assert!(gate.should_reply(&group, true, &[], NOW));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
@@ -515,10 +1057,23 @@ mod tests {
|
|
|
assert_eq!(turns.len(), 2);
|
|
assert_eq!(turns.len(), 2);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
turns[0],
|
|
turns[0],
|
|
|
- ChatTurn { role: Role::System, content: SYSTEM_PROMPT.to_owned() }
|
|
|
|
|
|
|
+ ChatTurn { role: Role::System, content: system_content(ROLE_NOTE_CHAT) }
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// Returns the expected system turn content for `SYSTEM_PROMPT` with `note`.
|
|
|
|
|
+ fn system_content(note: &str) -> String {
|
|
|
|
|
+ format!("{SYSTEM_PROMPT}{ROLE_NOTE_SEPARATOR}{note}")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn build_turns_system_note_depends_on_self_chat() {
|
|
|
|
|
+ let normal = build_turns(SYSTEM_PROMPT, &[], TurnContext::default());
|
|
|
|
|
+ let own = build_turns(SYSTEM_PROMPT, &[], SELF_CHAT);
|
|
|
|
|
+ assert_eq!(normal[0].content, system_content(ROLE_NOTE_CHAT));
|
|
|
|
|
+ assert_eq!(own[0].content, system_content(ROLE_NOTE_SELF_CHAT));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#[test]
|
|
#[test]
|
|
|
fn build_turns_with_no_messages_has_only_system() {
|
|
fn build_turns_with_no_messages_has_only_system() {
|
|
|
let turns = build_turns(SYSTEM_PROMPT, &[], TurnContext::default());
|
|
let turns = build_turns(SYSTEM_PROMPT, &[], TurnContext::default());
|
|
@@ -583,7 +1138,20 @@ mod tests {
|
|
|
];
|
|
];
|
|
|
let turns = build_turns(SYSTEM_PROMPT, &messages, TurnContext::default());
|
|
let turns = build_turns(SYSTEM_PROMPT, &messages, TurnContext::default());
|
|
|
let contents: Vec<&str> = turns.iter().map(|turn| turn.content.as_str()).collect();
|
|
let contents: Vec<&str> = turns.iter().map(|turn| turn.content.as_str()).collect();
|
|
|
- assert_eq!(contents, [SYSTEM_PROMPT, "first", "last"]);
|
|
|
|
|
|
|
+ assert_eq!(contents, [system_content(ROLE_NOTE_CHAT).as_str(), "first", "last"]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn context_start_is_yesterday_local_midnight() {
|
|
|
|
|
+ let now = chrono::Utc::now().timestamp();
|
|
|
|
|
+ let start = context_start(now);
|
|
|
|
|
+ let start_local = Local.timestamp_opt(start, 0).single().unwrap();
|
|
|
|
|
+ let today = Local.timestamp_opt(now, 0).single().unwrap().date_naive();
|
|
|
|
|
+
|
|
|
|
|
+ assert!(start < now);
|
|
|
|
|
+ assert!(now - start <= 2 * SECONDS_PER_DAY + 3_600);
|
|
|
|
|
+ assert_eq!((start_local.hour(), start_local.minute()), (0, 0));
|
|
|
|
|
+ assert_eq!(start_local.date_naive(), today.pred_opt().unwrap());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Returns the content of the turn at `index` (system turn included).
|
|
/// Returns the content of the turn at `index` (system turn included).
|
|
@@ -676,4 +1244,36 @@ mod tests {
|
|
|
assert_eq!(turns[1], ChatTurn { role: Role::Assistant, content: VOICE_TEXT.to_owned() });
|
|
assert_eq!(turns[1], ChatTurn { role: Role::Assistant, content: VOICE_TEXT.to_owned() });
|
|
|
assert_eq!(turns[2], ChatTurn { role: Role::User, content: VOICE_NOTE_INSTRUCTION.to_owned() });
|
|
assert_eq!(turns[2], ChatTurn { role: Role::User, content: VOICE_NOTE_INSTRUCTION.to_owned() });
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ /// Builds a group-chat history message from the given sender.
|
|
|
|
|
+ fn group_message(text: &str, sender_jid: &str, sender_name: Option<&str>, from_me: bool) -> StoredMessage {
|
|
|
|
|
+ StoredMessage {
|
|
|
|
|
+ chat_jid: GROUP_JID.to_owned(),
|
|
|
|
|
+ sender_jid: sender_jid.to_owned(),
|
|
|
|
|
+ sender_name: sender_name.map(str::to_owned),
|
|
|
|
|
+ ..history_message(text, from_me)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn build_turns_group_prefixes_user_turns_with_sender() {
|
|
|
|
|
+ let messages = [
|
|
|
|
|
+ group_message("ciao", PRIVATE_JID, Some(" Anna "), false),
|
|
|
|
|
+ group_message("risposta", PRIVATE_JID, None, true),
|
|
|
|
|
+ group_message("salve", "393339999999:3@s.whatsapp.net", None, false),
|
|
|
|
|
+ group_message("blank name", "393338888888@s.whatsapp.net", Some(" "), false),
|
|
|
|
|
+ ];
|
|
|
|
|
+ let turns = build_turns(SYSTEM_PROMPT, &messages, TurnContext::default());
|
|
|
|
|
+ assert_eq!(turns[1], ChatTurn { role: Role::User, content: "Anna: ciao".to_owned() });
|
|
|
|
|
+ assert_eq!(turns[2], ChatTurn { role: Role::Assistant, content: "risposta".to_owned() });
|
|
|
|
|
+ assert_eq!(turns[3], ChatTurn { role: Role::User, content: "393339999999: salve".to_owned() });
|
|
|
|
|
+ assert_eq!(turns[4], ChatTurn { role: Role::User, content: "393338888888: blank name".to_owned() });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn build_turns_private_chat_user_turn_is_not_prefixed() {
|
|
|
|
|
+ let message = StoredMessage { sender_name: Some("Anna".to_owned()), ..history_message("ciao", false) };
|
|
|
|
|
+ let turns = build_turns(SYSTEM_PROMPT, &[message], TurnContext::default());
|
|
|
|
|
+ assert_eq!(turns[1], ChatTurn { role: Role::User, content: "ciao".to_owned() });
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|