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}