imessage_database/tables/messages/message.rs
1/*!
2 This module represents common (but not all) columns in the `message` table.
3
4 # Iterating over Message Data
5
6 Generally, use [`Message::get()`] or [`Message::stream_rows()`] to iterate over message rows.
7
8 ## Example
9 ```rust
10 use imessage_database::{
11 tables::{
12 messages::Message,
13 table::{get_connection, Diagnostic, Table},
14 },
15 util::dirs::default_db_path,
16 };
17
18 let db_path = default_db_path();
19 let conn = get_connection(&db_path).unwrap();
20
21 let mut statement = Message::get(&conn).unwrap();
22
23 let messages = statement.query_map([], |row| Ok(Message::from_row(row))).unwrap();
24
25 messages.map(|msg| println!("{:#?}", Message::extract(msg)));
26 ```
27
28 # Making Custom Message Queries
29
30 In addition columns from the `messages` table, there are several additional fields represented
31 by [`Message`] that are not present in the database:
32
33 - [`Message::chat_id`]
34 - [`Message::num_attachments`]
35 - [`Message::deleted_from`]
36 - [`Message::num_replies`]
37
38 ## Sample Queries
39
40 To provide a custom query, ensure inclusion of the foregoing columns:
41
42 ```sql
43 SELECT
44 *,
45 c.chat_id,
46 (SELECT COUNT(*) FROM message_attachment_join a WHERE m.ROWID = a.message_id) as num_attachments,
47 d.chat_id as deleted_from,
48 (SELECT COUNT(*) FROM message m2 WHERE m2.thread_originator_guid = m.guid) as num_replies
49 FROM
50 message as m
51 LEFT JOIN chat_message_join as c ON m.ROWID = c.message_id
52 LEFT JOIN chat_recoverable_message_join as d ON m.ROWID = d.message_id
53 ORDER BY
54 m.date;
55 ```
56
57 If the source database does not include these required columns, include them as so:
58
59 ```sql
60 SELECT
61 *,
62 c.chat_id,
63 (SELECT COUNT(*) FROM message_attachment_join a WHERE m.ROWID = a.message_id) as num_attachments,
64 NULL as deleted_from,
65 0 as num_replies
66 FROM
67 message as m
68 LEFT JOIN chat_message_join as c ON m.ROWID = c.message_id
69 ORDER BY
70 m.date;
71 ```
72
73 ## Custom Query Example
74
75 The following will return an iterator over messages that have an associated emoji:
76
77
78 ```rust
79 use imessage_database::{
80 tables::{
81 messages::Message,
82 table::{get_connection, Diagnostic, Table},
83 },
84 util::dirs::default_db_path
85 };
86
87 let db_path = default_db_path();
88 let db = get_connection(&db_path).unwrap();
89
90 let mut statement = db.prepare("
91 SELECT
92 *,
93 c.chat_id,
94 (SELECT COUNT(*) FROM message_attachment_join a WHERE m.ROWID = a.message_id) as num_attachments,
95 d.chat_id as deleted_from,
96 (SELECT COUNT(*) FROM message m2 WHERE m2.thread_originator_guid = m.guid) as num_replies
97 FROM
98 message as m
99 LEFT JOIN chat_message_join as c ON m.ROWID = c.message_id
100 LEFT JOIN chat_recoverable_message_join as d ON m.ROWID = d.message_id
101 WHERE m.associated_message_emoji IS NOT NULL
102 ORDER BY
103 m.date;
104 ").unwrap();
105
106 let messages = statement.query_map([], |row| Ok(Message::from_row(row))).unwrap();
107
108 messages.map(|msg| println!("{:#?}", Message::extract(msg)));
109 ```
110*/
111
112use std::{collections::HashMap, io::Read};
113
114use chrono::{DateTime, offset::Local};
115use plist::Value;
116use rusqlite::{Connection, Error, Result, Row, Statement, blob::Blob};
117
118use crate::{
119 error::{message::MessageError, table::TableError},
120 message_types::{
121 edited::{EditStatus, EditedMessage},
122 expressives::{BubbleEffect, Expressive, ScreenEffect},
123 variants::{Announcement, BalloonProvider, CustomBalloon, Tapback, TapbackAction, Variant},
124 },
125 tables::{
126 messages::{
127 body::{parse_body_legacy, parse_body_typedstream},
128 models::{BubbleComponent, GroupAction, Service},
129 query_parts::{ios_13_older_query, ios_14_15_query, ios_16_newer_query},
130 },
131 table::{
132 ATTRIBUTED_BODY, AttributedBody, CHAT_MESSAGE_JOIN, Cacheable, Diagnostic, GetBlob,
133 MESSAGE, MESSAGE_ATTACHMENT_JOIN, MESSAGE_PAYLOAD, MESSAGE_SUMMARY_INFO,
134 RECENTLY_DELETED, Table,
135 },
136 },
137 util::{
138 bundle_id::parse_balloon_bundle_id,
139 dates::{get_local_time, readable_diff},
140 output::{done_processing, processing},
141 query_context::QueryContext,
142 streamtyped,
143 typedstream::{models::Archivable, parser::TypedStreamReader},
144 },
145};
146
147/// The required columns, interpolated into the most recent schema due to performance considerations
148pub(crate) const COLS: &str = "rowid, guid, text, service, handle_id, destination_caller_id, subject, date, date_read, date_delivered, is_from_me, is_read, item_type, other_handle, share_status, share_direction, group_title, group_action_type, associated_message_guid, associated_message_type, balloon_bundle_id, expressive_send_style_id, thread_originator_guid, thread_originator_part, date_edited, associated_message_emoji";
149
150/// Represents a single row in the `message` table.
151///
152/// Additional information is available in the [parent](crate::tables::messages::message) module.
153#[derive(Debug)]
154#[allow(non_snake_case)]
155pub struct Message {
156 pub rowid: i32,
157 pub guid: String,
158 /// The text of the message, which may require calling [`Self::generate_text()`] to populate
159 pub text: Option<String>,
160 /// The service the message was sent from
161 pub service: Option<String>,
162 /// The ID of the person who sent the message
163 pub handle_id: Option<i32>,
164 /// The address the database owner received the message at, i.e. a phone number or email
165 pub destination_caller_id: Option<String>,
166 /// The content of the Subject field
167 pub subject: Option<String>,
168 /// The date the message was written to the database
169 pub date: i64,
170 /// The date the message was read
171 pub date_read: i64,
172 /// The date a message was delivered
173 pub date_delivered: i64,
174 /// `true` if the database owner sent the message, else `false`
175 pub is_from_me: bool,
176 /// `true` if the message was read by the recipient, else `false`
177 pub is_read: bool,
178 /// Intermediate data for determining the [`Variant`] of a message
179 pub item_type: i32,
180 /// Optional handle for the recipient of a message that includes shared content
181 pub other_handle: Option<i32>,
182 /// Boolean determining whether some shared data is active or inactive, i.e. shared location being enabled or disabled
183 pub share_status: bool,
184 /// Boolean determining the direction shared data was sent; `false` indicates it was sent from the database owner, `true` indicates it was sent to the database owner
185 pub share_direction: Option<bool>,
186 /// If the message updates the [`display_name`](crate::tables::chat::Chat::display_name) of the chat, this field will be populated
187 pub group_title: Option<String>,
188 /// If the message modified for a group, this will be nonzero
189 pub group_action_type: i32,
190 /// The message GUID of a message associated with this one
191 pub associated_message_guid: Option<String>,
192 /// Intermediate data for determining the [`Variant`] of a message
193 pub associated_message_type: Option<i32>,
194 /// The [bundle ID](https://developer.apple.com/help/app-store-connect/reference/app-bundle-information) of the app that generated the [`AppMessage`](crate::message_types::app::AppMessage)
195 pub balloon_bundle_id: Option<String>,
196 /// Intermediate data for determining the [`expressive`](crate::message_types::expressives) of a message
197 pub expressive_send_style_id: Option<String>,
198 /// Indicates the first message in a thread of replies in [`get_replies()`](crate::tables::messages::Message::get_replies)
199 pub thread_originator_guid: Option<String>,
200 /// Indicates the part of a message a reply is pointing to
201 pub thread_originator_part: Option<String>,
202 /// The date the message was most recently edited
203 pub date_edited: i64,
204 /// If present, this is the emoji associated with a custom emoji tapback
205 pub associated_message_emoji: Option<String>,
206 /// The [`identifier`](crate::tables::chat::Chat::chat_identifier) of the chat the message belongs to
207 pub chat_id: Option<i32>,
208 /// The number of attached files included in the message
209 pub num_attachments: i32,
210 /// The [`identifier`](crate::tables::chat::Chat::chat_identifier) of the chat the message was deleted from
211 pub deleted_from: Option<i32>,
212 /// The number of replies to the message
213 pub num_replies: i32,
214 /// The components of the message body, parsed by [`TypedStreamReader`]
215 pub components: Option<Vec<Archivable>>,
216 /// The components of the message that may or may not have been edited or unsent
217 pub edited_parts: Option<EditedMessage>,
218}
219
220impl Table for Message {
221 fn from_row(row: &Row) -> Result<Message> {
222 Ok(Message {
223 rowid: row.get("rowid")?,
224 guid: row.get("guid")?,
225 text: row.get("text").unwrap_or(None),
226 service: row.get("service").unwrap_or(None),
227 handle_id: row.get("handle_id").unwrap_or(None),
228 destination_caller_id: row.get("destination_caller_id").unwrap_or(None),
229 subject: row.get("subject").unwrap_or(None),
230 date: row.get("date")?,
231 date_read: row.get("date_read").unwrap_or(0),
232 date_delivered: row.get("date_delivered").unwrap_or(0),
233 is_from_me: row.get("is_from_me")?,
234 is_read: row.get("is_read").unwrap_or(false),
235 item_type: row.get("item_type").unwrap_or_default(),
236 other_handle: row.get("other_handle").unwrap_or(None),
237 share_status: row.get("share_status").unwrap_or(false),
238 share_direction: row.get("share_direction").unwrap_or(None),
239 group_title: row.get("group_title").unwrap_or(None),
240 group_action_type: row.get("group_action_type").unwrap_or(0),
241 associated_message_guid: row.get("associated_message_guid").unwrap_or(None),
242 associated_message_type: row.get("associated_message_type").unwrap_or(None),
243 balloon_bundle_id: row.get("balloon_bundle_id").unwrap_or(None),
244 expressive_send_style_id: row.get("expressive_send_style_id").unwrap_or(None),
245 thread_originator_guid: row.get("thread_originator_guid").unwrap_or(None),
246 thread_originator_part: row.get("thread_originator_part").unwrap_or(None),
247 date_edited: row.get("date_edited").unwrap_or(0),
248 associated_message_emoji: row.get("associated_message_emoji").unwrap_or(None),
249 chat_id: row.get("chat_id").unwrap_or(None),
250 num_attachments: row.get("num_attachments")?,
251 deleted_from: row.get("deleted_from").unwrap_or(None),
252 num_replies: row.get("num_replies")?,
253 components: None,
254 edited_parts: None,
255 })
256 }
257
258 /// Convert data from the messages table to native Rust data structures, falling back to
259 /// more compatible queries to ensure compatibility with older database schemas
260 fn get(db: &Connection) -> Result<Statement, TableError> {
261 db.prepare(&ios_16_newer_query(None))
262 .or_else(|_| db.prepare(&ios_14_15_query(None)))
263 .or_else(|_| db.prepare(&ios_13_older_query(None)))
264 .map_err(TableError::Messages)
265 }
266
267 fn extract(message: Result<Result<Self, Error>, Error>) -> Result<Self, TableError> {
268 match message {
269 Ok(Ok(message)) => Ok(message),
270 Err(why) | Ok(Err(why)) => Err(TableError::Messages(why)),
271 }
272 }
273}
274
275impl Diagnostic for Message {
276 /// Emit diagnostic data for the Messages table
277 ///
278 /// # Example:
279 ///
280 /// ```
281 /// use imessage_database::util::dirs::default_db_path;
282 /// use imessage_database::tables::table::{Diagnostic, get_connection};
283 /// use imessage_database::tables::messages::Message;
284 ///
285 /// let db_path = default_db_path();
286 /// let conn = get_connection(&db_path).unwrap();
287 /// Message::run_diagnostic(&conn);
288 /// ```
289 fn run_diagnostic(db: &Connection) -> Result<(), TableError> {
290 processing();
291 let mut messages_without_chat = db
292 .prepare(&format!(
293 "
294 SELECT
295 COUNT(m.rowid)
296 FROM
297 {MESSAGE} as m
298 LEFT JOIN {CHAT_MESSAGE_JOIN} as c ON m.rowid = c.message_id
299 WHERE
300 c.chat_id is NULL
301 ORDER BY
302 m.date
303 "
304 ))
305 .map_err(TableError::Messages)?;
306
307 let num_dangling: i32 = messages_without_chat
308 .query_row([], |r| r.get(0))
309 .unwrap_or(0);
310
311 let mut messages_in_more_than_one_chat_q = db
312 .prepare(&format!(
313 "
314 SELECT
315 COUNT(*)
316 FROM (
317 SELECT DISTINCT
318 message_id
319 , COUNT(chat_id) AS c
320 FROM {CHAT_MESSAGE_JOIN}
321 GROUP BY
322 message_id
323 HAVING c > 1);
324 "
325 ))
326 .map_err(TableError::Messages)?;
327
328 let messages_in_more_than_one_chat: i32 = messages_in_more_than_one_chat_q
329 .query_row([], |r| r.get(0))
330 .unwrap_or(0);
331
332 let mut messages_count = db
333 .prepare(&format!(
334 "
335 SELECT
336 COUNT(rowid)
337 FROM
338 {MESSAGE}
339 "
340 ))
341 .map_err(TableError::Messages)?;
342
343 let total_messages: i64 = messages_count.query_row([], |r| r.get(0)).unwrap_or(0);
344
345 done_processing();
346
347 println!("Message diagnostic data:");
348 println!(" Total messages: {total_messages}");
349 if num_dangling > 0 {
350 println!(" Messages not associated with a chat: {num_dangling}");
351 }
352 if messages_in_more_than_one_chat > 0 {
353 println!(
354 " Messages belonging to more than one chat: {messages_in_more_than_one_chat}"
355 );
356 }
357 Ok(())
358 }
359}
360
361impl Cacheable for Message {
362 type K = String;
363 type V = HashMap<usize, Vec<Self>>;
364 /// Used for tapbacks that do not exist in a foreign key table
365 ///
366 /// Builds a map like:
367 ///
368 /// ```json
369 /// {
370 /// "message_guid": {
371 /// 0: [Message, Message],
372 /// 1: [Message]
373 /// }
374 /// }
375 /// ```
376 ///
377 /// Where the `0` and `1` are the tapback indexes in the body of the message mapped by `message_guid`
378 fn cache(db: &Connection) -> Result<HashMap<Self::K, Self::V>, TableError> {
379 // Create cache for user IDs
380 let mut map: HashMap<Self::K, Self::V> = HashMap::new();
381
382 // Create query
383 let statement = db.prepare(&format!(
384 "SELECT
385 {COLS},
386 c.chat_id,
387 (SELECT COUNT(*) FROM {MESSAGE_ATTACHMENT_JOIN} a WHERE m.ROWID = a.message_id) as num_attachments,
388 NULL as deleted_from,
389 0 as num_replies
390 FROM
391 {MESSAGE} as m
392 LEFT JOIN {CHAT_MESSAGE_JOIN} as c ON m.ROWID = c.message_id
393 WHERE m.associated_message_guid IS NOT NULL
394 "
395 )).or_else(|_| db.prepare(&format!(
396 "SELECT
397 *,
398 c.chat_id,
399 (SELECT COUNT(*) FROM {MESSAGE_ATTACHMENT_JOIN} a WHERE m.ROWID = a.message_id) as num_attachments,
400 NULL as deleted_from,
401 0 as num_replies
402 FROM
403 {MESSAGE} as m
404 LEFT JOIN {CHAT_MESSAGE_JOIN} as c ON m.ROWID = c.message_id
405 WHERE m.associated_message_guid IS NOT NULL
406 "
407 )));
408
409 if let Ok(mut statement) = statement {
410 // Execute query to build the message tapback map
411 let messages = statement
412 .query_map([], |row| Ok(Message::from_row(row)))
413 .map_err(TableError::Messages)?;
414
415 // Iterate over the messages and update the map
416 for message in messages {
417 let message = Self::extract(message)?;
418 if message.is_tapback() {
419 if let Some((idx, tapback_target_guid)) = message.clean_associated_guid() {
420 map.entry(tapback_target_guid.to_string())
421 .or_insert_with(HashMap::new)
422 .entry(idx)
423 .or_insert_with(Vec::new)
424 .push(message);
425 }
426 }
427 }
428 }
429
430 Ok(map)
431 }
432}
433
434impl GetBlob for Message {
435 /// Extract a blob of data that belongs to a single message from a given column
436 fn get_blob<'a>(&self, db: &'a Connection, column: &str) -> Option<Blob<'a>> {
437 match db.blob_open(
438 rusqlite::DatabaseName::Main,
439 MESSAGE,
440 column,
441 self.rowid as i64,
442 true,
443 ) {
444 Ok(blob) => Some(blob),
445 Err(_) => None,
446 }
447 }
448}
449
450impl AttributedBody for Message {
451 /// Get a vector of a message body's components. If the text has not been captured with [`Self::generate_text()`], the vector will be empty.
452 ///
453 /// For more detail see the trait documentation [here](crate::tables::table::AttributedBody).
454 fn body(&self) -> Vec<BubbleComponent> {
455 // If the message is an app, it will be rendered differently, so just escape there
456 if self.balloon_bundle_id.is_some() {
457 return vec![BubbleComponent::App];
458 }
459
460 if let Some(body) = parse_body_typedstream(
461 self.components.as_ref(),
462 self.text.as_deref(),
463 self.edited_parts.as_ref(),
464 ) {
465 return body;
466 }
467
468 // Naive logic for when `typedstream` component parsing fails
469 parse_body_legacy(&self.text)
470 }
471}
472
473impl Message {
474 /// Generate the text of a message, deserializing it as [`typedstream`](crate::util::typedstream) (and falling back to [`streamtyped`]) data if necessary.
475 pub fn generate_text<'a>(&'a mut self, db: &'a Connection) -> Result<&'a str, MessageError> {
476 // Grab the body data from the table
477 if let Some(body) = self.attributed_body(db) {
478 // Attempt to deserialize the typedstream data
479 let mut typedstream = TypedStreamReader::from(&body);
480 self.components = typedstream.parse().ok();
481
482 // If we deserialize the typedstream, use that data
483 self.text = self
484 .components
485 .as_ref()
486 .and_then(|items| items.first())
487 .and_then(|item| item.as_nsstring())
488 .map(String::from);
489
490 // If the above parsing failed, fall back to the legacy parser instead
491 if self.text.is_none() {
492 self.text =
493 Some(streamtyped::parse(body).map_err(MessageError::StreamTypedParseError)?);
494 }
495 }
496
497 // Generate the edited message data
498 self.edited_parts = self
499 .is_edited()
500 .then(|| self.message_summary_info(db))
501 .flatten()
502 .as_ref()
503 .and_then(|payload| EditedMessage::from_map(payload).ok());
504
505 if let Some(t) = &self.text {
506 Ok(t)
507 } else {
508 Err(MessageError::NoText)
509 }
510 }
511
512 /// Calculates the date a message was written to the database.
513 ///
514 /// This field is stored as a unix timestamp with an epoch of `2001-01-01 00:00:00` in the local time zone
515 ///
516 /// `offset` can be provided by [`get_offset`](crate::util::dates::get_offset) or manually.
517 pub fn date(&self, offset: &i64) -> Result<DateTime<Local>, MessageError> {
518 get_local_time(&self.date, offset)
519 }
520
521 /// Calculates the date a message was marked as delivered.
522 ///
523 /// This field is stored as a unix timestamp with an epoch of `2001-01-01 00:00:00` in the local time zone
524 ///
525 /// `offset` can be provided by [`get_offset`](crate::util::dates::get_offset) or manually.
526 pub fn date_delivered(&self, offset: &i64) -> Result<DateTime<Local>, MessageError> {
527 get_local_time(&self.date_delivered, offset)
528 }
529
530 /// Calculates the date a message was marked as read.
531 ///
532 /// This field is stored as a unix timestamp with an epoch of `2001-01-01 00:00:00` in the local time zone
533 ///
534 /// `offset` can be provided by [`get_offset`](crate::util::dates::get_offset) or manually.
535 pub fn date_read(&self, offset: &i64) -> Result<DateTime<Local>, MessageError> {
536 get_local_time(&self.date_read, offset)
537 }
538
539 /// Calculates the date a message was most recently edited.
540 ///
541 /// This field is stored as a unix timestamp with an epoch of `2001-01-01 00:00:00` in the local time zone
542 ///
543 /// `offset` can be provided by [`get_offset`](crate::util::dates::get_offset) or manually.
544 pub fn date_edited(&self, offset: &i64) -> Result<DateTime<Local>, MessageError> {
545 get_local_time(&self.date_edited, offset)
546 }
547
548 /// Gets the time until the message was read. This can happen in two ways:
549 ///
550 /// - You received a message, then waited to read it
551 /// - You sent a message, and the recipient waited to read it
552 ///
553 /// In the former case, this subtracts the date read column (`date_read`) from the date received column (`date`).
554 /// In the latter case, this subtracts the date delivered column (`date_delivered`) from the date received column (`date`).
555 ///
556 /// Not all messages get tagged with the read properties.
557 /// If more than one message has been sent in a thread before getting read,
558 /// only the most recent message will get the tag.
559 ///
560 /// `offset` can be provided by [`get_offset`](crate::util::dates::get_offset) or manually.
561 pub fn time_until_read(&self, offset: &i64) -> Option<String> {
562 // Message we received
563 if !self.is_from_me && self.date_read != 0 && self.date != 0 {
564 return readable_diff(self.date(offset), self.date_read(offset));
565 }
566 // Message we sent
567 else if self.is_from_me && self.date_delivered != 0 && self.date != 0 {
568 return readable_diff(self.date(offset), self.date_delivered(offset));
569 }
570 None
571 }
572
573 /// `true` if the message is a response to a thread, else `false`
574 pub fn is_reply(&self) -> bool {
575 self.thread_originator_guid.is_some()
576 }
577
578 /// `true` if the message is an [`Announcement`], else `false`
579 pub fn is_announcement(&self) -> bool {
580 self.get_announcement().is_some()
581 }
582
583 /// `true` if the message is a [`Tapback`] to another message, else `false`
584 pub fn is_tapback(&self) -> bool {
585 matches!(self.variant(), Variant::Tapback(..))
586 }
587
588 /// `true` if the message has an [`Expressive`], else `false`
589 pub fn is_expressive(&self) -> bool {
590 self.expressive_send_style_id.is_some()
591 }
592
593 /// `true` if the message has a [URL preview](crate::message_types::url), else `false`
594 pub fn is_url(&self) -> bool {
595 matches!(self.variant(), Variant::App(CustomBalloon::URL))
596 }
597
598 /// `true` if the message is a [`HandwrittenMessage`](crate::message_types::handwriting::models::HandwrittenMessage), else `false`
599 pub fn is_handwriting(&self) -> bool {
600 matches!(self.variant(), Variant::App(CustomBalloon::Handwriting))
601 }
602
603 /// `true` if the message is a [`Digital Touch`](crate::message_types::digital_touch::models), else `false`
604 pub fn is_digital_touch(&self) -> bool {
605 matches!(self.variant(), Variant::App(CustomBalloon::DigitalTouch))
606 }
607
608 /// `true` if the message was [`Edited`](crate::message_types::edited), else `false`
609 pub fn is_edited(&self) -> bool {
610 self.date_edited != 0
611 }
612
613 /// `true` if the specified message component was [edited](crate::message_types::edited::EditStatus::Edited), else `false`
614 pub fn is_part_edited(&self, index: usize) -> bool {
615 if let Some(edited_parts) = &self.edited_parts {
616 if let Some(part) = edited_parts.part(index) {
617 return matches!(part.status, EditStatus::Edited);
618 }
619 }
620 false
621 }
622
623 /// `true` if all message components were [unsent](crate::message_types::edited::EditStatus::Unsent), else `false`
624 pub fn is_fully_unsent(&self) -> bool {
625 self.edited_parts.as_ref().is_some_and(|ep| {
626 ep.parts
627 .iter()
628 .all(|part| matches!(part.status, EditStatus::Unsent))
629 })
630 }
631
632 /// `true` if the message contains [`Attachment`](crate::tables::attachment::Attachment)s, else `false`
633 ///
634 /// Attachments can be queried with [`Attachment::from_message()`](crate::tables::attachment::Attachment::from_message).
635 pub fn has_attachments(&self) -> bool {
636 self.num_attachments > 0
637 }
638
639 /// `true` if the message begins a thread, else `false`
640 pub fn has_replies(&self) -> bool {
641 self.num_replies > 0
642 }
643
644 /// `true` if the message is a [SharePlay/FaceTime](crate::message_types::variants::Variant::SharePlay) message, else `false`
645 pub fn is_shareplay(&self) -> bool {
646 self.item_type == 6
647 }
648
649 /// `true` if the message was sent by the database owner, else `false`
650 pub fn is_from_me(&self) -> bool {
651 if let (Some(other_handle), Some(share_direction)) =
652 (self.other_handle, self.share_direction)
653 {
654 self.is_from_me || other_handle != 0 && !share_direction
655 } else {
656 self.is_from_me
657 }
658 }
659
660 /// Get the group action for the current message
661 pub fn group_action(&self) -> Option<GroupAction> {
662 GroupAction::from_message(self)
663 }
664
665 /// `true` if the message indicates a sender started sharing their location, else `false`
666 pub fn started_sharing_location(&self) -> bool {
667 self.item_type == 4 && self.group_action_type == 0 && !self.share_status
668 }
669
670 /// `true` if the message indicates a sender stopped sharing their location, else `false`
671 pub fn stopped_sharing_location(&self) -> bool {
672 self.item_type == 4 && self.group_action_type == 0 && self.share_status
673 }
674
675 /// `true` if the message was deleted and is recoverable, else `false`
676 ///
677 /// Messages removed by deleting an entire conversation or by deleting a single message
678 /// from a conversation are moved to a separate collection for up to 30 days. Messages
679 /// present in this collection are restored to the conversations they belong to. Apple
680 /// details this process [here](https://support.apple.com/en-us/HT202549#delete).
681 ///
682 /// Messages that have expired from this restoration process are permanently deleted and
683 /// cannot be recovered.
684 ///
685 /// Note: This is not the same as an [`Unsent`](crate::message_types::edited::EditStatus::Unsent) message.
686 pub fn is_deleted(&self) -> bool {
687 self.deleted_from.is_some()
688 }
689
690 /// Get the index of the part of a message a reply is pointing to
691 fn get_reply_index(&self) -> usize {
692 if let Some(parts) = &self.thread_originator_part {
693 return match parts.split(':').next() {
694 Some(part) => str::parse::<usize>(part).unwrap_or(0),
695 None => 0,
696 };
697 }
698 0
699 }
700
701 /// Generate the SQL `WHERE` clause described by a [`QueryContext`].
702 ///
703 /// If `include_recoverable` is `true`, the filter includes messages from the recently deleted messages
704 /// table that match the chat IDs. This allows recovery of deleted messages that are still
705 /// present in the database but no longer visible in the Messages app.
706 pub(crate) fn generate_filter_statement(
707 context: &QueryContext,
708 include_recoverable: bool,
709 ) -> String {
710 let mut filters = String::new();
711
712 // Start date filter
713 if let Some(start) = context.start {
714 filters.push_str(&format!(" m.date >= {start}"));
715 }
716
717 // End date filter
718 if let Some(end) = context.end {
719 if !filters.is_empty() {
720 filters.push_str(" AND ");
721 }
722 filters.push_str(&format!(" m.date <= {end}"));
723 }
724
725 // Chat ID filter, optionally including recoverable messages
726 if let Some(chat_ids) = &context.selected_chat_ids {
727 if !filters.is_empty() {
728 filters.push_str(" AND ");
729 }
730
731 // Allocate the filter string for interpolation
732 let ids = chat_ids
733 .iter()
734 .map(|x| x.to_string())
735 .collect::<Vec<String>>()
736 .join(", ");
737
738 if include_recoverable {
739 filters.push_str(&format!(" (c.chat_id IN ({ids}) OR d.chat_id IN ({ids}))"));
740 } else {
741 filters.push_str(&format!(" c.chat_id IN ({ids})"));
742 }
743 }
744
745 if !filters.is_empty() {
746 return format!("WHERE {filters}");
747 }
748 filters
749 }
750
751 /// Get the number of messages in the database
752 ///
753 /// # Example:
754 ///
755 /// ```
756 /// use imessage_database::util::dirs::default_db_path;
757 /// use imessage_database::tables::table::{Diagnostic, get_connection};
758 /// use imessage_database::tables::messages::Message;
759 /// use imessage_database::util::query_context::QueryContext;
760 ///
761 /// let db_path = default_db_path();
762 /// let conn = get_connection(&db_path).unwrap();
763 /// let context = QueryContext::default();
764 /// Message::get_count(&conn, &context);
765 /// ```
766 pub fn get_count(db: &Connection, context: &QueryContext) -> Result<u64, TableError> {
767 let mut statement = if context.has_filters() {
768 db.prepare(&format!(
769 "SELECT
770 COUNT(*)
771 FROM {MESSAGE} as m
772 LEFT JOIN {CHAT_MESSAGE_JOIN} as c ON m.ROWID = c.message_id
773 LEFT JOIN {RECENTLY_DELETED} as d ON m.ROWID = d.message_id
774 {}",
775 Self::generate_filter_statement(context, true)
776 ))
777 .or_else(|_| {
778 db.prepare(&format!(
779 "SELECT
780 COUNT(*)
781 FROM {MESSAGE} as m
782 LEFT JOIN {CHAT_MESSAGE_JOIN} as c ON m.ROWID = c.message_id
783 {}",
784 Self::generate_filter_statement(context, false)
785 ))
786 })
787 .map_err(TableError::Messages)?
788 } else {
789 db.prepare(&format!("SELECT COUNT(*) FROM {MESSAGE}"))
790 .map_err(TableError::Messages)?
791 };
792 // Execute query, defaulting to zero if it fails
793 let count: u64 = statement.query_row([], |r| r.get(0)).unwrap_or(0);
794
795 Ok(count)
796 }
797
798 /// Stream messages from the database with optional filters.
799 ///
800 /// # Example:
801 ///
802 /// ```
803 /// use imessage_database::util::dirs::default_db_path;
804 /// use imessage_database::tables::table::{Diagnostic, get_connection};
805 /// use imessage_database::tables::{messages::Message, table::Table};
806 /// use imessage_database::util::query_context::QueryContext;
807 ///
808 /// let db_path = default_db_path();
809 /// let conn = get_connection(&db_path).unwrap();
810 /// let context = QueryContext::default();
811 ///
812 /// let mut statement = Message::stream_rows(&conn, &context).unwrap();
813 ///
814 /// let messages = statement.query_map([], |row| Ok(Message::from_row(row))).unwrap();
815 ///
816 /// messages.map(|msg| println!("{:#?}", Message::extract(msg)));
817 /// ```
818 pub fn stream_rows<'a>(
819 db: &'a Connection,
820 context: &'a QueryContext,
821 ) -> Result<Statement<'a>, TableError> {
822 if !context.has_filters() {
823 return Self::get(db);
824 }
825 db.prepare(&ios_16_newer_query(Some(&Self::generate_filter_statement(
826 context, true,
827 ))))
828 .or_else(|_| {
829 db.prepare(&ios_14_15_query(Some(&Self::generate_filter_statement(
830 context, false,
831 ))))
832 })
833 .or_else(|_| {
834 db.prepare(&ios_13_older_query(Some(&Self::generate_filter_statement(
835 context, false,
836 ))))
837 })
838 .map_err(TableError::Messages)
839 }
840
841 /// See [`Tapback`] for details on this data.
842 pub fn clean_associated_guid(&self) -> Option<(usize, &str)> {
843 if let Some(guid) = &self.associated_message_guid {
844 if guid.starts_with("p:") {
845 let mut split = guid.split('/');
846 let index_str = split.next()?;
847 let message_id = split.next()?;
848 let index = str::parse::<usize>(&index_str.replace("p:", "")).unwrap_or(0);
849 return Some((index, message_id.get(0..36)?));
850 } else if guid.starts_with("bp:") {
851 return Some((0, guid.get(3..39)?));
852 }
853
854 return Some((0, guid.get(0..36)?));
855 }
856 None
857 }
858
859 /// Parse the index of a tapback from it's associated GUID field
860 fn tapback_index(&self) -> usize {
861 match self.clean_associated_guid() {
862 Some((x, _)) => x,
863 None => 0,
864 }
865 }
866
867 /// Build a `HashMap` of message component index to messages that reply to that component
868 pub fn get_replies(&self, db: &Connection) -> Result<HashMap<usize, Vec<Self>>, TableError> {
869 let mut out_h: HashMap<usize, Vec<Self>> = HashMap::new();
870
871 // No need to hit the DB if we know we don't have replies
872 if self.has_replies() {
873 let filters = format!("WHERE m.thread_originator_guid = \"{}\"", self.guid);
874
875 // No iOS 13 and prior used here because `thread_originator_guid` is not present in that schema
876 let mut statement = db
877 .prepare(&ios_16_newer_query(Some(&filters)))
878 .or_else(|_| db.prepare(&ios_14_15_query(Some(&filters))))
879 .map_err(TableError::Messages)?;
880
881 let iter = statement
882 .query_map([], |row| Ok(Message::from_row(row)))
883 .map_err(TableError::Messages)?;
884
885 for message in iter {
886 let m = Message::extract(message)?;
887 let idx = m.get_reply_index();
888 match out_h.get_mut(&idx) {
889 Some(body_part) => body_part.push(m),
890 None => {
891 out_h.insert(idx, vec![m]);
892 }
893 }
894 }
895 }
896
897 Ok(out_h)
898 }
899
900 /// Get the variant of a message, see [`variants`](crate::message_types::variants) for detail.
901 pub fn variant(&self) -> Variant {
902 // Check if a message was edited first as those have special properties
903 if self.is_edited() {
904 return Variant::Edited;
905 }
906
907 // Handle different types of bundle IDs next, as those are most common
908 if let Some(associated_message_type) = self.associated_message_type {
909 return match associated_message_type {
910 // Standard iMessages with either text or a message payload
911 0 | 2 | 3 => match parse_balloon_bundle_id(self.balloon_bundle_id.as_deref()) {
912 Some(bundle_id) => match bundle_id {
913 "com.apple.messages.URLBalloonProvider" => Variant::App(CustomBalloon::URL),
914 "com.apple.Handwriting.HandwritingProvider" => {
915 Variant::App(CustomBalloon::Handwriting)
916 }
917 "com.apple.DigitalTouchBalloonProvider" => {
918 Variant::App(CustomBalloon::DigitalTouch)
919 }
920 "com.apple.PassbookUIService.PeerPaymentMessagesExtension" => {
921 Variant::App(CustomBalloon::ApplePay)
922 }
923 "com.apple.ActivityMessagesApp.MessagesExtension" => {
924 Variant::App(CustomBalloon::Fitness)
925 }
926 "com.apple.mobileslideshow.PhotosMessagesApp" => {
927 Variant::App(CustomBalloon::Slideshow)
928 }
929 "com.apple.SafetyMonitorApp.SafetyMonitorMessages" => {
930 Variant::App(CustomBalloon::CheckIn)
931 }
932 "com.apple.findmy.FindMyMessagesApp" => Variant::App(CustomBalloon::FindMy),
933 _ => Variant::App(CustomBalloon::Application(bundle_id)),
934 },
935 // This is the most common case
936 None => Variant::Normal,
937 },
938
939 // Stickers overlaid on messages
940 1000 => {
941 Variant::Tapback(self.tapback_index(), TapbackAction::Added, Tapback::Sticker)
942 }
943
944 // Tapbacks
945 2000 => {
946 Variant::Tapback(self.tapback_index(), TapbackAction::Added, Tapback::Loved)
947 }
948 2001 => {
949 Variant::Tapback(self.tapback_index(), TapbackAction::Added, Tapback::Liked)
950 }
951 2002 => Variant::Tapback(
952 self.tapback_index(),
953 TapbackAction::Added,
954 Tapback::Disliked,
955 ),
956 2003 => {
957 Variant::Tapback(self.tapback_index(), TapbackAction::Added, Tapback::Laughed)
958 }
959 2004 => Variant::Tapback(
960 self.tapback_index(),
961 TapbackAction::Added,
962 Tapback::Emphasized,
963 ),
964 2005 => Variant::Tapback(
965 self.tapback_index(),
966 TapbackAction::Added,
967 Tapback::Questioned,
968 ),
969 2006 => Variant::Tapback(
970 self.tapback_index(),
971 TapbackAction::Added,
972 Tapback::Emoji(self.associated_message_emoji.as_deref()),
973 ),
974 2007 => {
975 Variant::Tapback(self.tapback_index(), TapbackAction::Added, Tapback::Sticker)
976 }
977 3000 => {
978 Variant::Tapback(self.tapback_index(), TapbackAction::Removed, Tapback::Loved)
979 }
980 3001 => {
981 Variant::Tapback(self.tapback_index(), TapbackAction::Removed, Tapback::Liked)
982 }
983 3002 => Variant::Tapback(
984 self.tapback_index(),
985 TapbackAction::Removed,
986 Tapback::Disliked,
987 ),
988 3003 => Variant::Tapback(
989 self.tapback_index(),
990 TapbackAction::Removed,
991 Tapback::Laughed,
992 ),
993 3004 => Variant::Tapback(
994 self.tapback_index(),
995 TapbackAction::Removed,
996 Tapback::Emphasized,
997 ),
998 3005 => Variant::Tapback(
999 self.tapback_index(),
1000 TapbackAction::Removed,
1001 Tapback::Questioned,
1002 ),
1003 3006 => Variant::Tapback(
1004 self.tapback_index(),
1005 TapbackAction::Removed,
1006 Tapback::Emoji(self.associated_message_emoji.as_deref()),
1007 ),
1008 3007 => Variant::Tapback(
1009 self.tapback_index(),
1010 TapbackAction::Removed,
1011 Tapback::Sticker,
1012 ),
1013
1014 // Unknown
1015 x => Variant::Unknown(x),
1016 };
1017 }
1018
1019 // Any other rarer cases belong here
1020 if self.is_shareplay() {
1021 return Variant::SharePlay;
1022 }
1023
1024 Variant::Normal
1025 }
1026
1027 /// Determine the type of announcement a message contains, if it contains one
1028 pub fn get_announcement(&self) -> Option<Announcement> {
1029 if let Some(action) = self.group_action() {
1030 return Some(Announcement::GroupAction(action));
1031 }
1032
1033 if self.is_fully_unsent() {
1034 return Some(Announcement::FullyUnsent);
1035 }
1036
1037 None
1038 }
1039
1040 /// Determine the service the message was sent from, i.e. iMessage, SMS, IRC, etc.
1041 pub fn service(&self) -> Service {
1042 Service::from(self.service.as_deref())
1043 }
1044
1045 /// Get a message's plist from the [`MESSAGE_PAYLOAD`] BLOB column
1046 ///
1047 /// Calling this hits the database, so it is expensive and should
1048 /// only get invoked when needed.
1049 ///
1050 /// This column contains data used by iMessage app balloons and can be parsed with
1051 /// [`parse_ns_keyed_archiver()`](crate::util::plist::parse_ns_keyed_archiver).
1052 pub fn payload_data(&self, db: &Connection) -> Option<Value> {
1053 Value::from_reader(self.get_blob(db, MESSAGE_PAYLOAD)?).ok()
1054 }
1055
1056 /// Get a message's raw data from the [`MESSAGE_PAYLOAD`] BLOB column
1057 ///
1058 /// Calling this hits the database, so it is expensive and should
1059 /// only get invoked when needed.
1060 ///
1061 /// This column contains data used by [`HandwrittenMessage`](crate::message_types::handwriting::HandwrittenMessage)s.
1062 pub fn raw_payload_data(&self, db: &Connection) -> Option<Vec<u8>> {
1063 let mut buf = Vec::new();
1064 self.get_blob(db, MESSAGE_PAYLOAD)?
1065 .read_to_end(&mut buf)
1066 .ok()?;
1067 Some(buf)
1068 }
1069
1070 /// Get a message's plist from the [`MESSAGE_SUMMARY_INFO`] BLOB column
1071 ///
1072 /// Calling this hits the database, so it is expensive and should
1073 /// only get invoked when needed.
1074 ///
1075 /// This column contains data used by [`edited`](crate::message_types::edited) iMessages.
1076 pub fn message_summary_info(&self, db: &Connection) -> Option<Value> {
1077 Value::from_reader(self.get_blob(db, MESSAGE_SUMMARY_INFO)?).ok()
1078 }
1079
1080 /// Get a message's [typedstream](crate::util::typedstream) from the [`ATTRIBUTED_BODY`] BLOB column
1081 ///
1082 /// Calling this hits the database, so it is expensive and should
1083 /// only get invoked when needed.
1084 ///
1085 /// This column contains the message's body text with any other attributes.
1086 pub fn attributed_body(&self, db: &Connection) -> Option<Vec<u8>> {
1087 let mut body = vec![];
1088 self.get_blob(db, ATTRIBUTED_BODY)?
1089 .read_to_end(&mut body)
1090 .ok();
1091 Some(body)
1092 }
1093
1094 /// Determine which [`Expressive`] the message was sent with
1095 pub fn get_expressive(&self) -> Expressive {
1096 match &self.expressive_send_style_id {
1097 Some(content) => match content.as_str() {
1098 "com.apple.MobileSMS.expressivesend.gentle" => {
1099 Expressive::Bubble(BubbleEffect::Gentle)
1100 }
1101 "com.apple.MobileSMS.expressivesend.impact" => {
1102 Expressive::Bubble(BubbleEffect::Slam)
1103 }
1104 "com.apple.MobileSMS.expressivesend.invisibleink" => {
1105 Expressive::Bubble(BubbleEffect::InvisibleInk)
1106 }
1107 "com.apple.MobileSMS.expressivesend.loud" => Expressive::Bubble(BubbleEffect::Loud),
1108 "com.apple.messages.effect.CKConfettiEffect" => {
1109 Expressive::Screen(ScreenEffect::Confetti)
1110 }
1111 "com.apple.messages.effect.CKEchoEffect" => Expressive::Screen(ScreenEffect::Echo),
1112 "com.apple.messages.effect.CKFireworksEffect" => {
1113 Expressive::Screen(ScreenEffect::Fireworks)
1114 }
1115 "com.apple.messages.effect.CKHappyBirthdayEffect" => {
1116 Expressive::Screen(ScreenEffect::Balloons)
1117 }
1118 "com.apple.messages.effect.CKHeartEffect" => {
1119 Expressive::Screen(ScreenEffect::Heart)
1120 }
1121 "com.apple.messages.effect.CKLasersEffect" => {
1122 Expressive::Screen(ScreenEffect::Lasers)
1123 }
1124 "com.apple.messages.effect.CKShootingStarEffect" => {
1125 Expressive::Screen(ScreenEffect::ShootingStar)
1126 }
1127 "com.apple.messages.effect.CKSparklesEffect" => {
1128 Expressive::Screen(ScreenEffect::Sparkles)
1129 }
1130 "com.apple.messages.effect.CKSpotlightEffect" => {
1131 Expressive::Screen(ScreenEffect::Spotlight)
1132 }
1133 _ => Expressive::Unknown(content),
1134 },
1135 None => Expressive::None,
1136 }
1137 }
1138
1139 /// Create a message from a given GUID; useful for debugging
1140 ///
1141 /// # Example
1142 /// ```rust
1143 /// use imessage_database::{
1144 /// tables::{
1145 /// messages::Message,
1146 /// table::get_connection,
1147 /// },
1148 /// util::dirs::default_db_path,
1149 /// };
1150 ///
1151 /// let db_path = default_db_path();
1152 /// let conn = get_connection(&db_path).unwrap();
1153 ///
1154 /// if let Ok(mut message) = Message::from_guid("example-guid", &conn) {
1155 /// let _ = message.generate_text(&conn);
1156 /// println!("{:#?}", message)
1157 /// }
1158 ///```
1159 pub fn from_guid(guid: &str, db: &Connection) -> Result<Self, TableError> {
1160 // If the database has `chat_recoverable_message_join`, we can restore some deleted messages.
1161 // If database has `thread_originator_guid`, we can parse replies, otherwise default to 0
1162 let filters = format!("WHERE m.guid = \"{guid}\"");
1163
1164 let mut statement = db
1165 .prepare(&ios_16_newer_query(Some(&filters)))
1166 .or_else(|_| db.prepare(&ios_14_15_query(Some(&filters))))
1167 .or_else(|_| db.prepare(&ios_13_older_query(Some(&filters))))
1168 .map_err(TableError::Messages)?;
1169
1170 Message::extract(statement.query_row([], |row| Ok(Message::from_row(row))))
1171 }
1172}
1173
1174#[cfg(test)]
1175impl Message {
1176 pub fn blank() -> Message {
1177 Message {
1178 rowid: i32::default(),
1179 guid: String::default(),
1180 text: None,
1181 service: Some("iMessage".to_string()),
1182 handle_id: Some(i32::default()),
1183 destination_caller_id: None,
1184 subject: None,
1185 date: i64::default(),
1186 date_read: i64::default(),
1187 date_delivered: i64::default(),
1188 is_from_me: false,
1189 is_read: false,
1190 item_type: 0,
1191 other_handle: None,
1192 share_status: false,
1193 share_direction: None,
1194 group_title: None,
1195 group_action_type: 0,
1196 associated_message_guid: None,
1197 associated_message_type: None,
1198 balloon_bundle_id: None,
1199 expressive_send_style_id: None,
1200 thread_originator_guid: None,
1201 thread_originator_part: None,
1202 date_edited: 0,
1203 associated_message_emoji: None,
1204 chat_id: None,
1205 num_attachments: 0,
1206 deleted_from: None,
1207 num_replies: 0,
1208 components: None,
1209 edited_parts: None,
1210 }
1211 }
1212}