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