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