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}