| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852 |
- //! SQLite-backed store for message history, chats (with autoreply flag), contacts and settings.
- use std::collections::HashMap;
- use std::path::Path;
- use anyhow::{Context, Result};
- use rusqlite::{Connection, OptionalExtension, Row, params};
- const TABLE_MESSAGES: &str = "messages";
- const TABLE_CHATS: &str = "chats";
- const TABLE_CONTACTS: &str = "contacts";
- const TABLE_SETTINGS: &str = "settings";
- const TABLE_IMAGE_DESCRIPTIONS: &str = "image_descriptions";
- const INDEX_MESSAGES_BY_CHAT: &str = "idx_messages_chat_timestamp";
- /// Default `chats.last_timestamp` for chats that have no message yet.
- const DEFAULT_LAST_TIMESTAMP: i64 = 0;
- /// Default autoreply state of a chat that was never toggled.
- const DEFAULT_AUTOREPLY: bool = false;
- /// Key of the global autoreply flag in the settings table.
- const SETTING_GLOBAL_AUTOREPLY: &str = "global_autoreply";
- /// Default global autoreply state when the setting was never stored.
- const DEFAULT_GLOBAL_AUTOREPLY: bool = false;
- /// Separator between the user part and the server part of a WhatsApp jid.
- const JID_SEPARATOR: char = '@';
- /// A message persisted in the history.
- #[derive(Debug, Clone, PartialEq)]
- pub struct StoredMessage {
- pub message_id: String,
- pub chat_jid: String,
- pub sender_jid: String,
- pub sender_name: Option<String>,
- pub text: String,
- pub timestamp: i64,
- pub from_me: bool,
- pub is_ai: bool,
- }
- /// A chat as shown in the chat list.
- #[derive(Debug, Clone, PartialEq)]
- pub struct ChatSummary {
- pub jid: String,
- pub name: String,
- pub last_timestamp: i64,
- pub autoreply: bool,
- }
- /// A named contact.
- #[derive(Debug, Clone, PartialEq)]
- pub struct Contact {
- pub jid: String,
- pub name: String,
- }
- /// Local SQLite message history.
- pub struct History {
- connection: Connection,
- }
- impl History {
- /// Purpose: open (or create) the history database file and ensure the schema exists.
- /// Inputs: `path` of the SQLite file; missing parent directories are created.
- /// Output: a ready-to-use `History`.
- /// Side effects: may create directories and the database file; creates missing tables.
- pub fn open(path: &Path) -> Result<History> {
- if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) {
- std::fs::create_dir_all(parent)
- .with_context(|| format!("create history directory {}", parent.display()))?;
- }
- let connection = Connection::open(path)
- .with_context(|| format!("open history database {}", path.display()))?;
- Self::from_connection(connection)
- }
- /// Purpose: create a throw-away history living only in memory (tests only).
- /// Inputs: none.
- /// Output: a ready-to-use `History`.
- /// Side effects: creates the schema in the in-memory database.
- #[cfg(test)]
- pub fn open_in_memory() -> Result<History> {
- let connection =
- Connection::open_in_memory().context("open in-memory history database")?;
- Self::from_connection(connection)
- }
- /// Purpose: wrap a connection and create the schema on it.
- /// Inputs: an open SQLite connection.
- /// Output: the `History` owning that connection.
- /// Side effects: executes the `CREATE TABLE IF NOT EXISTS` statements.
- fn from_connection(connection: Connection) -> Result<History> {
- connection
- .execute_batch(&schema_sql())
- .context("create history schema")?;
- Ok(History { connection })
- }
- /// Purpose: persist a message once, keeping the chat row up to date.
- /// Inputs: the message to store (deduplicated by `message_id`).
- /// Output: `true` if the message was newly inserted, `false` if it already existed.
- /// Side effects: inserts into messages; upserts chats (last_timestamp = max, name from
- /// the sender push name when the chat has none and the message is not from me), atomically.
- pub fn save_message(&self, message: &StoredMessage) -> Result<bool> {
- let context = || format!("save message {} in chat {}", message.message_id, message.chat_jid);
- let transaction = self.connection.unchecked_transaction().with_context(context)?;
- let inserted = insert_message(&transaction, message).with_context(context)?;
- if inserted {
- upsert_chat_for_message(&transaction, message).with_context(context)?;
- }
- transaction.commit().with_context(context)?;
- Ok(inserted)
- }
- /// Purpose: fetch the latest messages of a chat.
- /// Inputs: `chat_jid` and the maximum number of messages (`limit`).
- /// Output: up to `limit` most recent messages, in ascending timestamp order.
- /// Side effects: none (read only).
- pub fn recent_messages(&self, chat_jid: &str, limit: usize) -> Result<Vec<StoredMessage>> {
- let context = || format!("read last {limit} messages of chat {chat_jid}");
- let sql = format!(
- "SELECT message_id, chat_jid, sender_jid, sender_name, text, timestamp, from_me, is_ai \
- FROM {TABLE_MESSAGES} WHERE chat_jid = ?1 \
- ORDER BY timestamp DESC, rowid DESC LIMIT ?2"
- );
- let limit = i64::try_from(limit).unwrap_or(i64::MAX);
- let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
- let mut messages = statement
- .query_map(params![chat_jid, limit], message_from_row)
- .with_context(context)?
- .collect::<rusqlite::Result<Vec<_>>>()
- .with_context(context)?;
- messages.reverse();
- Ok(messages)
- }
- /// Purpose: fetch the messages of a chat from a given instant up to now, capped.
- /// Inputs: `chat_jid`, `since` (unix seconds, inclusive) and the maximum number of messages
- /// (`limit`).
- /// Output: up to `limit` most recent messages with `timestamp >= since`, in ascending
- /// timestamp order.
- /// Side effects: none (read only).
- pub fn messages_since(&self, chat_jid: &str, since: i64, limit: usize) -> Result<Vec<StoredMessage>> {
- let context = || format!("read last {limit} messages of chat {chat_jid} since {since}");
- let sql = format!(
- "SELECT message_id, chat_jid, sender_jid, sender_name, text, timestamp, from_me, is_ai \
- FROM {TABLE_MESSAGES} WHERE chat_jid = ?1 AND timestamp >= ?2 \
- ORDER BY timestamp DESC, rowid DESC LIMIT ?3"
- );
- let limit = i64::try_from(limit).unwrap_or(i64::MAX);
- let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
- let mut messages = statement
- .query_map(params![chat_jid, since, limit], message_from_row)
- .with_context(context)?
- .collect::<rusqlite::Result<Vec<_>>>()
- .with_context(context)?;
- messages.reverse();
- Ok(messages)
- }
- /// Purpose: list all chats for the UI.
- /// Inputs: none.
- /// Output: chats ordered by `last_timestamp` descending; name = contact name, else chat
- /// name, else the user part of the jid.
- /// Side effects: none (read only).
- pub fn list_chats(&self) -> Result<Vec<ChatSummary>> {
- let context = || "list chats";
- let sql = format!(
- "SELECT c.jid, COALESCE(k.name, c.name), c.last_timestamp, c.autoreply \
- FROM {TABLE_CHATS} c LEFT JOIN {TABLE_CONTACTS} k ON k.jid = c.jid \
- ORDER BY c.last_timestamp DESC, c.jid"
- );
- let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
- let chats = statement
- .query_map([], chat_summary_from_row)
- .with_context(context)?
- .collect::<rusqlite::Result<Vec<_>>>()
- .with_context(context)?;
- Ok(chats)
- }
- /// Purpose: store or rename a contact.
- /// Inputs: the contact; a blank name is ignored.
- /// Output: `()`.
- /// Side effects: upserts the contacts row unless the name is blank.
- pub fn upsert_contact(&self, contact: &Contact) -> Result<()> {
- if contact.name.trim().is_empty() {
- return Ok(());
- }
- let sql = format!(
- "INSERT INTO {TABLE_CONTACTS} (jid, name) VALUES (?1, ?2) \
- ON CONFLICT(jid) DO UPDATE SET name = excluded.name"
- );
- self.connection
- .execute(&sql, params![contact.jid, contact.name])
- .with_context(|| format!("upsert contact {}", contact.jid))?;
- Ok(())
- }
- /// Purpose: list all known contacts.
- /// Inputs: none.
- /// Output: contacts ordered by name, case-insensitive.
- /// Side effects: none (read only).
- pub fn list_contacts(&self) -> Result<Vec<Contact>> {
- let context = || "list contacts";
- let sql = format!(
- "SELECT jid, name FROM {TABLE_CONTACTS} ORDER BY name COLLATE NOCASE, jid"
- );
- let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
- let contacts = statement
- .query_map([], |row| {
- Ok(Contact {
- jid: row.get(0)?,
- name: row.get(1)?,
- })
- })
- .with_context(context)?
- .collect::<rusqlite::Result<Vec<_>>>()
- .with_context(context)?;
- Ok(contacts)
- }
- /// Purpose: resolve the human-readable name of a jid.
- /// Inputs: `jid` (chat or contact).
- /// Output: contact name, else chat name, else the user part of the jid.
- /// Side effects: none (read only).
- pub fn display_name(&self, jid: &str) -> Result<String> {
- let sql = format!(
- "SELECT COALESCE((SELECT name FROM {TABLE_CONTACTS} WHERE jid = ?1), \
- (SELECT name FROM {TABLE_CHATS} WHERE jid = ?1))"
- );
- let stored_name: Option<String> = self
- .connection
- .query_row(&sql, params![jid], |row| row.get(0))
- .with_context(|| format!("resolve display name of {jid}"))?;
- Ok(stored_name.unwrap_or_else(|| jid_user_part(jid).to_string()))
- }
- /// Purpose: enable or disable the AI autoreply for a chat.
- /// Inputs: `chat_jid` and the new state.
- /// Output: `()`.
- /// Side effects: upserts the chats row (created with default values if missing).
- pub fn set_autoreply(&self, chat_jid: &str, enabled: bool) -> Result<()> {
- let sql = format!(
- "INSERT INTO {TABLE_CHATS} (jid, autoreply) VALUES (?1, ?2) \
- ON CONFLICT(jid) DO UPDATE SET autoreply = excluded.autoreply"
- );
- self.connection
- .execute(&sql, params![chat_jid, enabled])
- .with_context(|| format!("set autoreply={enabled} for chat {chat_jid}"))?;
- Ok(())
- }
- /// Purpose: tell whether the AI autoreply is enabled for a chat.
- /// Inputs: `chat_jid`.
- /// Output: the stored flag, or the default (`false`) if the chat has no row.
- /// Side effects: none (read only).
- pub fn is_autoreply(&self, chat_jid: &str) -> Result<bool> {
- let sql = format!("SELECT autoreply FROM {TABLE_CHATS} WHERE jid = ?1");
- let enabled: Option<bool> = self
- .connection
- .query_row(&sql, params![chat_jid], |row| row.get(0))
- .optional()
- .with_context(|| format!("read autoreply of chat {chat_jid}"))?;
- Ok(enabled.unwrap_or(DEFAULT_AUTOREPLY))
- }
- /// Purpose: enable or disable the AI autoreply for all chats at once.
- /// Inputs: the new state.
- /// Output: `()`.
- /// Side effects: upserts the global autoreply row of the settings table.
- pub fn set_global_autoreply(&self, enabled: bool) -> Result<()> {
- let sql = format!(
- "INSERT INTO {TABLE_SETTINGS} (key, value) VALUES (?1, ?2) \
- ON CONFLICT(key) DO UPDATE SET value = excluded.value"
- );
- self.connection
- .execute(&sql, params![SETTING_GLOBAL_AUTOREPLY, enabled])
- .with_context(|| format!("set global autoreply={enabled}"))?;
- Ok(())
- }
- /// Purpose: tell whether the AI autoreply is enabled globally.
- /// Inputs: none.
- /// Output: the stored flag, or the default (`false`) if never set.
- /// Side effects: none (read only).
- pub fn is_global_autoreply(&self) -> Result<bool> {
- let sql = format!("SELECT value FROM {TABLE_SETTINGS} WHERE key = ?1");
- let enabled: Option<bool> = self
- .connection
- .query_row(&sql, params![SETTING_GLOBAL_AUTOREPLY], |row| row.get(0))
- .optional()
- .context("read global autoreply")?;
- Ok(enabled.unwrap_or(DEFAULT_GLOBAL_AUTOREPLY))
- }
- /// Purpose: tell whether a message is stored.
- /// Inputs: `message_id`.
- /// Output: `true` if the history holds a message with that id.
- /// Side effects: none (read only).
- pub fn message_exists(&self, message_id: &str) -> Result<bool> {
- let sql = format!("SELECT 1 FROM {TABLE_MESSAGES} WHERE message_id = ?1");
- let found: Option<i64> = self
- .connection
- .query_row(&sql, params![message_id], |row| row.get(0))
- .optional()
- .with_context(|| format!("look up message {message_id}"))?;
- Ok(found.is_some())
- }
- /// Purpose: store the description of an image message, replacing any previous one.
- /// Inputs: `message_id` of the image message, its `chat_jid` and the `description`.
- /// Output: `()`.
- /// Side effects: upserts a row of the image descriptions table.
- pub fn save_image_description(&self, message_id: &str, chat_jid: &str, description: &str) -> Result<()> {
- let sql = format!(
- "INSERT INTO {TABLE_IMAGE_DESCRIPTIONS} (message_id, chat_jid, description) VALUES (?1, ?2, ?3) \
- ON CONFLICT(message_id) DO UPDATE SET description = excluded.description"
- );
- self.connection
- .execute(&sql, params![message_id, chat_jid, description])
- .with_context(|| format!("save image description of message {message_id}"))?;
- Ok(())
- }
- /// Purpose: read the stored image descriptions of a chat.
- /// Inputs: `chat_jid`.
- /// Output: the descriptions keyed by message id (empty when there are none).
- /// Side effects: none (read only).
- pub fn image_descriptions(&self, chat_jid: &str) -> Result<HashMap<String, String>> {
- let context = || format!("read image descriptions of chat {chat_jid}");
- let sql = format!("SELECT message_id, description FROM {TABLE_IMAGE_DESCRIPTIONS} WHERE chat_jid = ?1");
- let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
- let descriptions = statement
- .query_map(params![chat_jid], |row| Ok((row.get(0)?, row.get(1)?)))
- .with_context(context)?
- .collect::<rusqlite::Result<HashMap<_, _>>>()
- .with_context(context)?;
- Ok(descriptions)
- }
- /// Purpose: delete a chat from the local history.
- /// Inputs: `chat_jid`.
- /// Output: `()` (also when the chat does not exist).
- /// Side effects: removes the chat messages, their image descriptions and the chat row
- /// (including its per-chat autoreply flag), atomically; the contact, if any, is kept; a later
- /// message recreates the chat.
- pub fn delete_chat(&self, chat_jid: &str) -> Result<()> {
- let context = || format!("delete chat {chat_jid}");
- let delete_messages = format!("DELETE FROM {TABLE_MESSAGES} WHERE chat_jid = ?1");
- let delete_descriptions = format!("DELETE FROM {TABLE_IMAGE_DESCRIPTIONS} WHERE chat_jid = ?1");
- let delete_chat = format!("DELETE FROM {TABLE_CHATS} WHERE jid = ?1");
- let transaction = self.connection.unchecked_transaction().with_context(context)?;
- transaction
- .execute(&delete_messages, params![chat_jid])
- .with_context(context)?;
- transaction
- .execute(&delete_descriptions, params![chat_jid])
- .with_context(context)?;
- transaction
- .execute(&delete_chat, params![chat_jid])
- .with_context(context)?;
- transaction.commit().with_context(context)?;
- Ok(())
- }
- }
- /// Purpose: build the `CREATE TABLE/INDEX IF NOT EXISTS` script of the whole schema.
- /// Inputs: none.
- /// Output: the SQL script.
- /// Side effects: none.
- fn schema_sql() -> String {
- format!(
- "CREATE TABLE IF NOT EXISTS {TABLE_MESSAGES} (
- message_id TEXT PRIMARY KEY,
- chat_jid TEXT NOT NULL,
- sender_jid TEXT NOT NULL,
- sender_name TEXT,
- text TEXT NOT NULL,
- timestamp INTEGER NOT NULL,
- from_me INTEGER NOT NULL,
- is_ai INTEGER NOT NULL
- );
- CREATE INDEX IF NOT EXISTS {INDEX_MESSAGES_BY_CHAT}
- ON {TABLE_MESSAGES} (chat_jid, timestamp);
- CREATE TABLE IF NOT EXISTS {TABLE_CHATS} (
- jid TEXT PRIMARY KEY,
- name TEXT,
- last_timestamp INTEGER NOT NULL DEFAULT {DEFAULT_LAST_TIMESTAMP},
- autoreply INTEGER NOT NULL DEFAULT {default_autoreply}
- );
- CREATE TABLE IF NOT EXISTS {TABLE_CONTACTS} (
- jid TEXT PRIMARY KEY,
- name TEXT NOT NULL
- );
- CREATE TABLE IF NOT EXISTS {TABLE_SETTINGS} (
- key TEXT PRIMARY KEY,
- value INTEGER NOT NULL
- );
- CREATE TABLE IF NOT EXISTS {TABLE_IMAGE_DESCRIPTIONS} (
- message_id TEXT PRIMARY KEY,
- chat_jid TEXT NOT NULL,
- description TEXT NOT NULL
- );",
- default_autoreply = i64::from(DEFAULT_AUTOREPLY),
- )
- }
- /// Purpose: insert a message unless its id is already stored.
- /// Inputs: a connection (or transaction) and the message.
- /// Output: `true` if a row was inserted, `false` if ignored as duplicate.
- /// Side effects: writes into messages.
- fn insert_message(connection: &Connection, message: &StoredMessage) -> rusqlite::Result<bool> {
- let sql = format!(
- "INSERT OR IGNORE INTO {TABLE_MESSAGES} \
- (message_id, chat_jid, sender_jid, sender_name, text, timestamp, from_me, is_ai) \
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
- );
- let changed = connection.execute(
- &sql,
- params![
- message.message_id,
- message.chat_jid,
- message.sender_jid,
- message.sender_name,
- message.text,
- message.timestamp,
- message.from_me,
- message.is_ai,
- ],
- )?;
- Ok(changed > 0)
- }
- /// Purpose: upsert the chat row touched by a newly stored message.
- /// Inputs: a connection (or transaction) and the message.
- /// Output: `()`.
- /// Side effects: writes into chats; keeps the max `last_timestamp` and never overwrites an
- /// existing non-empty name.
- fn upsert_chat_for_message(connection: &Connection, message: &StoredMessage) -> rusqlite::Result<()> {
- let sql = format!(
- "INSERT INTO {TABLE_CHATS} (jid, name, last_timestamp) VALUES (?1, ?2, ?3) \
- ON CONFLICT(jid) DO UPDATE SET \
- last_timestamp = MAX(last_timestamp, excluded.last_timestamp), \
- name = COALESCE(NULLIF(name, ''), excluded.name)"
- );
- connection.execute(
- &sql,
- params![message.chat_jid, chat_name_candidate(message), message.timestamp],
- )?;
- Ok(())
- }
- /// Purpose: pick the chat name a message may provide.
- /// Inputs: the message.
- /// Output: the trimmed sender push name for incoming messages, `None` otherwise or if blank.
- /// Side effects: none.
- fn chat_name_candidate(message: &StoredMessage) -> Option<&str> {
- if message.from_me {
- return None;
- }
- message
- .sender_name
- .as_deref()
- .map(str::trim)
- .filter(|name| !name.is_empty())
- }
- /// Purpose: extract the user part of a jid (text before `@`).
- /// Inputs: `jid`.
- /// Output: the user part, or the whole jid if it has no separator.
- /// Side effects: none.
- fn jid_user_part(jid: &str) -> &str {
- jid.split_once(JID_SEPARATOR).map_or(jid, |(user, _)| user)
- }
- /// Purpose: map a messages row (columns in `recent_messages` order) to a `StoredMessage`.
- /// Inputs: the SQLite row.
- /// Output: the message, or the column conversion error.
- /// Side effects: none.
- fn message_from_row(row: &Row<'_>) -> rusqlite::Result<StoredMessage> {
- Ok(StoredMessage {
- message_id: row.get(0)?,
- chat_jid: row.get(1)?,
- sender_jid: row.get(2)?,
- sender_name: row.get(3)?,
- text: row.get(4)?,
- timestamp: row.get(5)?,
- from_me: row.get(6)?,
- is_ai: row.get(7)?,
- })
- }
- /// Purpose: map a chats row (jid, resolved name, last_timestamp, autoreply) to a `ChatSummary`.
- /// Inputs: the SQLite row; a NULL name falls back to the user part of the jid.
- /// Output: the summary, or the column conversion error.
- /// Side effects: none.
- fn chat_summary_from_row(row: &Row<'_>) -> rusqlite::Result<ChatSummary> {
- let jid: String = row.get(0)?;
- let stored_name: Option<String> = row.get(1)?;
- let name = stored_name.unwrap_or_else(|| jid_user_part(&jid).to_string());
- Ok(ChatSummary {
- jid,
- name,
- last_timestamp: row.get(2)?,
- autoreply: row.get(3)?,
- })
- }
- #[cfg(test)]
- mod tests {
- use super::*;
- const CHAT_A: &str = "393331111111@s.whatsapp.net";
- const CHAT_B: &str = "393332222222@s.whatsapp.net";
- const GROUP: &str = "120363000000@g.us";
- /// Builds an incoming message with the given id, chat and timestamp.
- fn message(message_id: &str, chat_jid: &str, timestamp: i64) -> StoredMessage {
- StoredMessage {
- message_id: message_id.to_string(),
- chat_jid: chat_jid.to_string(),
- sender_jid: chat_jid.to_string(),
- sender_name: None,
- text: format!("text {message_id}"),
- timestamp,
- from_me: false,
- is_ai: false,
- }
- }
- /// Builds a contact from jid and name.
- fn contact(jid: &str, name: &str) -> Contact {
- Contact {
- jid: jid.to_string(),
- name: name.to_string(),
- }
- }
- #[test]
- fn duplicate_message_id_is_ignored() {
- let history = History::open_in_memory().unwrap();
- let original = message("m1", CHAT_A, 10);
- let mut duplicate = message("m1", CHAT_A, 20);
- duplicate.text = "changed".to_string();
- assert!(history.save_message(&original).unwrap());
- assert!(!history.save_message(&duplicate).unwrap());
- let stored = history.recent_messages(CHAT_A, 10).unwrap();
- assert_eq!(stored, vec![original]);
- }
- #[test]
- fn recent_messages_respects_limit_and_ascending_order() {
- let history = History::open_in_memory().unwrap();
- for (id, timestamp) in [("m3", 30), ("m1", 10), ("m4", 40), ("m2", 20)] {
- history.save_message(&message(id, CHAT_A, timestamp)).unwrap();
- }
- history.save_message(&message("other", CHAT_B, 50)).unwrap();
- let ids: Vec<String> = history
- .recent_messages(CHAT_A, 3)
- .unwrap()
- .into_iter()
- .map(|stored| stored.message_id)
- .collect();
- assert_eq!(ids, ["m2", "m3", "m4"]);
- assert!(history.recent_messages(CHAT_A, 0).unwrap().is_empty());
- }
- /// Saves one message per `(id, timestamp)` pair in `CHAT_A`.
- fn save_chat_a(history: &History, entries: &[(&str, i64)]) {
- for (id, timestamp) in entries {
- history.save_message(&message(id, CHAT_A, *timestamp)).unwrap();
- }
- }
- /// Returns the ids of `messages` in order.
- fn ids_of(messages: Vec<StoredMessage>) -> Vec<String> {
- messages.into_iter().map(|stored| stored.message_id).collect()
- }
- #[test]
- fn messages_since_excludes_older_and_includes_boundary() {
- let history = History::open_in_memory().unwrap();
- save_chat_a(&history, &[("m1", 9), ("m2", 10), ("m3", 11)]);
- history.save_message(&message("other", CHAT_B, 50)).unwrap();
- let ids = ids_of(history.messages_since(CHAT_A, 10, 10).unwrap());
- assert_eq!(ids, ["m2", "m3"]);
- }
- #[test]
- fn messages_since_respects_limit_keeping_most_recent() {
- let history = History::open_in_memory().unwrap();
- save_chat_a(&history, &[("m1", 10), ("m2", 20), ("m3", 30), ("m4", 40)]);
- let ids = ids_of(history.messages_since(CHAT_A, 0, 2).unwrap());
- assert_eq!(ids, ["m3", "m4"]);
- assert!(history.messages_since(CHAT_A, 0, 0).unwrap().is_empty());
- }
- #[test]
- fn messages_since_returns_ascending_order() {
- let history = History::open_in_memory().unwrap();
- save_chat_a(&history, &[("m3", 30), ("m1", 10), ("m4", 40), ("m2", 20)]);
- let ids = ids_of(history.messages_since(CHAT_A, 0, 10).unwrap());
- assert_eq!(ids, ["m1", "m2", "m3", "m4"]);
- }
- #[test]
- fn message_flags_and_sender_name_roundtrip() {
- let history = History::open_in_memory().unwrap();
- let mut sent = message("m1", CHAT_A, 10);
- sent.from_me = true;
- sent.is_ai = true;
- sent.sender_name = Some("Me".to_string());
- history.save_message(&sent).unwrap();
- assert_eq!(history.recent_messages(CHAT_A, 1).unwrap(), vec![sent]);
- }
- #[test]
- fn list_chats_orders_by_last_timestamp_desc_and_keeps_max() {
- let history = History::open_in_memory().unwrap();
- history.save_message(&message("a1", CHAT_A, 100)).unwrap();
- history.save_message(&message("b1", CHAT_B, 200)).unwrap();
- history.save_message(&message("a0", CHAT_A, 50)).unwrap();
- let chats = history.list_chats().unwrap();
- let summary: Vec<(&str, i64)> = chats
- .iter()
- .map(|chat| (chat.jid.as_str(), chat.last_timestamp))
- .collect();
- assert_eq!(summary, [(CHAT_B, 200), (CHAT_A, 100)]);
- }
- #[test]
- fn chat_name_falls_back_contact_then_chat_name_then_jid_user_part() {
- let history = History::open_in_memory().unwrap();
- history.save_message(&message("a1", CHAT_A, 30)).unwrap();
- let mut named = message("b1", CHAT_B, 20);
- named.sender_name = Some("Push Bob".to_string());
- history.save_message(&named).unwrap();
- history.save_message(&message("g1", GROUP, 10)).unwrap();
- let names = |history: &History| -> Vec<String> {
- history.list_chats().unwrap().into_iter().map(|chat| chat.name).collect()
- };
- assert_eq!(names(&history), ["393331111111", "Push Bob", "120363000000"]);
- history.upsert_contact(&contact(CHAT_B, "Roberto")).unwrap();
- assert_eq!(names(&history), ["393331111111", "Roberto", "120363000000"]);
- assert_eq!(history.display_name(CHAT_A).unwrap(), "393331111111");
- assert_eq!(history.display_name(CHAT_B).unwrap(), "Roberto");
- assert_eq!(history.display_name("unknown@s.whatsapp.net").unwrap(), "unknown");
- }
- #[test]
- fn chat_name_is_set_once_and_never_from_own_messages() {
- let history = History::open_in_memory().unwrap();
- let mut own = message("m1", CHAT_A, 10);
- own.from_me = true;
- own.sender_name = Some("Me".to_string());
- history.save_message(&own).unwrap();
- assert_eq!(history.display_name(CHAT_A).unwrap(), "393331111111");
- let mut first = message("m2", CHAT_A, 20);
- first.sender_name = Some("Alice".to_string());
- let mut second = message("m3", CHAT_A, 30);
- second.sender_name = Some("Alice Renamed".to_string());
- history.save_message(&first).unwrap();
- history.save_message(&second).unwrap();
- assert_eq!(history.display_name(CHAT_A).unwrap(), "Alice");
- }
- #[test]
- fn autoreply_defaults_to_false_and_toggles() {
- let history = History::open_in_memory().unwrap();
- assert!(!history.is_autoreply(CHAT_A).unwrap());
- history.set_autoreply(CHAT_A, true).unwrap();
- assert!(history.is_autoreply(CHAT_A).unwrap());
- assert!(!history.is_autoreply(CHAT_B).unwrap());
- history.set_autoreply(CHAT_A, false).unwrap();
- assert!(!history.is_autoreply(CHAT_A).unwrap());
- }
- #[test]
- fn global_autoreply_defaults_to_false_toggles_and_is_independent_of_chats() {
- let history = History::open_in_memory().unwrap();
- assert!(!history.is_global_autoreply().unwrap());
- history.set_global_autoreply(true).unwrap();
- assert!(history.is_global_autoreply().unwrap());
- assert!(!history.is_autoreply(CHAT_A).unwrap());
- history.set_global_autoreply(false).unwrap();
- assert!(!history.is_global_autoreply().unwrap());
- }
- #[test]
- fn autoreply_survives_incoming_messages_and_shows_in_chat_list() {
- let history = History::open_in_memory().unwrap();
- history.set_autoreply(CHAT_A, true).unwrap();
- history.save_message(&message("m1", CHAT_A, 10)).unwrap();
- let chats = history.list_chats().unwrap();
- assert_eq!(chats.len(), 1);
- assert!(chats[0].autoreply);
- assert_eq!(chats[0].last_timestamp, 10);
- }
- #[test]
- fn message_exists_only_for_stored_messages_until_the_chat_is_deleted() {
- let history = History::open_in_memory().unwrap();
- history.save_message(&message("a1", CHAT_A, 10)).unwrap();
- assert!(history.message_exists("a1").unwrap());
- assert!(!history.message_exists("missing").unwrap());
- history.delete_chat(CHAT_A).unwrap();
- assert!(!history.message_exists("a1").unwrap());
- }
- #[test]
- fn image_descriptions_are_stored_per_chat_replaced_and_deleted_with_the_chat() {
- let history = History::open_in_memory().unwrap();
- history.save_image_description("a1", CHAT_A, "un gatto").unwrap();
- history.save_image_description("a1", CHAT_A, "un gatto nero").unwrap();
- history.save_image_description("b1", CHAT_B, "una torta").unwrap();
- let descriptions = history.image_descriptions(CHAT_A).unwrap();
- assert_eq!(descriptions.len(), 1);
- assert_eq!(descriptions.get("a1").map(String::as_str), Some("un gatto nero"));
- history.delete_chat(CHAT_A).unwrap();
- assert!(history.image_descriptions(CHAT_A).unwrap().is_empty());
- assert_eq!(history.image_descriptions(CHAT_B).unwrap().len(), 1);
- }
- #[test]
- fn delete_chat_removes_messages_and_row_but_keeps_contact_and_other_chats() {
- let history = History::open_in_memory().unwrap();
- history.save_message(&message("a1", CHAT_A, 10)).unwrap();
- history.save_message(&message("a2", CHAT_A, 20)).unwrap();
- history.save_message(&message("b1", CHAT_B, 30)).unwrap();
- history.upsert_contact(&contact(CHAT_A, "Anna")).unwrap();
- history.set_autoreply(CHAT_A, true).unwrap();
- history.delete_chat(CHAT_A).unwrap();
- let jids: Vec<String> = history
- .list_chats()
- .unwrap()
- .into_iter()
- .map(|chat| chat.jid)
- .collect();
- assert_eq!(jids, [CHAT_B]);
- assert!(history.recent_messages(CHAT_A, 10).unwrap().is_empty());
- assert!(!history.recent_messages(CHAT_B, 10).unwrap().is_empty());
- assert_eq!(history.list_contacts().unwrap(), vec![contact(CHAT_A, "Anna")]);
- assert!(!history.is_autoreply(CHAT_A).unwrap());
- }
- #[test]
- fn list_contacts_is_ordered_case_insensitive() {
- let history = History::open_in_memory().unwrap();
- history.upsert_contact(&contact("3@s.whatsapp.net", "bruno")).unwrap();
- history.upsert_contact(&contact("1@s.whatsapp.net", "Carla")).unwrap();
- history.upsert_contact(&contact("2@s.whatsapp.net", "Anna")).unwrap();
- let names: Vec<String> = history
- .list_contacts()
- .unwrap()
- .into_iter()
- .map(|stored| stored.name)
- .collect();
- assert_eq!(names, ["Anna", "bruno", "Carla"]);
- }
- #[test]
- fn upsert_contact_ignores_blank_names_and_renames_existing() {
- let history = History::open_in_memory().unwrap();
- history.upsert_contact(&contact(CHAT_A, "")).unwrap();
- history.upsert_contact(&contact(CHAT_A, " ")).unwrap();
- assert!(history.list_contacts().unwrap().is_empty());
- history.upsert_contact(&contact(CHAT_A, "Anna")).unwrap();
- history.upsert_contact(&contact(CHAT_A, "")).unwrap();
- history.upsert_contact(&contact(CHAT_A, "Anna B")).unwrap();
- assert_eq!(history.list_contacts().unwrap(), vec![contact(CHAT_A, "Anna B")]);
- }
- #[test]
- fn open_persists_data_on_disk_and_creates_parent_directory() {
- let directory = std::env::temp_dir().join(format!("wh-history-test-{}", std::process::id()));
- let path = directory.join("nested").join("history.db");
- {
- let history = History::open(&path).unwrap();
- history.save_message(&message("m1", CHAT_A, 10)).unwrap();
- }
- let reopened = History::open(&path).unwrap();
- assert_eq!(reopened.recent_messages(CHAT_A, 10).unwrap().len(), 1);
- drop(reopened);
- std::fs::remove_dir_all(&directory).unwrap();
- }
- }
|