| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593 |
- //! 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<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: 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: 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<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());
- }
- #[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 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<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();
- }
- }
|