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