//! SQLite-backed store for message history, chats (with autoreply flag) and contacts. 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 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; /// 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, 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 { 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 { 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 { 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 { 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> { 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::>>() .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> { 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::>>() .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> { 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::>>() .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 { 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 = 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 { let sql = format!("SELECT autoreply FROM {TABLE_CHATS} WHERE jid = ?1"); let enabled: Option = 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: 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 );", 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 { 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 { 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 { let jid: String = row.get(0)?; let stored_name: Option = 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 = 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()); } #[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 { 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 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 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 = 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(); } }