imessage_database/tables/
chat.rs

1/*!
2 This module represents common (but not all) columns in the `chat` table.
3*/
4
5use std::collections::HashMap;
6
7use plist::Value;
8use rusqlite::{Connection, Error, Result, Row, Statement, blob::Blob};
9
10use crate::{
11    error::{plist::PlistParseError, table::TableError},
12    tables::{
13        messages::models::Service,
14        table::{CHAT, Cacheable, GetBlob, PROPERTIES, Table},
15    },
16    util::plist::{get_bool_from_dict, get_owned_string_from_dict},
17};
18
19/// Chat properties are stored as a `plist` in the database
20/// This represents the metadata for a chatroom
21#[derive(Debug, PartialEq, Eq)]
22pub struct Properties {
23    /// Whether the chat has read receipts enabled
24    read_receipts_enabled: bool,
25    /// The most recent message in the chat
26    last_message_guid: Option<String>,
27    /// Whether the chat was forced to use SMS/RCS instead of iMessage
28    forced_sms: bool,
29    /// GUID of the group photo, if it exists in the attachments table
30    group_photo_guid: Option<String>,
31}
32
33impl Properties {
34    /// Create a new `Properties` given a `plist` blob
35    pub(self) fn from_plist(plist: &Value) -> Result<Self, PlistParseError> {
36        Ok(Self {
37            read_receipts_enabled: get_bool_from_dict(plist, "EnableReadReceiptForChat")
38                .unwrap_or(false),
39            last_message_guid: get_owned_string_from_dict(plist, "lastSeenMessageGuid"),
40            forced_sms: get_bool_from_dict(plist, "shouldForceToSMS").unwrap_or(false),
41            group_photo_guid: get_owned_string_from_dict(plist, "groupPhotoGuid"),
42        })
43    }
44}
45
46/// Represents a single row in the `chat` table.
47#[derive(Debug)]
48pub struct Chat {
49    /// The unique identifier for the chat in the database
50    pub rowid: i32,
51    /// The identifier for the chat, typically a phone number, email, or group chat ID
52    pub chat_identifier: String,
53    /// The service the chat used, i.e. iMessage, SMS, IRC, etc.
54    pub service_name: Option<String>,
55    /// Optional custom name created created for the chat
56    pub display_name: Option<String>,
57}
58
59impl Table for Chat {
60    fn from_row(row: &Row) -> Result<Chat> {
61        Ok(Chat {
62            rowid: row.get("rowid")?,
63            chat_identifier: row.get("chat_identifier")?,
64            service_name: row.get("service_name")?,
65            display_name: row.get("display_name").unwrap_or(None),
66        })
67    }
68
69    fn get(db: &Connection) -> Result<Statement, TableError> {
70        Ok(db.prepare(&format!("SELECT * from {CHAT}"))?)
71    }
72
73    fn extract(chat: Result<Result<Self, Error>, Error>) -> Result<Self, TableError> {
74        match chat {
75            Ok(Ok(chat)) => Ok(chat),
76            Err(why) | Ok(Err(why)) => Err(TableError::QueryError(why)),
77        }
78    }
79}
80
81impl Cacheable for Chat {
82    type K = i32;
83    type V = Chat;
84    /// Generate a hashmap containing each chatroom's ID pointing to the chatroom's metadata.
85    ///
86    /// These chatroom ID's contain duplicates and must be deduped later once we have all of
87    /// the participants parsed out. On its own this data is not useful.
88    ///
89    /// # Example:
90    ///
91    /// ```
92    /// use imessage_database::util::dirs::default_db_path;
93    /// use imessage_database::tables::table::{Cacheable, get_connection};
94    /// use imessage_database::tables::chat::Chat;
95    ///
96    /// let db_path = default_db_path();
97    /// let conn = get_connection(&db_path).unwrap();
98    /// let chatrooms = Chat::cache(&conn);
99    /// ```
100    fn cache(db: &Connection) -> Result<HashMap<Self::K, Self::V>, TableError> {
101        let mut map = HashMap::new();
102
103        let mut statement = Chat::get(db)?;
104
105        let chats = statement.query_map([], |row| Ok(Chat::from_row(row)))?;
106
107        for chat in chats {
108            let result = Chat::extract(chat)?;
109            map.insert(result.rowid, result);
110        }
111        Ok(map)
112    }
113}
114
115impl GetBlob for Chat {
116    /// Extract a blob of data that belongs to a single message from a given column
117    fn get_blob<'a>(&self, db: &'a Connection, column: &str) -> Option<Blob<'a>> {
118        db.blob_open(rusqlite::MAIN_DB, CHAT, column, i64::from(self.rowid), true)
119            .ok()
120    }
121}
122
123impl Chat {
124    /// Generate a name for a chat, falling back to the default if a custom one is not set
125    #[must_use]
126    pub fn name(&self) -> &str {
127        match self.display_name() {
128            Some(name) => name,
129            None => &self.chat_identifier,
130        }
131    }
132
133    /// Get the current display name for the chat, if it exists.
134    #[must_use]
135    pub fn display_name(&self) -> Option<&str> {
136        match &self.display_name {
137            Some(name) => {
138                if !name.is_empty() {
139                    return Some(name.as_str());
140                }
141                None
142            }
143            None => None,
144        }
145    }
146
147    /// Get the service used by the chat, i.e. iMessage, SMS, IRC, etc.
148    #[must_use]
149    pub fn service(&self) -> Service {
150        Service::from(self.service_name.as_deref())
151    }
152
153    /// Get the [`Properties`] for the chat, if they exist
154    ///
155    /// Calling this hits the database, so it is expensive and should
156    /// only get invoked when needed.
157    #[must_use]
158    pub fn properties(&self, db: &Connection) -> Option<Properties> {
159        match Value::from_reader(self.get_blob(db, PROPERTIES)?) {
160            Ok(plist) => Properties::from_plist(&plist).ok(),
161            Err(_) => None,
162        }
163    }
164}
165
166#[cfg(test)]
167mod test_properties {
168    use plist::Value;
169    use std::env::current_dir;
170    use std::fs::File;
171
172    use crate::tables::chat::Properties;
173
174    #[test]
175    fn test_can_parse_properties_simple() {
176        let plist_path = current_dir()
177            .unwrap()
178            .as_path()
179            .join("test_data/chat_properties/ChatProp1.plist");
180        let plist_data = File::open(plist_path).unwrap();
181        let plist = Value::from_reader(plist_data).unwrap();
182        println!("Parsed plist: {plist:#?}");
183
184        let actual = Properties::from_plist(&plist).unwrap();
185        let expected = Properties {
186            read_receipts_enabled: false,
187            last_message_guid: Some(String::from("FF0615B9-C4AF-4BD8-B9A8-1B5F9351033F")),
188            forced_sms: false,
189            group_photo_guid: None,
190        };
191        print!("Parsed properties: {expected:?}");
192        assert_eq!(actual, expected);
193    }
194
195    #[test]
196    fn test_can_parse_properties_enable_read_receipts() {
197        let plist_path = current_dir()
198            .unwrap()
199            .as_path()
200            .join("test_data/chat_properties/ChatProp2.plist");
201        let plist_data = File::open(plist_path).unwrap();
202        let plist = Value::from_reader(plist_data).unwrap();
203        println!("Parsed plist: {plist:#?}");
204
205        let actual = Properties::from_plist(&plist).unwrap();
206        let expected = Properties {
207            read_receipts_enabled: true,
208            last_message_guid: Some(String::from("678BA15C-C309-FAAC-3678-78ACE995EB54")),
209            forced_sms: false,
210            group_photo_guid: None,
211        };
212        print!("Parsed properties: {expected:?}");
213        assert_eq!(actual, expected);
214    }
215
216    #[test]
217    fn test_can_parse_properties_third_with_summary() {
218        let plist_path = current_dir()
219            .unwrap()
220            .as_path()
221            .join("test_data/chat_properties/ChatProp3.plist");
222        let plist_data = File::open(plist_path).unwrap();
223        let plist = Value::from_reader(plist_data).unwrap();
224        println!("Parsed plist: {plist:#?}");
225
226        let actual = Properties::from_plist(&plist).unwrap();
227        let expected = Properties {
228            read_receipts_enabled: false,
229            last_message_guid: Some(String::from("CEE419B6-17C7-42F7-8C2A-09A38CCA5730")),
230            forced_sms: false,
231            group_photo_guid: None,
232        };
233        print!("Parsed properties: {expected:?}");
234        assert_eq!(actual, expected);
235    }
236
237    #[test]
238    fn test_can_parse_properties_forced_sms() {
239        let plist_path = current_dir()
240            .unwrap()
241            .as_path()
242            .join("test_data/chat_properties/ChatProp4.plist");
243        let plist_data = File::open(plist_path).unwrap();
244        let plist = Value::from_reader(plist_data).unwrap();
245        println!("Parsed plist: {plist:#?}");
246
247        let actual = Properties::from_plist(&plist).unwrap();
248        let expected = Properties {
249            read_receipts_enabled: false,
250            last_message_guid: Some(String::from("87D5257D-6536-4067-A8A0-E7EF10ECBA9D")),
251            forced_sms: true,
252            group_photo_guid: None,
253        };
254        print!("Parsed properties: {expected:?}");
255        assert_eq!(actual, expected);
256    }
257}