Skip to main content

imessage_database/tables/messages/
message.rs

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