Skip to main content

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