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