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 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 =
491 Some(streamtyped::parse(body).map_err(MessageError::StreamTypedParseError)?);
492 }
493 }
494
495 // Generate the edited message data
496 self.edited_parts = self
497 .is_edited()
498 .then(|| self.message_summary_info(db))
499 .flatten()
500 .as_ref()
501 .and_then(|payload| EditedMessage::from_map(payload).ok());
502
503 if let Some(t) = &self.text {
504 Ok(t)
505 } else {
506 Err(MessageError::NoText)
507 }
508 }
509
510 /// Calculates the date a message was written to the database.
511 ///
512 /// This field is stored as a unix timestamp with an epoch of `2001-01-01 00:00:00` in the local time zone
513 ///
514 /// `offset` can be provided by [`get_offset`](crate::util::dates::get_offset) or manually.
515 pub fn date(&self, offset: &i64) -> Result<DateTime<Local>, MessageError> {
516 get_local_time(&self.date, offset)
517 }
518
519 /// Calculates the date a message was marked as delivered.
520 ///
521 /// This field is stored as a unix timestamp with an epoch of `2001-01-01 00:00:00` in the local time zone
522 ///
523 /// `offset` can be provided by [`get_offset`](crate::util::dates::get_offset) or manually.
524 pub fn date_delivered(&self, offset: &i64) -> Result<DateTime<Local>, MessageError> {
525 get_local_time(&self.date_delivered, offset)
526 }
527
528 /// Calculates the date a message was marked as read.
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_read(&self, offset: &i64) -> Result<DateTime<Local>, MessageError> {
534 get_local_time(&self.date_read, offset)
535 }
536
537 /// Calculates the date a message was most recently edited.
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_edited(&self, offset: &i64) -> Result<DateTime<Local>, MessageError> {
543 get_local_time(&self.date_edited, offset)
544 }
545
546 /// Gets the time until the message was read. This can happen in two ways:
547 ///
548 /// - You received a message, then waited to read it
549 /// - You sent a message, and the recipient waited to read it
550 ///
551 /// In the former case, this subtracts the date read column (`date_read`) from the date received column (`date`).
552 /// In the latter case, this subtracts the date delivered column (`date_delivered`) from the date received column (`date`).
553 ///
554 /// Not all messages get tagged with the read properties.
555 /// If more than one message has been sent in a thread before getting read,
556 /// only the most recent message will get the tag.
557 ///
558 /// `offset` can be provided by [`get_offset`](crate::util::dates::get_offset) or manually.
559 #[must_use]
560 pub fn time_until_read(&self, offset: &i64) -> Option<String> {
561 // Message we received
562 if !self.is_from_me && self.date_read != 0 && self.date != 0 {
563 return readable_diff(self.date(offset), self.date_read(offset));
564 }
565 // Message we sent
566 else if self.is_from_me && self.date_delivered != 0 && self.date != 0 {
567 return readable_diff(self.date(offset), self.date_delivered(offset));
568 }
569 None
570 }
571
572 /// `true` if the message is a response to a thread, else `false`
573 #[must_use]
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 #[must_use]
580 pub fn is_announcement(&self) -> bool {
581 self.get_announcement().is_some()
582 }
583
584 /// `true` if the message is a [`Tapback`] to another message, else `false`
585 #[must_use]
586 pub fn is_tapback(&self) -> bool {
587 matches!(self.variant(), Variant::Tapback(..))
588 }
589
590 /// `true` if the message has an [`Expressive`], else `false`
591 #[must_use]
592 pub fn is_expressive(&self) -> bool {
593 self.expressive_send_style_id.is_some()
594 }
595
596 /// `true` if the message has a [URL preview](crate::message_types::url), else `false`
597 #[must_use]
598 pub fn is_url(&self) -> bool {
599 matches!(self.variant(), Variant::App(CustomBalloon::URL))
600 }
601
602 /// `true` if the message is a [`HandwrittenMessage`](crate::message_types::handwriting::models::HandwrittenMessage), else `false`
603 #[must_use]
604 pub fn is_handwriting(&self) -> bool {
605 matches!(self.variant(), Variant::App(CustomBalloon::Handwriting))
606 }
607
608 /// `true` if the message is a [`Digital Touch`](crate::message_types::digital_touch::models), else `false`
609 #[must_use]
610 pub fn is_digital_touch(&self) -> bool {
611 matches!(self.variant(), Variant::App(CustomBalloon::DigitalTouch))
612 }
613
614 /// `true` if the message was [`Edited`](crate::message_types::edited), else `false`
615 #[must_use]
616 pub fn is_edited(&self) -> bool {
617 self.date_edited != 0
618 }
619
620 /// `true` if the specified message component was [edited](crate::message_types::edited::EditStatus::Edited), else `false`
621 #[must_use]
622 pub fn is_part_edited(&self, index: usize) -> bool {
623 if let Some(edited_parts) = &self.edited_parts {
624 if let Some(part) = edited_parts.part(index) {
625 return matches!(part.status, EditStatus::Edited);
626 }
627 }
628 false
629 }
630
631 /// `true` if all message components were [unsent](crate::message_types::edited::EditStatus::Unsent), else `false`
632 #[must_use]
633 pub fn is_fully_unsent(&self) -> bool {
634 self.edited_parts.as_ref().is_some_and(|ep| {
635 ep.parts
636 .iter()
637 .all(|part| matches!(part.status, EditStatus::Unsent))
638 })
639 }
640
641 /// `true` if the message contains [`Attachment`](crate::tables::attachment::Attachment)s, else `false`
642 ///
643 /// Attachments can be queried with [`Attachment::from_message()`](crate::tables::attachment::Attachment::from_message).
644 #[must_use]
645 pub fn has_attachments(&self) -> bool {
646 self.num_attachments > 0
647 }
648
649 /// `true` if the message begins a thread, else `false`
650 #[must_use]
651 pub fn has_replies(&self) -> bool {
652 self.num_replies > 0
653 }
654
655 /// `true` if the message indicates a sent audio message was kept, else `false`
656 #[must_use]
657 pub fn is_kept_audio_message(&self) -> bool {
658 self.item_type == 5
659 }
660
661 /// `true` if the message is a [SharePlay/FaceTime](crate::message_types::variants::Variant::SharePlay) message, else `false`
662 #[must_use]
663 pub fn is_shareplay(&self) -> bool {
664 self.item_type == 6
665 }
666
667 /// `true` if the message was sent by the database owner, else `false`
668 #[must_use]
669 pub fn is_from_me(&self) -> bool {
670 if let (Some(other_handle), Some(share_direction)) =
671 (self.other_handle, self.share_direction)
672 {
673 self.is_from_me || other_handle != 0 && !share_direction
674 } else {
675 self.is_from_me
676 }
677 }
678
679 /// Get the group action for the current message
680 #[must_use]
681 pub fn group_action(&self) -> Option<GroupAction> {
682 GroupAction::from_message(self)
683 }
684
685 /// `true` if the message indicates a sender started sharing their location, else `false`
686 #[must_use]
687 pub fn started_sharing_location(&self) -> bool {
688 self.item_type == 4 && self.group_action_type == 0 && !self.share_status
689 }
690
691 /// `true` if the message indicates a sender stopped sharing their location, else `false`
692 #[must_use]
693 pub fn stopped_sharing_location(&self) -> bool {
694 self.item_type == 4 && self.group_action_type == 0 && self.share_status
695 }
696
697 /// `true` if the message was deleted and is recoverable, else `false`
698 ///
699 /// Messages removed by deleting an entire conversation or by deleting a single message
700 /// from a conversation are moved to a separate collection for up to 30 days. Messages
701 /// present in this collection are restored to the conversations they belong to. Apple
702 /// details this process [here](https://support.apple.com/en-us/HT202549#delete).
703 ///
704 /// Messages that have expired from this restoration process are permanently deleted and
705 /// cannot be recovered.
706 ///
707 /// Note: This is not the same as an [`Unsent`](crate::message_types::edited::EditStatus::Unsent) message.
708 #[must_use]
709 pub fn is_deleted(&self) -> bool {
710 self.deleted_from.is_some()
711 }
712
713 /// Get the index of the part of a message a reply is pointing to
714 fn get_reply_index(&self) -> usize {
715 if let Some(parts) = &self.thread_originator_part {
716 return match parts.split(':').next() {
717 Some(part) => str::parse::<usize>(part).unwrap_or(0),
718 None => 0,
719 };
720 }
721 0
722 }
723
724 /// Generate the SQL `WHERE` clause described by a [`QueryContext`].
725 ///
726 /// If `include_recoverable` is `true`, the filter includes messages from the recently deleted messages
727 /// table that match the chat IDs. This allows recovery of deleted messages that are still
728 /// present in the database but no longer visible in the Messages app.
729 pub(crate) fn generate_filter_statement(
730 context: &QueryContext,
731 include_recoverable: bool,
732 ) -> String {
733 let mut filters = String::new();
734
735 // Start date filter
736 if let Some(start) = context.start {
737 filters.push_str(&format!(" m.date >= {start}"));
738 }
739
740 // End date filter
741 if let Some(end) = context.end {
742 if !filters.is_empty() {
743 filters.push_str(" AND ");
744 }
745 filters.push_str(&format!(" m.date <= {end}"));
746 }
747
748 // Chat ID filter, optionally including recoverable messages
749 if let Some(chat_ids) = &context.selected_chat_ids {
750 if !filters.is_empty() {
751 filters.push_str(" AND ");
752 }
753
754 // Allocate the filter string for interpolation
755 let ids = chat_ids
756 .iter()
757 .map(std::string::ToString::to_string)
758 .collect::<Vec<String>>()
759 .join(", ");
760
761 if include_recoverable {
762 filters.push_str(&format!(" (c.chat_id IN ({ids}) OR d.chat_id IN ({ids}))"));
763 } else {
764 filters.push_str(&format!(" c.chat_id IN ({ids})"));
765 }
766 }
767
768 if !filters.is_empty() {
769 return format!("WHERE {filters}");
770 }
771 filters
772 }
773
774 /// Get the number of messages in the database
775 ///
776 /// # Example:
777 ///
778 /// ```
779 /// use imessage_database::util::dirs::default_db_path;
780 /// use imessage_database::tables::table::{Diagnostic, get_connection};
781 /// use imessage_database::tables::messages::Message;
782 /// use imessage_database::util::query_context::QueryContext;
783 ///
784 /// let db_path = default_db_path();
785 /// let conn = get_connection(&db_path).unwrap();
786 /// let context = QueryContext::default();
787 /// Message::get_count(&conn, &context);
788 /// ```
789 pub fn get_count(db: &Connection, context: &QueryContext) -> Result<u64, TableError> {
790 let mut statement = if context.has_filters() {
791 db.prepare(&format!(
792 "SELECT
793 COUNT(*)
794 FROM {MESSAGE} as m
795 LEFT JOIN {CHAT_MESSAGE_JOIN} as c ON m.ROWID = c.message_id
796 LEFT JOIN {RECENTLY_DELETED} as d ON m.ROWID = d.message_id
797 {}",
798 Self::generate_filter_statement(context, true)
799 ))
800 .or_else(|_| {
801 db.prepare(&format!(
802 "SELECT
803 COUNT(*)
804 FROM {MESSAGE} as m
805 LEFT JOIN {CHAT_MESSAGE_JOIN} as c ON m.ROWID = c.message_id
806 {}",
807 Self::generate_filter_statement(context, false)
808 ))
809 })
810 .map_err(TableError::Messages)?
811 } else {
812 db.prepare(&format!("SELECT COUNT(*) FROM {MESSAGE}"))
813 .map_err(TableError::Messages)?
814 };
815 // Execute query, defaulting to zero if it fails
816 let count: u64 = statement.query_row([], |r| r.get(0)).unwrap_or(0);
817
818 Ok(count)
819 }
820
821 /// Stream messages from the database with optional filters.
822 ///
823 /// # Example:
824 ///
825 /// ```
826 /// use imessage_database::util::dirs::default_db_path;
827 /// use imessage_database::tables::table::{Diagnostic, get_connection};
828 /// use imessage_database::tables::{messages::Message, table::Table};
829 /// use imessage_database::util::query_context::QueryContext;
830 ///
831 /// let db_path = default_db_path();
832 /// let conn = get_connection(&db_path).unwrap();
833 /// let context = QueryContext::default();
834 ///
835 /// let mut statement = Message::stream_rows(&conn, &context).unwrap();
836 ///
837 /// let messages = statement.query_map([], |row| Ok(Message::from_row(row))).unwrap();
838 ///
839 /// messages.map(|msg| println!("{:#?}", Message::extract(msg)));
840 /// ```
841 pub fn stream_rows<'a>(
842 db: &'a Connection,
843 context: &'a QueryContext,
844 ) -> Result<Statement<'a>, TableError> {
845 if !context.has_filters() {
846 return Self::get(db);
847 }
848 db.prepare(&ios_16_newer_query(Some(&Self::generate_filter_statement(
849 context, true,
850 ))))
851 .or_else(|_| {
852 db.prepare(&ios_14_15_query(Some(&Self::generate_filter_statement(
853 context, false,
854 ))))
855 })
856 .or_else(|_| {
857 db.prepare(&ios_13_older_query(Some(&Self::generate_filter_statement(
858 context, false,
859 ))))
860 })
861 .map_err(TableError::Messages)
862 }
863
864 /// See [`Tapback`] for details on this data.
865 #[must_use]
866 pub fn clean_associated_guid(&self) -> Option<(usize, &str)> {
867 if let Some(guid) = &self.associated_message_guid {
868 if guid.starts_with("p:") {
869 let mut split = guid.split('/');
870 let index_str = split.next()?;
871 let message_id = split.next()?;
872 let index = str::parse::<usize>(&index_str.replace("p:", "")).unwrap_or(0);
873 return Some((index, message_id.get(0..36)?));
874 } else if guid.starts_with("bp:") {
875 return Some((0, guid.get(3..39)?));
876 }
877
878 return Some((0, guid.get(0..36)?));
879 }
880 None
881 }
882
883 /// Parse the index of a tapback from it's associated GUID field
884 fn tapback_index(&self) -> usize {
885 match self.clean_associated_guid() {
886 Some((x, _)) => x,
887 None => 0,
888 }
889 }
890
891 /// Build a `HashMap` of message component index to messages that reply to that component
892 pub fn get_replies(&self, db: &Connection) -> Result<HashMap<usize, Vec<Self>>, TableError> {
893 let mut out_h: HashMap<usize, Vec<Self>> = HashMap::new();
894
895 // No need to hit the DB if we know we don't have replies
896 if self.has_replies() {
897 let filters = format!("WHERE m.thread_originator_guid = \"{}\"", self.guid);
898
899 // No iOS 13 and prior used here because `thread_originator_guid` is not present in that schema
900 let mut statement = db
901 .prepare(&ios_16_newer_query(Some(&filters)))
902 .or_else(|_| db.prepare(&ios_14_15_query(Some(&filters))))
903 .map_err(TableError::Messages)?;
904
905 let iter = statement
906 .query_map([], |row| Ok(Message::from_row(row)))
907 .map_err(TableError::Messages)?;
908
909 for message in iter {
910 let m = Message::extract(message)?;
911 let idx = m.get_reply_index();
912 match out_h.get_mut(&idx) {
913 Some(body_part) => body_part.push(m),
914 None => {
915 out_h.insert(idx, vec![m]);
916 }
917 }
918 }
919 }
920
921 Ok(out_h)
922 }
923
924 /// Get the variant of a message, see [`variants`](crate::message_types::variants) for detail.
925 #[must_use]
926 pub fn variant(&self) -> Variant {
927 // Check if a message was edited first as those have special properties
928 if self.is_edited() {
929 return Variant::Edited;
930 }
931
932 // Handle different types of bundle IDs next, as those are most common
933 if let Some(associated_message_type) = self.associated_message_type {
934 return match associated_message_type {
935 // Standard iMessages with either text or a message payload
936 0 | 2 | 3 => match parse_balloon_bundle_id(self.balloon_bundle_id.as_deref()) {
937 Some(bundle_id) => match bundle_id {
938 "com.apple.messages.URLBalloonProvider" => Variant::App(CustomBalloon::URL),
939 "com.apple.Handwriting.HandwritingProvider" => {
940 Variant::App(CustomBalloon::Handwriting)
941 }
942 "com.apple.DigitalTouchBalloonProvider" => {
943 Variant::App(CustomBalloon::DigitalTouch)
944 }
945 "com.apple.PassbookUIService.PeerPaymentMessagesExtension" => {
946 Variant::App(CustomBalloon::ApplePay)
947 }
948 "com.apple.ActivityMessagesApp.MessagesExtension" => {
949 Variant::App(CustomBalloon::Fitness)
950 }
951 "com.apple.mobileslideshow.PhotosMessagesApp" => {
952 Variant::App(CustomBalloon::Slideshow)
953 }
954 "com.apple.SafetyMonitorApp.SafetyMonitorMessages" => {
955 Variant::App(CustomBalloon::CheckIn)
956 }
957 "com.apple.findmy.FindMyMessagesApp" => Variant::App(CustomBalloon::FindMy),
958 _ => Variant::App(CustomBalloon::Application(bundle_id)),
959 },
960 // This is the most common case
961 None => Variant::Normal,
962 },
963
964 // Stickers overlaid on messages
965 1000 => {
966 Variant::Tapback(self.tapback_index(), TapbackAction::Added, Tapback::Sticker)
967 }
968
969 // Tapbacks
970 2000 => {
971 Variant::Tapback(self.tapback_index(), TapbackAction::Added, Tapback::Loved)
972 }
973 2001 => {
974 Variant::Tapback(self.tapback_index(), TapbackAction::Added, Tapback::Liked)
975 }
976 2002 => Variant::Tapback(
977 self.tapback_index(),
978 TapbackAction::Added,
979 Tapback::Disliked,
980 ),
981 2003 => {
982 Variant::Tapback(self.tapback_index(), TapbackAction::Added, Tapback::Laughed)
983 }
984 2004 => Variant::Tapback(
985 self.tapback_index(),
986 TapbackAction::Added,
987 Tapback::Emphasized,
988 ),
989 2005 => Variant::Tapback(
990 self.tapback_index(),
991 TapbackAction::Added,
992 Tapback::Questioned,
993 ),
994 2006 => Variant::Tapback(
995 self.tapback_index(),
996 TapbackAction::Added,
997 Tapback::Emoji(self.associated_message_emoji.as_deref()),
998 ),
999 2007 => {
1000 Variant::Tapback(self.tapback_index(), TapbackAction::Added, Tapback::Sticker)
1001 }
1002 3000 => {
1003 Variant::Tapback(self.tapback_index(), TapbackAction::Removed, Tapback::Loved)
1004 }
1005 3001 => {
1006 Variant::Tapback(self.tapback_index(), TapbackAction::Removed, Tapback::Liked)
1007 }
1008 3002 => Variant::Tapback(
1009 self.tapback_index(),
1010 TapbackAction::Removed,
1011 Tapback::Disliked,
1012 ),
1013 3003 => Variant::Tapback(
1014 self.tapback_index(),
1015 TapbackAction::Removed,
1016 Tapback::Laughed,
1017 ),
1018 3004 => Variant::Tapback(
1019 self.tapback_index(),
1020 TapbackAction::Removed,
1021 Tapback::Emphasized,
1022 ),
1023 3005 => Variant::Tapback(
1024 self.tapback_index(),
1025 TapbackAction::Removed,
1026 Tapback::Questioned,
1027 ),
1028 3006 => Variant::Tapback(
1029 self.tapback_index(),
1030 TapbackAction::Removed,
1031 Tapback::Emoji(self.associated_message_emoji.as_deref()),
1032 ),
1033 3007 => Variant::Tapback(
1034 self.tapback_index(),
1035 TapbackAction::Removed,
1036 Tapback::Sticker,
1037 ),
1038
1039 // Unknown
1040 x => Variant::Unknown(x),
1041 };
1042 }
1043
1044 // Any other rarer cases belong here
1045 if self.is_shareplay() {
1046 return Variant::SharePlay;
1047 }
1048
1049 Variant::Normal
1050 }
1051
1052 /// Determine the type of announcement a message contains, if it contains one
1053 #[must_use]
1054 pub fn get_announcement(&self) -> Option<Announcement> {
1055 if let Some(action) = self.group_action() {
1056 return Some(Announcement::GroupAction(action));
1057 }
1058
1059 if self.is_fully_unsent() {
1060 return Some(Announcement::FullyUnsent);
1061 }
1062
1063 if self.is_kept_audio_message() {
1064 return Some(Announcement::AudioMessageKept);
1065 }
1066
1067 None
1068 }
1069
1070 /// Determine the service the message was sent from, i.e. iMessage, SMS, IRC, etc.
1071 #[must_use]
1072 pub fn service(&self) -> Service {
1073 Service::from(self.service.as_deref())
1074 }
1075
1076 /// Get a message's plist from the [`MESSAGE_PAYLOAD`] BLOB column
1077 ///
1078 /// Calling this hits the database, so it is expensive and should
1079 /// only get invoked when needed.
1080 ///
1081 /// This column contains data used by iMessage app balloons and can be parsed with
1082 /// [`parse_ns_keyed_archiver()`](crate::util::plist::parse_ns_keyed_archiver).
1083 pub fn payload_data(&self, db: &Connection) -> Option<Value> {
1084 Value::from_reader(self.get_blob(db, MESSAGE_PAYLOAD)?).ok()
1085 }
1086
1087 /// Get a message's raw data from the [`MESSAGE_PAYLOAD`] BLOB column
1088 ///
1089 /// Calling this hits the database, so it is expensive and should
1090 /// only get invoked when needed.
1091 ///
1092 /// This column contains data used by [`HandwrittenMessage`](crate::message_types::handwriting::HandwrittenMessage)s.
1093 pub fn raw_payload_data(&self, db: &Connection) -> Option<Vec<u8>> {
1094 let mut buf = Vec::new();
1095 self.get_blob(db, MESSAGE_PAYLOAD)?
1096 .read_to_end(&mut buf)
1097 .ok()?;
1098 Some(buf)
1099 }
1100
1101 /// Get a message's plist from the [`MESSAGE_SUMMARY_INFO`] BLOB column
1102 ///
1103 /// Calling this hits the database, so it is expensive and should
1104 /// only get invoked when needed.
1105 ///
1106 /// This column contains data used by [`edited`](crate::message_types::edited) iMessages.
1107 pub fn message_summary_info(&self, db: &Connection) -> Option<Value> {
1108 Value::from_reader(self.get_blob(db, MESSAGE_SUMMARY_INFO)?).ok()
1109 }
1110
1111 /// Get a message's [typedstream](crate::util::typedstream) from the [`ATTRIBUTED_BODY`] BLOB column
1112 ///
1113 /// Calling this hits the database, so it is expensive and should
1114 /// only get invoked when needed.
1115 ///
1116 /// This column contains the message's body text with any other attributes.
1117 pub fn attributed_body(&self, db: &Connection) -> Option<Vec<u8>> {
1118 let mut body = vec![];
1119 self.get_blob(db, ATTRIBUTED_BODY)?
1120 .read_to_end(&mut body)
1121 .ok();
1122 Some(body)
1123 }
1124
1125 /// Determine which [`Expressive`] the message was sent with
1126 #[must_use]
1127 pub fn get_expressive(&self) -> Expressive {
1128 match &self.expressive_send_style_id {
1129 Some(content) => match content.as_str() {
1130 "com.apple.MobileSMS.expressivesend.gentle" => {
1131 Expressive::Bubble(BubbleEffect::Gentle)
1132 }
1133 "com.apple.MobileSMS.expressivesend.impact" => {
1134 Expressive::Bubble(BubbleEffect::Slam)
1135 }
1136 "com.apple.MobileSMS.expressivesend.invisibleink" => {
1137 Expressive::Bubble(BubbleEffect::InvisibleInk)
1138 }
1139 "com.apple.MobileSMS.expressivesend.loud" => Expressive::Bubble(BubbleEffect::Loud),
1140 "com.apple.messages.effect.CKConfettiEffect" => {
1141 Expressive::Screen(ScreenEffect::Confetti)
1142 }
1143 "com.apple.messages.effect.CKEchoEffect" => Expressive::Screen(ScreenEffect::Echo),
1144 "com.apple.messages.effect.CKFireworksEffect" => {
1145 Expressive::Screen(ScreenEffect::Fireworks)
1146 }
1147 "com.apple.messages.effect.CKHappyBirthdayEffect" => {
1148 Expressive::Screen(ScreenEffect::Balloons)
1149 }
1150 "com.apple.messages.effect.CKHeartEffect" => {
1151 Expressive::Screen(ScreenEffect::Heart)
1152 }
1153 "com.apple.messages.effect.CKLasersEffect" => {
1154 Expressive::Screen(ScreenEffect::Lasers)
1155 }
1156 "com.apple.messages.effect.CKShootingStarEffect" => {
1157 Expressive::Screen(ScreenEffect::ShootingStar)
1158 }
1159 "com.apple.messages.effect.CKSparklesEffect" => {
1160 Expressive::Screen(ScreenEffect::Sparkles)
1161 }
1162 "com.apple.messages.effect.CKSpotlightEffect" => {
1163 Expressive::Screen(ScreenEffect::Spotlight)
1164 }
1165 _ => Expressive::Unknown(content),
1166 },
1167 None => Expressive::None,
1168 }
1169 }
1170
1171 /// Create a message from a given GUID; useful for debugging
1172 ///
1173 /// # Example
1174 /// ```rust
1175 /// use imessage_database::{
1176 /// tables::{
1177 /// messages::Message,
1178 /// table::get_connection,
1179 /// },
1180 /// util::dirs::default_db_path,
1181 /// };
1182 ///
1183 /// let db_path = default_db_path();
1184 /// let conn = get_connection(&db_path).unwrap();
1185 ///
1186 /// if let Ok(mut message) = Message::from_guid("example-guid", &conn) {
1187 /// let _ = message.generate_text(&conn);
1188 /// println!("{:#?}", message)
1189 /// }
1190 ///```
1191 pub fn from_guid(guid: &str, db: &Connection) -> Result<Self, TableError> {
1192 // If the database has `chat_recoverable_message_join`, we can restore some deleted messages.
1193 // If database has `thread_originator_guid`, we can parse replies, otherwise default to 0
1194 let filters = format!("WHERE m.guid = \"{guid}\"");
1195
1196 let mut statement = db
1197 .prepare(&ios_16_newer_query(Some(&filters)))
1198 .or_else(|_| db.prepare(&ios_14_15_query(Some(&filters))))
1199 .or_else(|_| db.prepare(&ios_13_older_query(Some(&filters))))
1200 .map_err(TableError::Messages)?;
1201
1202 Message::extract(statement.query_row([], |row| Ok(Message::from_row(row))))
1203 }
1204}
1205
1206#[cfg(test)]
1207impl Message {
1208 #[must_use]
1209 pub fn blank() -> Message {
1210 Message {
1211 rowid: i32::default(),
1212 guid: String::default(),
1213 text: None,
1214 service: Some("iMessage".to_string()),
1215 handle_id: Some(i32::default()),
1216 destination_caller_id: None,
1217 subject: None,
1218 date: i64::default(),
1219 date_read: i64::default(),
1220 date_delivered: i64::default(),
1221 is_from_me: false,
1222 is_read: false,
1223 item_type: 0,
1224 other_handle: None,
1225 share_status: false,
1226 share_direction: None,
1227 group_title: None,
1228 group_action_type: 0,
1229 associated_message_guid: None,
1230 associated_message_type: None,
1231 balloon_bundle_id: None,
1232 expressive_send_style_id: None,
1233 thread_originator_guid: None,
1234 thread_originator_part: None,
1235 date_edited: 0,
1236 associated_message_emoji: None,
1237 chat_id: None,
1238 num_attachments: 0,
1239 deleted_from: None,
1240 num_replies: 0,
1241 components: None,
1242 edited_parts: None,
1243 }
1244 }
1245}