@chronicle.app/imessage 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,13 @@
1
1
  # @chronicle.app/imessage
2
2
 
3
- Read `chat.db` with `ImessageExtractor`, then emit MessageAction/Message records with `ImessageTransformer`. `input` defaults to the macOS Messages database; use a consistent backup for exported data. The connection is read-only via built-in `node:sqlite`. Date windows are exclusive, records newest-first, and `limit: 0` means unlimited.
3
+ Read `chat.db` with `ImessageExtractor`, then emit MessageAction/Message records with `ImessageTransformer`. Sending a message brings it into being, so the `Message` is the action's `result`, and the action is keyed by it. `input` defaults to the macOS Messages database; use a consistent backup for exported data. Reading it needs Full Disk Access for the app that runs Chronicle (Terminal, iTerm, your editor): System Settings → Privacy & Security → Full Disk Access. Without it the run stops with that hint, not SQLite's `unable to open database file`. The connection is read-only via built-in `node:sqlite`. Date windows are exclusive, records newest-first, and `limit: 0` means unlimited.
4
4
 
5
- Automatic iCloud account and AddressBook enrichment comes from `@chronicle.app/icloud`. `includeContactNames: false` skips contacts. An explicit `account` object (or null) lets callers describe exported data without host account lookup. Transformer config can inject `lookupContact` for exported contacts. Missing permissions/account/contact data fall back to source-local identity and unlabelled participants. When no identifier for the account owner is available, the owner is left out of the agent and recipients.
5
+ A message in a group chat is part of the group's `Channel` (`isPartOf`), keyed in the `imessage` namespace by Apple's ID for the group: `original_group_id`, the ID it started with, `sameAs` `group_id` when Messages replaced it. `groupChatChannel` in `@chronicle.app/apple` builds it, and the Apple Photos plugin's Shared with You keys the same group with it. Each device keeps its own `chat_identifier` for a chat, so that isn't the key. The Channel's `name` is the group's name when it has one, and its `member` edges are the chat's participants and you, from the first message a run reads in the chat. chat.db's roster lists only the others, but every group chat in it is one you're in, or were. You're left out when there's no identifier for you, as you are from a message's sender and recipients. A group message's recipients are still each other participant and you. A one-to-one chat has no `Channel`.
6
6
 
7
- Attachments are local file references with MIME metadata, not copied bytes. Missing files are skipped. Attributed-body decoding retains the existing heuristic; it is not a complete typedstream decoder. Raw records preserve the source blob, and large integer timestamps are decimal strings so serialization is safe.
7
+ Automatic iCloud account and AddressBook enrichment comes from `@chronicle.app/apple`. `includeContactNames: false` skips contacts. An explicit `account` object (or null) lets callers describe exported data without host account lookup. Transformer config can inject `lookupContact` for exported contacts. Missing permissions/account/contact data fall back to source-local identity and unlabelled participants. When no identifier for the account owner is available, the owner is left out of the agent and recipients.
8
+
9
+ Messages keeps system events, such as a group being renamed or someone joining it, as rows beside the messages, with no text and often no sender. They aren't messages, so they're left out, and a survey doesn't count them. Attachments are local file references with MIME metadata, not copied bytes. Missing files are skipped. So are files Messages keeps hidden, such as a link preview's images and icon, which no one sent: the link is in the message's text. Attributed-body decoding retains the existing heuristic; it is not a complete typedstream decoder. Raw records preserve the source blob, and large integer timestamps are decimal strings so serialization is safe.
10
+
11
+ The plugin declares a `deepLinks` template for `Message` (`messages://open?message-guid=<GUID>`), so a host can open the message in Messages. SMS messages are linked the same way, since they are in `chat.db` too. Attachments are not linked, because an attachment's GUID is not a message GUID.
8
12
 
9
13
  Tests use synthetic Messages and AddressBook databases, account command fixtures, and temporary attachment files. They do not read personal messages or contacts.
@@ -1,4 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
1
+ import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
3
3
  import ImessageTransformer from './ImessageTransformer.js';
4
4
  export declare class ImessageExtractor extends SqliteExtractor<typeof ImessageExtractor> {
@@ -8,11 +8,43 @@ export declare class ImessageExtractor extends SqliteExtractor<typeof ImessageEx
8
8
  static delivery: "local";
9
9
  static strategy: string;
10
10
  static default: boolean;
11
+ static databases: {
12
+ tables: {
13
+ message: string[];
14
+ handle: string[];
15
+ chat: string[];
16
+ chat_message_join: string[];
17
+ chat_handle_join: string[];
18
+ message_attachment_join: string[];
19
+ attachment: string[];
20
+ };
21
+ }[];
11
22
  static schema: any;
12
23
  static defaultTransformer: typeof ImessageTransformer;
13
24
  extract(): AsyncGenerator<Record>;
25
+ /** The messages a run reads, one per chat they're in, and when, from three aggregates. */
26
+ survey(options?: SurveyOptions): Promise<Survey>;
27
+ /**
28
+ * Messages within since and until. chat.db stores message.date as nanoseconds
29
+ * since the Apple epoch. A system event, such as a group being renamed or
30
+ * someone joining it, is a row too, with no text and often no sender, so
31
+ * only rows that are messages (`item_type` 0) are read.
32
+ */
33
+ private messageFilter;
14
34
  private queryMessages;
15
35
  private loadChats;
36
+ /**
37
+ * The group chats, by chat, with their names and Apple's IDs for them. An
38
+ * older Messages without a column has none of what it holds.
39
+ */
40
+ private loadGroups;
41
+ /** A column of `chat` the schema marks optional, or NULL when this Messages has none. */
42
+ private chatColumn;
43
+ /**
44
+ * The attachments a run reads: files Messages shows. It keeps others hidden,
45
+ * such as the images and icon of a link preview, which no one sent.
46
+ */
47
+ private attachmentFilter;
16
48
  private loadAttachments;
17
49
  private processAttributedBody;
18
50
  }
@@ -1,5 +1,5 @@
1
- import { SqliteExtractor, iosToUnixTimestamp, timeRangeConditions, APPLE_EPOCH_OFFSET_SECONDS, } from '@chronicle.app/etl-sqlite';
2
- import { getICloudAccount } from '@chronicle.app/icloud';
1
+ import { SqliteExtractor, countsByMonth, iosToUnixTimestamp, monthSql, timeRangeConditions, APPLE_EPOCH_OFFSET_SECONDS, } from '@chronicle.app/etl-sqlite';
2
+ import { GROUP_CHAT_STYLE, getICloudAccount } from '@chronicle.app/apple';
3
3
  import ImessageTransformer from './ImessageTransformer.js';
4
4
  import { z } from 'zod';
5
5
  export class ImessageExtractor extends SqliteExtractor {
@@ -9,6 +9,40 @@ export class ImessageExtractor extends SqliteExtractor {
9
9
  static delivery = 'local';
10
10
  static strategy = 'app-db';
11
11
  static default = true;
12
+ // What extract() and survey() read, which a chat.db must have.
13
+ static databases = [
14
+ {
15
+ tables: {
16
+ message: [
17
+ 'ROWID',
18
+ 'guid',
19
+ 'text',
20
+ 'service',
21
+ 'date',
22
+ 'is_from_me',
23
+ 'handle_id',
24
+ 'attributedBody',
25
+ 'item_type?',
26
+ ],
27
+ handle: ['ROWID', 'id', 'service'],
28
+ chat: ['ROWID', 'style', 'display_name?', 'group_id?', 'original_group_id?'],
29
+ chat_message_join: ['chat_id', 'message_id'],
30
+ chat_handle_join: ['chat_id', 'handle_id'],
31
+ message_attachment_join: ['message_id', 'attachment_id'],
32
+ attachment: [
33
+ 'ROWID',
34
+ 'guid',
35
+ 'filename',
36
+ 'mime_type',
37
+ 'uti',
38
+ 'total_bytes',
39
+ 'transfer_name',
40
+ 'is_outgoing',
41
+ 'hide_attachment?',
42
+ ],
43
+ },
44
+ },
45
+ ];
12
46
  static schema = SqliteExtractor.schema.omit({ input: true }).extend({
13
47
  input: z
14
48
  .string()
@@ -32,11 +66,20 @@ export class ImessageExtractor extends SqliteExtractor {
32
66
  static defaultTransformer = ImessageTransformer;
33
67
  async *extract() {
34
68
  const chats = this.loadChats();
69
+ const groups = this.loadGroups();
35
70
  const rows = this.queryMessages();
36
71
  const attachments = this.loadAttachments();
37
72
  // Get real iCloud account info automatically
38
73
  const realIcloudAccount = this.config.account === undefined ? await getICloudAccount() : this.config.account;
74
+ // The group chats whose roster a message has carried, so each goes on its
75
+ // Channel once, on the first message seen in it, not on every message.
76
+ const rostered = new Set();
39
77
  for (const row of rows) {
78
+ const chatId = row.chat_id;
79
+ const group = groups[chatId];
80
+ const groupRoster = group !== undefined && !rostered.has(chatId);
81
+ if (groupRoster)
82
+ rostered.add(chatId);
40
83
  const attributedBody = this.processAttributedBody(row.attributedBody);
41
84
  const timestamp = iosToUnixTimestamp(Number.parseInt(row.date, 10));
42
85
  const participants = chats[row.chat_id] || [];
@@ -52,11 +95,62 @@ export class ImessageExtractor extends SqliteExtractor {
52
95
  handleIdText: row.handle_id_text,
53
96
  participants,
54
97
  attachments: messageAttachments,
98
+ group,
99
+ groupRoster,
55
100
  myIcloudAccount: realIcloudAccount,
56
101
  includeContactNames: this.config.includeContactNames,
57
102
  });
58
103
  }
59
104
  }
105
+ /** The messages a run reads, one per chat they're in, and when, from three aggregates. */
106
+ async survey(options = {}) {
107
+ const { conditions, values } = this.messageFilter();
108
+ const inRange = conditions.map(condition => ` AND ${condition}`).join('');
109
+ // A message's chats, as the run joins them, and only those in range.
110
+ const read = `SELECT 1 FROM message AS m INNER JOIN chat_message_join AS cm ON m.ROWID = cm.message_id`;
111
+ const messages = this.db.prepare(`SELECT COUNT(*) AS n, MIN(m.date) AS first, MAX(m.date) AS last
112
+ FROM message AS m INNER JOIN chat_message_join AS cm ON m.ROWID = cm.message_id
113
+ WHERE 1${inRange}`);
114
+ messages.setReadBigInts(true);
115
+ const { n, first, last } = messages.get(...values);
116
+ const { chats } = this.db.prepare(`SELECT COUNT(*) AS chats FROM chat WHERE EXISTS (${read} WHERE cm.chat_id = chat.ROWID${inRange})`).get(...values);
117
+ const { bytes } = this.db.prepare(`SELECT COALESCE(SUM(a.total_bytes), 0) AS bytes
118
+ FROM message_attachment_join AS maj INNER JOIN attachment AS a ON maj.attachment_id = a.ROWID
119
+ WHERE ${this.attachmentFilter()} AND EXISTS (${read} WHERE m.ROWID = maj.message_id${inRange})`).get(...values);
120
+ const at = (date) => date === null ? undefined : new Date(iosToUnixTimestamp(Number(date)));
121
+ const count = Number(n);
122
+ const month = monthSql(`m.date / 1000000000 + ${APPLE_EPOCH_OFFSET_SECONDS}`);
123
+ const byMonth = options.byMonth &&
124
+ countsByMonth(this.db.prepare(`SELECT ${month} AS month, COUNT(*) AS n
125
+ FROM message AS m INNER JOIN chat_message_join AS cm ON m.ROWID = cm.message_id
126
+ WHERE 1${inRange} GROUP BY month`).all(...values));
127
+ return {
128
+ messages: {
129
+ count,
130
+ ...(count > 0 && { earliest: at(first), latest: at(last) }),
131
+ ...(byMonth && { byMonth }),
132
+ measures: [
133
+ { key: 'conversations', label: 'conversations', value: chats },
134
+ { key: 'attachmentBytes', label: 'attachments', value: bytes, unit: 'bytes' },
135
+ ],
136
+ },
137
+ };
138
+ }
139
+ /**
140
+ * Messages within since and until. chat.db stores message.date as nanoseconds
141
+ * since the Apple epoch. A system event, such as a group being renamed or
142
+ * someone joining it, is a row too, with no text and often no sender, so
143
+ * only rows that are messages (`item_type` 0) are read.
144
+ */
145
+ messageFilter() {
146
+ const filter = timeRangeConditions('m.date', this.config, {
147
+ convert: date => (BigInt(date.getTime()) * 1000000n -
148
+ BigInt(APPLE_EPOCH_OFFSET_SECONDS) * 1000000000n).toString(),
149
+ });
150
+ if (this.has('message.item_type'))
151
+ filter.conditions.push('m.item_type = 0');
152
+ return filter;
153
+ }
60
154
  queryMessages() {
61
155
  let sql = `
62
156
  SELECT
@@ -67,11 +161,7 @@ export class ImessageExtractor extends SqliteExtractor {
67
161
  message AS m
68
162
  LEFT OUTER JOIN handle AS h ON m.handle_id = h.ROWID
69
163
  INNER JOIN chat_message_join AS cm ON m.ROWID = cm.message_id`;
70
- // chat.db stores message.date as nanoseconds since the Apple epoch
71
- const { conditions, values } = timeRangeConditions('m.date', this.config, {
72
- convert: date => (BigInt(date.getTime()) * 1000000n -
73
- BigInt(APPLE_EPOCH_OFFSET_SECONDS) * 1000000000n).toString(),
74
- });
164
+ const { conditions, values } = this.messageFilter();
75
165
  if (conditions.length > 0) {
76
166
  sql += ` WHERE ${conditions.join(' AND ')}`;
77
167
  }
@@ -118,6 +208,39 @@ export class ImessageExtractor extends SqliteExtractor {
118
208
  }
119
209
  return chats;
120
210
  }
211
+ /**
212
+ * The group chats, by chat, with their names and Apple's IDs for them. An
213
+ * older Messages without a column has none of what it holds.
214
+ */
215
+ loadGroups() {
216
+ const rows = this.db.prepare(`SELECT ROWID AS chat_id, ${this.chatColumn('display_name')} AS name,
217
+ ${this.chatColumn('group_id')} AS group_id,
218
+ ${this.chatColumn('original_group_id')} AS original_group_id
219
+ FROM chat WHERE style = ?`).all(GROUP_CHAT_STYLE);
220
+ const groups = {};
221
+ for (const row of rows) {
222
+ groups[row.chat_id] = {
223
+ name: row.name?.trim() || null,
224
+ groupId: row.group_id,
225
+ originalGroupId: row.original_group_id,
226
+ };
227
+ }
228
+ return groups;
229
+ }
230
+ /** A column of `chat` the schema marks optional, or NULL when this Messages has none. */
231
+ chatColumn(name) {
232
+ return this.has(`chat.${name}`) ? name : 'NULL';
233
+ }
234
+ /**
235
+ * The attachments a run reads: files Messages shows. It keeps others hidden,
236
+ * such as the images and icon of a link preview, which no one sent.
237
+ */
238
+ attachmentFilter() {
239
+ const shown = this.has('attachment.hide_attachment')
240
+ ? ' AND COALESCE(a.hide_attachment, 0) = 0'
241
+ : '';
242
+ return `a.filename IS NOT NULL${shown}`;
243
+ }
121
244
  loadAttachments() {
122
245
  const sql = `
123
246
  SELECT
@@ -132,7 +255,7 @@ export class ImessageExtractor extends SqliteExtractor {
132
255
  a.is_outgoing
133
256
  FROM message_attachment_join AS maj
134
257
  INNER JOIN attachment AS a ON maj.attachment_id = a.ROWID
135
- WHERE a.filename IS NOT NULL
258
+ WHERE ${this.attachmentFilter()}
136
259
  `;
137
260
  const stmt = this.db.prepare(sql);
138
261
  const results = stmt.all();
@@ -5,6 +5,14 @@ export default class ImessageTransformer extends ChronicleTransformer {
5
5
  transform(record: Record): Promise<ActionAndChildren[]>;
6
6
  private buildMessageAction;
7
7
  private buildMessage;
8
+ /**
9
+ * The group chat a message is in, keyed by Apple's ID for the group, as
10
+ * `groupChatChannel` keys it for every plugin. Its members are the chat's
11
+ * roster and you: chat.db's roster lists only the others, but every group
12
+ * chat in it is one you're in. They go on it from the first message the
13
+ * extractor saw in the chat, as WhatsApp does, rather than from every message.
14
+ */
15
+ private buildChannel;
8
16
  private buildParticipants;
9
17
  /**
10
18
  * One chat participant as an Agent in the service's identity namespace
@@ -1,5 +1,5 @@
1
1
  import { ChronicleTransformer, normalizePhoneNumber, createMediaObject, } from '@chronicle.app/etl';
2
- import { lookupContact, buildICloudPersonSchema } from '@chronicle.app/icloud';
2
+ import { lookupContact, buildICloudPersonSchema, groupChatChannel, } from '@chronicle.app/apple';
3
3
  import * as fs from 'node:fs';
4
4
  import * as path from 'node:path';
5
5
  export default class ImessageTransformer extends ChronicleTransformer {
@@ -20,14 +20,15 @@ export default class ImessageTransformer extends ChronicleTransformer {
20
20
  '@type': 'MessageAction',
21
21
  timestamp: new Date(record.context.timestamp),
22
22
  source: service,
23
- sourceId: record.context.guid, // Use GUID instead of ROWID
24
- '@key': ['source', 'sourceId', '@type'],
25
- object: this.buildMessage(record, recipients),
23
+ // Keyed by the message it sent.
24
+ '@key': ['@type', 'source', 'result.sourceId'],
25
+ // Sending brings the message into being, so it's the action's result.
26
+ result: this.buildMessage(record, recipients, this.buildChannel(record, participants, myIdentity)),
26
27
  ...(agent && { agent }),
27
28
  };
28
29
  return [obj];
29
30
  }
30
- buildMessage(record, recipients) {
31
+ buildMessage(record, recipients, channel) {
31
32
  const messageSource = this.getServiceSource(record.context.service);
32
33
  const message = {
33
34
  '@type': 'Message',
@@ -40,6 +41,10 @@ export default class ImessageTransformer extends ChronicleTransformer {
40
41
  if (recipients.length > 0) {
41
42
  message.recipient = recipients;
42
43
  }
44
+ // A group chat's message is part of its Channel. A one-to-one chat has none.
45
+ if (channel) {
46
+ message.isPartOf = [channel];
47
+ }
43
48
  // Add attachments using contains property (following Bluesky pattern)
44
49
  const attachments = record.context.attachments || [];
45
50
  if (attachments.length > 0) {
@@ -47,6 +52,25 @@ export default class ImessageTransformer extends ChronicleTransformer {
47
52
  }
48
53
  return message;
49
54
  }
55
+ /**
56
+ * The group chat a message is in, keyed by Apple's ID for the group, as
57
+ * `groupChatChannel` keys it for every plugin. Its members are the chat's
58
+ * roster and you: chat.db's roster lists only the others, but every group
59
+ * chat in it is one you're in. They go on it from the first message the
60
+ * extractor saw in the chat, as WhatsApp does, rather than from every message.
61
+ */
62
+ buildChannel(record, participants, myIdentity) {
63
+ const { group, groupRoster } = record.context;
64
+ const channel = group ? groupChatChannel(group) : null;
65
+ if (!channel)
66
+ return null;
67
+ if (group.name)
68
+ channel.name = group.name;
69
+ const members = [...participants, ...(myIdentity ? [myIdentity] : [])];
70
+ if (groupRoster && members.length > 0)
71
+ channel.member = members;
72
+ return channel;
73
+ }
50
74
  buildParticipants(record) {
51
75
  const agentSource = this.getAgentSource(record.context.service);
52
76
  const chatParticipants = record.context.participants || [];
@@ -163,6 +187,9 @@ export default class ImessageTransformer extends ChronicleTransformer {
163
187
  const { handleIdText } = record.context;
164
188
  const agent = participants.find(p => p.handle === handleIdText);
165
189
  if (!agent) {
190
+ // A sender whose handle is gone from chat.db is unknown.
191
+ if (!handleIdText)
192
+ return { agent: undefined, recipients: myIdentity ? [myIdentity] : [] };
166
193
  // The sender is not among the chat's participants: build them from the
167
194
  // message's own handle, the same way as any other participant.
168
195
  const agentSource = this.getAgentSource(record.context.service);
@@ -204,7 +231,7 @@ export default class ImessageTransformer extends ChronicleTransformer {
204
231
  const filePath = this.resolveAttachmentPath(attachment.filename);
205
232
  // Check if file exists
206
233
  if (!fs.existsSync(filePath)) {
207
- console.warn(`Attachment file not found: ${filePath}`);
234
+ this.logger.warn('Attachment file not found', { file: filePath });
208
235
  continue;
209
236
  }
210
237
  // MIME drives the media object @type (ImageObject vs DocumentObject…).
@@ -228,7 +255,10 @@ export default class ImessageTransformer extends ChronicleTransformer {
228
255
  mediaObjects.push(mediaObject);
229
256
  }
230
257
  catch (error) {
231
- console.warn(`Failed to process attachment ${attachment.filename}:`, error);
258
+ this.logger.warn('Failed to process attachment', {
259
+ file: attachment.filename,
260
+ error: String(error),
261
+ });
232
262
  }
233
263
  }
234
264
  return mediaObjects;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chronicle.app/imessage",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [
@@ -19,9 +19,9 @@
19
19
  "type": "module",
20
20
  "description": "Chronicle imessage extraction plugin",
21
21
  "devDependencies": {
22
- "@chronicle.app/etl": "0.3.0",
23
- "@chronicle.app/schema": "0.3.0",
24
- "@chronicle.app/tsconfig": "0.3.0",
22
+ "@chronicle.app/etl": "0.5.0",
23
+ "@chronicle.app/schema": "0.5.0",
24
+ "@chronicle.app/tsconfig": "0.5.0",
25
25
  "@types/node": "^22.10.1",
26
26
  "typescript": "^5.7.2"
27
27
  },
@@ -36,8 +36,8 @@
36
36
  "registry": "https://registry.npmjs.org/"
37
37
  },
38
38
  "dependencies": {
39
- "@chronicle.app/etl-sqlite": "0.3.0",
40
- "@chronicle.app/icloud": "0.3.0",
39
+ "@chronicle.app/etl-sqlite": "0.5.0",
40
+ "@chronicle.app/apple": "0.5.0",
41
41
  "zod": "^3.24.1"
42
42
  },
43
43
  "peerDependencies": {
@@ -63,7 +63,64 @@
63
63
  "recordTypes": [
64
64
  "messages"
65
65
  ],
66
- "default": true
66
+ "default": true,
67
+ "locations": [
68
+ {
69
+ "path": "~/Library/Messages/chat.db"
70
+ }
71
+ ],
72
+ "databases": [
73
+ {
74
+ "tables": {
75
+ "message": [
76
+ "ROWID",
77
+ "guid",
78
+ "text",
79
+ "service",
80
+ "date",
81
+ "is_from_me",
82
+ "handle_id",
83
+ "attributedBody",
84
+ "item_type?"
85
+ ],
86
+ "handle": [
87
+ "ROWID",
88
+ "id",
89
+ "service"
90
+ ],
91
+ "chat": [
92
+ "ROWID",
93
+ "style",
94
+ "display_name?",
95
+ "group_id?",
96
+ "original_group_id?"
97
+ ],
98
+ "chat_message_join": [
99
+ "chat_id",
100
+ "message_id"
101
+ ],
102
+ "chat_handle_join": [
103
+ "chat_id",
104
+ "handle_id"
105
+ ],
106
+ "message_attachment_join": [
107
+ "message_id",
108
+ "attachment_id"
109
+ ],
110
+ "attachment": [
111
+ "ROWID",
112
+ "guid",
113
+ "filename",
114
+ "mime_type",
115
+ "uti",
116
+ "total_bytes",
117
+ "transfer_name",
118
+ "is_outgoing",
119
+ "hide_attachment?"
120
+ ]
121
+ }
122
+ }
123
+ ]
67
124
  }
68
125
  },
69
126
  "platforms": [
@@ -73,6 +130,11 @@
73
130
  "full-disk-access"
74
131
  ]
75
132
  }
133
+ },
134
+ "deepLinks": {
135
+ "imessage": {
136
+ "Message": "messages://open?message-guid={sourceId}"
137
+ }
76
138
  }
77
139
  }
78
140
  }