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