history.rs 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852
  1. //! SQLite-backed store for message history, chats (with autoreply flag), contacts and settings.
  2. use std::collections::HashMap;
  3. use std::path::Path;
  4. use anyhow::{Context, Result};
  5. use rusqlite::{Connection, OptionalExtension, Row, params};
  6. const TABLE_MESSAGES: &str = "messages";
  7. const TABLE_CHATS: &str = "chats";
  8. const TABLE_CONTACTS: &str = "contacts";
  9. const TABLE_SETTINGS: &str = "settings";
  10. const TABLE_IMAGE_DESCRIPTIONS: &str = "image_descriptions";
  11. const INDEX_MESSAGES_BY_CHAT: &str = "idx_messages_chat_timestamp";
  12. /// Default `chats.last_timestamp` for chats that have no message yet.
  13. const DEFAULT_LAST_TIMESTAMP: i64 = 0;
  14. /// Default autoreply state of a chat that was never toggled.
  15. const DEFAULT_AUTOREPLY: bool = false;
  16. /// Key of the global autoreply flag in the settings table.
  17. const SETTING_GLOBAL_AUTOREPLY: &str = "global_autoreply";
  18. /// Default global autoreply state when the setting was never stored.
  19. const DEFAULT_GLOBAL_AUTOREPLY: bool = false;
  20. /// Separator between the user part and the server part of a WhatsApp jid.
  21. const JID_SEPARATOR: char = '@';
  22. /// A message persisted in the history.
  23. #[derive(Debug, Clone, PartialEq)]
  24. pub struct StoredMessage {
  25. pub message_id: String,
  26. pub chat_jid: String,
  27. pub sender_jid: String,
  28. pub sender_name: Option<String>,
  29. pub text: String,
  30. pub timestamp: i64,
  31. pub from_me: bool,
  32. pub is_ai: bool,
  33. }
  34. /// A chat as shown in the chat list.
  35. #[derive(Debug, Clone, PartialEq)]
  36. pub struct ChatSummary {
  37. pub jid: String,
  38. pub name: String,
  39. pub last_timestamp: i64,
  40. pub autoreply: bool,
  41. }
  42. /// A named contact.
  43. #[derive(Debug, Clone, PartialEq)]
  44. pub struct Contact {
  45. pub jid: String,
  46. pub name: String,
  47. }
  48. /// Local SQLite message history.
  49. pub struct History {
  50. connection: Connection,
  51. }
  52. impl History {
  53. /// Purpose: open (or create) the history database file and ensure the schema exists.
  54. /// Inputs: `path` of the SQLite file; missing parent directories are created.
  55. /// Output: a ready-to-use `History`.
  56. /// Side effects: may create directories and the database file; creates missing tables.
  57. pub fn open(path: &Path) -> Result<History> {
  58. if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) {
  59. std::fs::create_dir_all(parent)
  60. .with_context(|| format!("create history directory {}", parent.display()))?;
  61. }
  62. let connection = Connection::open(path)
  63. .with_context(|| format!("open history database {}", path.display()))?;
  64. Self::from_connection(connection)
  65. }
  66. /// Purpose: create a throw-away history living only in memory (tests only).
  67. /// Inputs: none.
  68. /// Output: a ready-to-use `History`.
  69. /// Side effects: creates the schema in the in-memory database.
  70. #[cfg(test)]
  71. pub fn open_in_memory() -> Result<History> {
  72. let connection =
  73. Connection::open_in_memory().context("open in-memory history database")?;
  74. Self::from_connection(connection)
  75. }
  76. /// Purpose: wrap a connection and create the schema on it.
  77. /// Inputs: an open SQLite connection.
  78. /// Output: the `History` owning that connection.
  79. /// Side effects: executes the `CREATE TABLE IF NOT EXISTS` statements.
  80. fn from_connection(connection: Connection) -> Result<History> {
  81. connection
  82. .execute_batch(&schema_sql())
  83. .context("create history schema")?;
  84. Ok(History { connection })
  85. }
  86. /// Purpose: persist a message once, keeping the chat row up to date.
  87. /// Inputs: the message to store (deduplicated by `message_id`).
  88. /// Output: `true` if the message was newly inserted, `false` if it already existed.
  89. /// Side effects: inserts into messages; upserts chats (last_timestamp = max, name from
  90. /// the sender push name when the chat has none and the message is not from me), atomically.
  91. pub fn save_message(&self, message: &StoredMessage) -> Result<bool> {
  92. let context = || format!("save message {} in chat {}", message.message_id, message.chat_jid);
  93. let transaction = self.connection.unchecked_transaction().with_context(context)?;
  94. let inserted = insert_message(&transaction, message).with_context(context)?;
  95. if inserted {
  96. upsert_chat_for_message(&transaction, message).with_context(context)?;
  97. }
  98. transaction.commit().with_context(context)?;
  99. Ok(inserted)
  100. }
  101. /// Purpose: fetch the latest messages of a chat.
  102. /// Inputs: `chat_jid` and the maximum number of messages (`limit`).
  103. /// Output: up to `limit` most recent messages, in ascending timestamp order.
  104. /// Side effects: none (read only).
  105. pub fn recent_messages(&self, chat_jid: &str, limit: usize) -> Result<Vec<StoredMessage>> {
  106. let context = || format!("read last {limit} messages of chat {chat_jid}");
  107. let sql = format!(
  108. "SELECT message_id, chat_jid, sender_jid, sender_name, text, timestamp, from_me, is_ai \
  109. FROM {TABLE_MESSAGES} WHERE chat_jid = ?1 \
  110. ORDER BY timestamp DESC, rowid DESC LIMIT ?2"
  111. );
  112. let limit = i64::try_from(limit).unwrap_or(i64::MAX);
  113. let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
  114. let mut messages = statement
  115. .query_map(params![chat_jid, limit], message_from_row)
  116. .with_context(context)?
  117. .collect::<rusqlite::Result<Vec<_>>>()
  118. .with_context(context)?;
  119. messages.reverse();
  120. Ok(messages)
  121. }
  122. /// Purpose: fetch the messages of a chat from a given instant up to now, capped.
  123. /// Inputs: `chat_jid`, `since` (unix seconds, inclusive) and the maximum number of messages
  124. /// (`limit`).
  125. /// Output: up to `limit` most recent messages with `timestamp >= since`, in ascending
  126. /// timestamp order.
  127. /// Side effects: none (read only).
  128. pub fn messages_since(&self, chat_jid: &str, since: i64, limit: usize) -> Result<Vec<StoredMessage>> {
  129. let context = || format!("read last {limit} messages of chat {chat_jid} since {since}");
  130. let sql = format!(
  131. "SELECT message_id, chat_jid, sender_jid, sender_name, text, timestamp, from_me, is_ai \
  132. FROM {TABLE_MESSAGES} WHERE chat_jid = ?1 AND timestamp >= ?2 \
  133. ORDER BY timestamp DESC, rowid DESC LIMIT ?3"
  134. );
  135. let limit = i64::try_from(limit).unwrap_or(i64::MAX);
  136. let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
  137. let mut messages = statement
  138. .query_map(params![chat_jid, since, limit], message_from_row)
  139. .with_context(context)?
  140. .collect::<rusqlite::Result<Vec<_>>>()
  141. .with_context(context)?;
  142. messages.reverse();
  143. Ok(messages)
  144. }
  145. /// Purpose: list all chats for the UI.
  146. /// Inputs: none.
  147. /// Output: chats ordered by `last_timestamp` descending; name = contact name, else chat
  148. /// name, else the user part of the jid.
  149. /// Side effects: none (read only).
  150. pub fn list_chats(&self) -> Result<Vec<ChatSummary>> {
  151. let context = || "list chats";
  152. let sql = format!(
  153. "SELECT c.jid, COALESCE(k.name, c.name), c.last_timestamp, c.autoreply \
  154. FROM {TABLE_CHATS} c LEFT JOIN {TABLE_CONTACTS} k ON k.jid = c.jid \
  155. ORDER BY c.last_timestamp DESC, c.jid"
  156. );
  157. let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
  158. let chats = statement
  159. .query_map([], chat_summary_from_row)
  160. .with_context(context)?
  161. .collect::<rusqlite::Result<Vec<_>>>()
  162. .with_context(context)?;
  163. Ok(chats)
  164. }
  165. /// Purpose: store or rename a contact.
  166. /// Inputs: the contact; a blank name is ignored.
  167. /// Output: `()`.
  168. /// Side effects: upserts the contacts row unless the name is blank.
  169. pub fn upsert_contact(&self, contact: &Contact) -> Result<()> {
  170. if contact.name.trim().is_empty() {
  171. return Ok(());
  172. }
  173. let sql = format!(
  174. "INSERT INTO {TABLE_CONTACTS} (jid, name) VALUES (?1, ?2) \
  175. ON CONFLICT(jid) DO UPDATE SET name = excluded.name"
  176. );
  177. self.connection
  178. .execute(&sql, params![contact.jid, contact.name])
  179. .with_context(|| format!("upsert contact {}", contact.jid))?;
  180. Ok(())
  181. }
  182. /// Purpose: list all known contacts.
  183. /// Inputs: none.
  184. /// Output: contacts ordered by name, case-insensitive.
  185. /// Side effects: none (read only).
  186. pub fn list_contacts(&self) -> Result<Vec<Contact>> {
  187. let context = || "list contacts";
  188. let sql = format!(
  189. "SELECT jid, name FROM {TABLE_CONTACTS} ORDER BY name COLLATE NOCASE, jid"
  190. );
  191. let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
  192. let contacts = statement
  193. .query_map([], |row| {
  194. Ok(Contact {
  195. jid: row.get(0)?,
  196. name: row.get(1)?,
  197. })
  198. })
  199. .with_context(context)?
  200. .collect::<rusqlite::Result<Vec<_>>>()
  201. .with_context(context)?;
  202. Ok(contacts)
  203. }
  204. /// Purpose: resolve the human-readable name of a jid.
  205. /// Inputs: `jid` (chat or contact).
  206. /// Output: contact name, else chat name, else the user part of the jid.
  207. /// Side effects: none (read only).
  208. pub fn display_name(&self, jid: &str) -> Result<String> {
  209. let sql = format!(
  210. "SELECT COALESCE((SELECT name FROM {TABLE_CONTACTS} WHERE jid = ?1), \
  211. (SELECT name FROM {TABLE_CHATS} WHERE jid = ?1))"
  212. );
  213. let stored_name: Option<String> = self
  214. .connection
  215. .query_row(&sql, params![jid], |row| row.get(0))
  216. .with_context(|| format!("resolve display name of {jid}"))?;
  217. Ok(stored_name.unwrap_or_else(|| jid_user_part(jid).to_string()))
  218. }
  219. /// Purpose: enable or disable the AI autoreply for a chat.
  220. /// Inputs: `chat_jid` and the new state.
  221. /// Output: `()`.
  222. /// Side effects: upserts the chats row (created with default values if missing).
  223. pub fn set_autoreply(&self, chat_jid: &str, enabled: bool) -> Result<()> {
  224. let sql = format!(
  225. "INSERT INTO {TABLE_CHATS} (jid, autoreply) VALUES (?1, ?2) \
  226. ON CONFLICT(jid) DO UPDATE SET autoreply = excluded.autoreply"
  227. );
  228. self.connection
  229. .execute(&sql, params![chat_jid, enabled])
  230. .with_context(|| format!("set autoreply={enabled} for chat {chat_jid}"))?;
  231. Ok(())
  232. }
  233. /// Purpose: tell whether the AI autoreply is enabled for a chat.
  234. /// Inputs: `chat_jid`.
  235. /// Output: the stored flag, or the default (`false`) if the chat has no row.
  236. /// Side effects: none (read only).
  237. pub fn is_autoreply(&self, chat_jid: &str) -> Result<bool> {
  238. let sql = format!("SELECT autoreply FROM {TABLE_CHATS} WHERE jid = ?1");
  239. let enabled: Option<bool> = self
  240. .connection
  241. .query_row(&sql, params![chat_jid], |row| row.get(0))
  242. .optional()
  243. .with_context(|| format!("read autoreply of chat {chat_jid}"))?;
  244. Ok(enabled.unwrap_or(DEFAULT_AUTOREPLY))
  245. }
  246. /// Purpose: enable or disable the AI autoreply for all chats at once.
  247. /// Inputs: the new state.
  248. /// Output: `()`.
  249. /// Side effects: upserts the global autoreply row of the settings table.
  250. pub fn set_global_autoreply(&self, enabled: bool) -> Result<()> {
  251. let sql = format!(
  252. "INSERT INTO {TABLE_SETTINGS} (key, value) VALUES (?1, ?2) \
  253. ON CONFLICT(key) DO UPDATE SET value = excluded.value"
  254. );
  255. self.connection
  256. .execute(&sql, params![SETTING_GLOBAL_AUTOREPLY, enabled])
  257. .with_context(|| format!("set global autoreply={enabled}"))?;
  258. Ok(())
  259. }
  260. /// Purpose: tell whether the AI autoreply is enabled globally.
  261. /// Inputs: none.
  262. /// Output: the stored flag, or the default (`false`) if never set.
  263. /// Side effects: none (read only).
  264. pub fn is_global_autoreply(&self) -> Result<bool> {
  265. let sql = format!("SELECT value FROM {TABLE_SETTINGS} WHERE key = ?1");
  266. let enabled: Option<bool> = self
  267. .connection
  268. .query_row(&sql, params![SETTING_GLOBAL_AUTOREPLY], |row| row.get(0))
  269. .optional()
  270. .context("read global autoreply")?;
  271. Ok(enabled.unwrap_or(DEFAULT_GLOBAL_AUTOREPLY))
  272. }
  273. /// Purpose: tell whether a message is stored.
  274. /// Inputs: `message_id`.
  275. /// Output: `true` if the history holds a message with that id.
  276. /// Side effects: none (read only).
  277. pub fn message_exists(&self, message_id: &str) -> Result<bool> {
  278. let sql = format!("SELECT 1 FROM {TABLE_MESSAGES} WHERE message_id = ?1");
  279. let found: Option<i64> = self
  280. .connection
  281. .query_row(&sql, params![message_id], |row| row.get(0))
  282. .optional()
  283. .with_context(|| format!("look up message {message_id}"))?;
  284. Ok(found.is_some())
  285. }
  286. /// Purpose: store the description of an image message, replacing any previous one.
  287. /// Inputs: `message_id` of the image message, its `chat_jid` and the `description`.
  288. /// Output: `()`.
  289. /// Side effects: upserts a row of the image descriptions table.
  290. pub fn save_image_description(&self, message_id: &str, chat_jid: &str, description: &str) -> Result<()> {
  291. let sql = format!(
  292. "INSERT INTO {TABLE_IMAGE_DESCRIPTIONS} (message_id, chat_jid, description) VALUES (?1, ?2, ?3) \
  293. ON CONFLICT(message_id) DO UPDATE SET description = excluded.description"
  294. );
  295. self.connection
  296. .execute(&sql, params![message_id, chat_jid, description])
  297. .with_context(|| format!("save image description of message {message_id}"))?;
  298. Ok(())
  299. }
  300. /// Purpose: read the stored image descriptions of a chat.
  301. /// Inputs: `chat_jid`.
  302. /// Output: the descriptions keyed by message id (empty when there are none).
  303. /// Side effects: none (read only).
  304. pub fn image_descriptions(&self, chat_jid: &str) -> Result<HashMap<String, String>> {
  305. let context = || format!("read image descriptions of chat {chat_jid}");
  306. let sql = format!("SELECT message_id, description FROM {TABLE_IMAGE_DESCRIPTIONS} WHERE chat_jid = ?1");
  307. let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
  308. let descriptions = statement
  309. .query_map(params![chat_jid], |row| Ok((row.get(0)?, row.get(1)?)))
  310. .with_context(context)?
  311. .collect::<rusqlite::Result<HashMap<_, _>>>()
  312. .with_context(context)?;
  313. Ok(descriptions)
  314. }
  315. /// Purpose: delete a chat from the local history.
  316. /// Inputs: `chat_jid`.
  317. /// Output: `()` (also when the chat does not exist).
  318. /// Side effects: removes the chat messages, their image descriptions and the chat row
  319. /// (including its per-chat autoreply flag), atomically; the contact, if any, is kept; a later
  320. /// message recreates the chat.
  321. pub fn delete_chat(&self, chat_jid: &str) -> Result<()> {
  322. let context = || format!("delete chat {chat_jid}");
  323. let delete_messages = format!("DELETE FROM {TABLE_MESSAGES} WHERE chat_jid = ?1");
  324. let delete_descriptions = format!("DELETE FROM {TABLE_IMAGE_DESCRIPTIONS} WHERE chat_jid = ?1");
  325. let delete_chat = format!("DELETE FROM {TABLE_CHATS} WHERE jid = ?1");
  326. let transaction = self.connection.unchecked_transaction().with_context(context)?;
  327. transaction
  328. .execute(&delete_messages, params![chat_jid])
  329. .with_context(context)?;
  330. transaction
  331. .execute(&delete_descriptions, params![chat_jid])
  332. .with_context(context)?;
  333. transaction
  334. .execute(&delete_chat, params![chat_jid])
  335. .with_context(context)?;
  336. transaction.commit().with_context(context)?;
  337. Ok(())
  338. }
  339. }
  340. /// Purpose: build the `CREATE TABLE/INDEX IF NOT EXISTS` script of the whole schema.
  341. /// Inputs: none.
  342. /// Output: the SQL script.
  343. /// Side effects: none.
  344. fn schema_sql() -> String {
  345. format!(
  346. "CREATE TABLE IF NOT EXISTS {TABLE_MESSAGES} (
  347. message_id TEXT PRIMARY KEY,
  348. chat_jid TEXT NOT NULL,
  349. sender_jid TEXT NOT NULL,
  350. sender_name TEXT,
  351. text TEXT NOT NULL,
  352. timestamp INTEGER NOT NULL,
  353. from_me INTEGER NOT NULL,
  354. is_ai INTEGER NOT NULL
  355. );
  356. CREATE INDEX IF NOT EXISTS {INDEX_MESSAGES_BY_CHAT}
  357. ON {TABLE_MESSAGES} (chat_jid, timestamp);
  358. CREATE TABLE IF NOT EXISTS {TABLE_CHATS} (
  359. jid TEXT PRIMARY KEY,
  360. name TEXT,
  361. last_timestamp INTEGER NOT NULL DEFAULT {DEFAULT_LAST_TIMESTAMP},
  362. autoreply INTEGER NOT NULL DEFAULT {default_autoreply}
  363. );
  364. CREATE TABLE IF NOT EXISTS {TABLE_CONTACTS} (
  365. jid TEXT PRIMARY KEY,
  366. name TEXT NOT NULL
  367. );
  368. CREATE TABLE IF NOT EXISTS {TABLE_SETTINGS} (
  369. key TEXT PRIMARY KEY,
  370. value INTEGER NOT NULL
  371. );
  372. CREATE TABLE IF NOT EXISTS {TABLE_IMAGE_DESCRIPTIONS} (
  373. message_id TEXT PRIMARY KEY,
  374. chat_jid TEXT NOT NULL,
  375. description TEXT NOT NULL
  376. );",
  377. default_autoreply = i64::from(DEFAULT_AUTOREPLY),
  378. )
  379. }
  380. /// Purpose: insert a message unless its id is already stored.
  381. /// Inputs: a connection (or transaction) and the message.
  382. /// Output: `true` if a row was inserted, `false` if ignored as duplicate.
  383. /// Side effects: writes into messages.
  384. fn insert_message(connection: &Connection, message: &StoredMessage) -> rusqlite::Result<bool> {
  385. let sql = format!(
  386. "INSERT OR IGNORE INTO {TABLE_MESSAGES} \
  387. (message_id, chat_jid, sender_jid, sender_name, text, timestamp, from_me, is_ai) \
  388. VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
  389. );
  390. let changed = connection.execute(
  391. &sql,
  392. params![
  393. message.message_id,
  394. message.chat_jid,
  395. message.sender_jid,
  396. message.sender_name,
  397. message.text,
  398. message.timestamp,
  399. message.from_me,
  400. message.is_ai,
  401. ],
  402. )?;
  403. Ok(changed > 0)
  404. }
  405. /// Purpose: upsert the chat row touched by a newly stored message.
  406. /// Inputs: a connection (or transaction) and the message.
  407. /// Output: `()`.
  408. /// Side effects: writes into chats; keeps the max `last_timestamp` and never overwrites an
  409. /// existing non-empty name.
  410. fn upsert_chat_for_message(connection: &Connection, message: &StoredMessage) -> rusqlite::Result<()> {
  411. let sql = format!(
  412. "INSERT INTO {TABLE_CHATS} (jid, name, last_timestamp) VALUES (?1, ?2, ?3) \
  413. ON CONFLICT(jid) DO UPDATE SET \
  414. last_timestamp = MAX(last_timestamp, excluded.last_timestamp), \
  415. name = COALESCE(NULLIF(name, ''), excluded.name)"
  416. );
  417. connection.execute(
  418. &sql,
  419. params![message.chat_jid, chat_name_candidate(message), message.timestamp],
  420. )?;
  421. Ok(())
  422. }
  423. /// Purpose: pick the chat name a message may provide.
  424. /// Inputs: the message.
  425. /// Output: the trimmed sender push name for incoming messages, `None` otherwise or if blank.
  426. /// Side effects: none.
  427. fn chat_name_candidate(message: &StoredMessage) -> Option<&str> {
  428. if message.from_me {
  429. return None;
  430. }
  431. message
  432. .sender_name
  433. .as_deref()
  434. .map(str::trim)
  435. .filter(|name| !name.is_empty())
  436. }
  437. /// Purpose: extract the user part of a jid (text before `@`).
  438. /// Inputs: `jid`.
  439. /// Output: the user part, or the whole jid if it has no separator.
  440. /// Side effects: none.
  441. fn jid_user_part(jid: &str) -> &str {
  442. jid.split_once(JID_SEPARATOR).map_or(jid, |(user, _)| user)
  443. }
  444. /// Purpose: map a messages row (columns in `recent_messages` order) to a `StoredMessage`.
  445. /// Inputs: the SQLite row.
  446. /// Output: the message, or the column conversion error.
  447. /// Side effects: none.
  448. fn message_from_row(row: &Row<'_>) -> rusqlite::Result<StoredMessage> {
  449. Ok(StoredMessage {
  450. message_id: row.get(0)?,
  451. chat_jid: row.get(1)?,
  452. sender_jid: row.get(2)?,
  453. sender_name: row.get(3)?,
  454. text: row.get(4)?,
  455. timestamp: row.get(5)?,
  456. from_me: row.get(6)?,
  457. is_ai: row.get(7)?,
  458. })
  459. }
  460. /// Purpose: map a chats row (jid, resolved name, last_timestamp, autoreply) to a `ChatSummary`.
  461. /// Inputs: the SQLite row; a NULL name falls back to the user part of the jid.
  462. /// Output: the summary, or the column conversion error.
  463. /// Side effects: none.
  464. fn chat_summary_from_row(row: &Row<'_>) -> rusqlite::Result<ChatSummary> {
  465. let jid: String = row.get(0)?;
  466. let stored_name: Option<String> = row.get(1)?;
  467. let name = stored_name.unwrap_or_else(|| jid_user_part(&jid).to_string());
  468. Ok(ChatSummary {
  469. jid,
  470. name,
  471. last_timestamp: row.get(2)?,
  472. autoreply: row.get(3)?,
  473. })
  474. }
  475. #[cfg(test)]
  476. mod tests {
  477. use super::*;
  478. const CHAT_A: &str = "393331111111@s.whatsapp.net";
  479. const CHAT_B: &str = "393332222222@s.whatsapp.net";
  480. const GROUP: &str = "120363000000@g.us";
  481. /// Builds an incoming message with the given id, chat and timestamp.
  482. fn message(message_id: &str, chat_jid: &str, timestamp: i64) -> StoredMessage {
  483. StoredMessage {
  484. message_id: message_id.to_string(),
  485. chat_jid: chat_jid.to_string(),
  486. sender_jid: chat_jid.to_string(),
  487. sender_name: None,
  488. text: format!("text {message_id}"),
  489. timestamp,
  490. from_me: false,
  491. is_ai: false,
  492. }
  493. }
  494. /// Builds a contact from jid and name.
  495. fn contact(jid: &str, name: &str) -> Contact {
  496. Contact {
  497. jid: jid.to_string(),
  498. name: name.to_string(),
  499. }
  500. }
  501. #[test]
  502. fn duplicate_message_id_is_ignored() {
  503. let history = History::open_in_memory().unwrap();
  504. let original = message("m1", CHAT_A, 10);
  505. let mut duplicate = message("m1", CHAT_A, 20);
  506. duplicate.text = "changed".to_string();
  507. assert!(history.save_message(&original).unwrap());
  508. assert!(!history.save_message(&duplicate).unwrap());
  509. let stored = history.recent_messages(CHAT_A, 10).unwrap();
  510. assert_eq!(stored, vec![original]);
  511. }
  512. #[test]
  513. fn recent_messages_respects_limit_and_ascending_order() {
  514. let history = History::open_in_memory().unwrap();
  515. for (id, timestamp) in [("m3", 30), ("m1", 10), ("m4", 40), ("m2", 20)] {
  516. history.save_message(&message(id, CHAT_A, timestamp)).unwrap();
  517. }
  518. history.save_message(&message("other", CHAT_B, 50)).unwrap();
  519. let ids: Vec<String> = history
  520. .recent_messages(CHAT_A, 3)
  521. .unwrap()
  522. .into_iter()
  523. .map(|stored| stored.message_id)
  524. .collect();
  525. assert_eq!(ids, ["m2", "m3", "m4"]);
  526. assert!(history.recent_messages(CHAT_A, 0).unwrap().is_empty());
  527. }
  528. /// Saves one message per `(id, timestamp)` pair in `CHAT_A`.
  529. fn save_chat_a(history: &History, entries: &[(&str, i64)]) {
  530. for (id, timestamp) in entries {
  531. history.save_message(&message(id, CHAT_A, *timestamp)).unwrap();
  532. }
  533. }
  534. /// Returns the ids of `messages` in order.
  535. fn ids_of(messages: Vec<StoredMessage>) -> Vec<String> {
  536. messages.into_iter().map(|stored| stored.message_id).collect()
  537. }
  538. #[test]
  539. fn messages_since_excludes_older_and_includes_boundary() {
  540. let history = History::open_in_memory().unwrap();
  541. save_chat_a(&history, &[("m1", 9), ("m2", 10), ("m3", 11)]);
  542. history.save_message(&message("other", CHAT_B, 50)).unwrap();
  543. let ids = ids_of(history.messages_since(CHAT_A, 10, 10).unwrap());
  544. assert_eq!(ids, ["m2", "m3"]);
  545. }
  546. #[test]
  547. fn messages_since_respects_limit_keeping_most_recent() {
  548. let history = History::open_in_memory().unwrap();
  549. save_chat_a(&history, &[("m1", 10), ("m2", 20), ("m3", 30), ("m4", 40)]);
  550. let ids = ids_of(history.messages_since(CHAT_A, 0, 2).unwrap());
  551. assert_eq!(ids, ["m3", "m4"]);
  552. assert!(history.messages_since(CHAT_A, 0, 0).unwrap().is_empty());
  553. }
  554. #[test]
  555. fn messages_since_returns_ascending_order() {
  556. let history = History::open_in_memory().unwrap();
  557. save_chat_a(&history, &[("m3", 30), ("m1", 10), ("m4", 40), ("m2", 20)]);
  558. let ids = ids_of(history.messages_since(CHAT_A, 0, 10).unwrap());
  559. assert_eq!(ids, ["m1", "m2", "m3", "m4"]);
  560. }
  561. #[test]
  562. fn message_flags_and_sender_name_roundtrip() {
  563. let history = History::open_in_memory().unwrap();
  564. let mut sent = message("m1", CHAT_A, 10);
  565. sent.from_me = true;
  566. sent.is_ai = true;
  567. sent.sender_name = Some("Me".to_string());
  568. history.save_message(&sent).unwrap();
  569. assert_eq!(history.recent_messages(CHAT_A, 1).unwrap(), vec![sent]);
  570. }
  571. #[test]
  572. fn list_chats_orders_by_last_timestamp_desc_and_keeps_max() {
  573. let history = History::open_in_memory().unwrap();
  574. history.save_message(&message("a1", CHAT_A, 100)).unwrap();
  575. history.save_message(&message("b1", CHAT_B, 200)).unwrap();
  576. history.save_message(&message("a0", CHAT_A, 50)).unwrap();
  577. let chats = history.list_chats().unwrap();
  578. let summary: Vec<(&str, i64)> = chats
  579. .iter()
  580. .map(|chat| (chat.jid.as_str(), chat.last_timestamp))
  581. .collect();
  582. assert_eq!(summary, [(CHAT_B, 200), (CHAT_A, 100)]);
  583. }
  584. #[test]
  585. fn chat_name_falls_back_contact_then_chat_name_then_jid_user_part() {
  586. let history = History::open_in_memory().unwrap();
  587. history.save_message(&message("a1", CHAT_A, 30)).unwrap();
  588. let mut named = message("b1", CHAT_B, 20);
  589. named.sender_name = Some("Push Bob".to_string());
  590. history.save_message(&named).unwrap();
  591. history.save_message(&message("g1", GROUP, 10)).unwrap();
  592. let names = |history: &History| -> Vec<String> {
  593. history.list_chats().unwrap().into_iter().map(|chat| chat.name).collect()
  594. };
  595. assert_eq!(names(&history), ["393331111111", "Push Bob", "120363000000"]);
  596. history.upsert_contact(&contact(CHAT_B, "Roberto")).unwrap();
  597. assert_eq!(names(&history), ["393331111111", "Roberto", "120363000000"]);
  598. assert_eq!(history.display_name(CHAT_A).unwrap(), "393331111111");
  599. assert_eq!(history.display_name(CHAT_B).unwrap(), "Roberto");
  600. assert_eq!(history.display_name("unknown@s.whatsapp.net").unwrap(), "unknown");
  601. }
  602. #[test]
  603. fn chat_name_is_set_once_and_never_from_own_messages() {
  604. let history = History::open_in_memory().unwrap();
  605. let mut own = message("m1", CHAT_A, 10);
  606. own.from_me = true;
  607. own.sender_name = Some("Me".to_string());
  608. history.save_message(&own).unwrap();
  609. assert_eq!(history.display_name(CHAT_A).unwrap(), "393331111111");
  610. let mut first = message("m2", CHAT_A, 20);
  611. first.sender_name = Some("Alice".to_string());
  612. let mut second = message("m3", CHAT_A, 30);
  613. second.sender_name = Some("Alice Renamed".to_string());
  614. history.save_message(&first).unwrap();
  615. history.save_message(&second).unwrap();
  616. assert_eq!(history.display_name(CHAT_A).unwrap(), "Alice");
  617. }
  618. #[test]
  619. fn autoreply_defaults_to_false_and_toggles() {
  620. let history = History::open_in_memory().unwrap();
  621. assert!(!history.is_autoreply(CHAT_A).unwrap());
  622. history.set_autoreply(CHAT_A, true).unwrap();
  623. assert!(history.is_autoreply(CHAT_A).unwrap());
  624. assert!(!history.is_autoreply(CHAT_B).unwrap());
  625. history.set_autoreply(CHAT_A, false).unwrap();
  626. assert!(!history.is_autoreply(CHAT_A).unwrap());
  627. }
  628. #[test]
  629. fn global_autoreply_defaults_to_false_toggles_and_is_independent_of_chats() {
  630. let history = History::open_in_memory().unwrap();
  631. assert!(!history.is_global_autoreply().unwrap());
  632. history.set_global_autoreply(true).unwrap();
  633. assert!(history.is_global_autoreply().unwrap());
  634. assert!(!history.is_autoreply(CHAT_A).unwrap());
  635. history.set_global_autoreply(false).unwrap();
  636. assert!(!history.is_global_autoreply().unwrap());
  637. }
  638. #[test]
  639. fn autoreply_survives_incoming_messages_and_shows_in_chat_list() {
  640. let history = History::open_in_memory().unwrap();
  641. history.set_autoreply(CHAT_A, true).unwrap();
  642. history.save_message(&message("m1", CHAT_A, 10)).unwrap();
  643. let chats = history.list_chats().unwrap();
  644. assert_eq!(chats.len(), 1);
  645. assert!(chats[0].autoreply);
  646. assert_eq!(chats[0].last_timestamp, 10);
  647. }
  648. #[test]
  649. fn message_exists_only_for_stored_messages_until_the_chat_is_deleted() {
  650. let history = History::open_in_memory().unwrap();
  651. history.save_message(&message("a1", CHAT_A, 10)).unwrap();
  652. assert!(history.message_exists("a1").unwrap());
  653. assert!(!history.message_exists("missing").unwrap());
  654. history.delete_chat(CHAT_A).unwrap();
  655. assert!(!history.message_exists("a1").unwrap());
  656. }
  657. #[test]
  658. fn image_descriptions_are_stored_per_chat_replaced_and_deleted_with_the_chat() {
  659. let history = History::open_in_memory().unwrap();
  660. history.save_image_description("a1", CHAT_A, "un gatto").unwrap();
  661. history.save_image_description("a1", CHAT_A, "un gatto nero").unwrap();
  662. history.save_image_description("b1", CHAT_B, "una torta").unwrap();
  663. let descriptions = history.image_descriptions(CHAT_A).unwrap();
  664. assert_eq!(descriptions.len(), 1);
  665. assert_eq!(descriptions.get("a1").map(String::as_str), Some("un gatto nero"));
  666. history.delete_chat(CHAT_A).unwrap();
  667. assert!(history.image_descriptions(CHAT_A).unwrap().is_empty());
  668. assert_eq!(history.image_descriptions(CHAT_B).unwrap().len(), 1);
  669. }
  670. #[test]
  671. fn delete_chat_removes_messages_and_row_but_keeps_contact_and_other_chats() {
  672. let history = History::open_in_memory().unwrap();
  673. history.save_message(&message("a1", CHAT_A, 10)).unwrap();
  674. history.save_message(&message("a2", CHAT_A, 20)).unwrap();
  675. history.save_message(&message("b1", CHAT_B, 30)).unwrap();
  676. history.upsert_contact(&contact(CHAT_A, "Anna")).unwrap();
  677. history.set_autoreply(CHAT_A, true).unwrap();
  678. history.delete_chat(CHAT_A).unwrap();
  679. let jids: Vec<String> = history
  680. .list_chats()
  681. .unwrap()
  682. .into_iter()
  683. .map(|chat| chat.jid)
  684. .collect();
  685. assert_eq!(jids, [CHAT_B]);
  686. assert!(history.recent_messages(CHAT_A, 10).unwrap().is_empty());
  687. assert!(!history.recent_messages(CHAT_B, 10).unwrap().is_empty());
  688. assert_eq!(history.list_contacts().unwrap(), vec![contact(CHAT_A, "Anna")]);
  689. assert!(!history.is_autoreply(CHAT_A).unwrap());
  690. }
  691. #[test]
  692. fn list_contacts_is_ordered_case_insensitive() {
  693. let history = History::open_in_memory().unwrap();
  694. history.upsert_contact(&contact("3@s.whatsapp.net", "bruno")).unwrap();
  695. history.upsert_contact(&contact("1@s.whatsapp.net", "Carla")).unwrap();
  696. history.upsert_contact(&contact("2@s.whatsapp.net", "Anna")).unwrap();
  697. let names: Vec<String> = history
  698. .list_contacts()
  699. .unwrap()
  700. .into_iter()
  701. .map(|stored| stored.name)
  702. .collect();
  703. assert_eq!(names, ["Anna", "bruno", "Carla"]);
  704. }
  705. #[test]
  706. fn upsert_contact_ignores_blank_names_and_renames_existing() {
  707. let history = History::open_in_memory().unwrap();
  708. history.upsert_contact(&contact(CHAT_A, "")).unwrap();
  709. history.upsert_contact(&contact(CHAT_A, " ")).unwrap();
  710. assert!(history.list_contacts().unwrap().is_empty());
  711. history.upsert_contact(&contact(CHAT_A, "Anna")).unwrap();
  712. history.upsert_contact(&contact(CHAT_A, "")).unwrap();
  713. history.upsert_contact(&contact(CHAT_A, "Anna B")).unwrap();
  714. assert_eq!(history.list_contacts().unwrap(), vec![contact(CHAT_A, "Anna B")]);
  715. }
  716. #[test]
  717. fn open_persists_data_on_disk_and_creates_parent_directory() {
  718. let directory = std::env::temp_dir().join(format!("wh-history-test-{}", std::process::id()));
  719. let path = directory.join("nested").join("history.db");
  720. {
  721. let history = History::open(&path).unwrap();
  722. history.save_message(&message("m1", CHAT_A, 10)).unwrap();
  723. }
  724. let reopened = History::open(&path).unwrap();
  725. assert_eq!(reopened.recent_messages(CHAT_A, 10).unwrap().len(), 1);
  726. drop(reopened);
  727. std::fs::remove_dir_all(&directory).unwrap();
  728. }
  729. }