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