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