@chronicle.app/imessage 0.1.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/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/connectors/ImessageExtractor.d.ts +19 -0
- package/dist/connectors/ImessageExtractor.js +177 -0
- package/dist/connectors/ImessageTransformer.d.ts +43 -0
- package/dist/connectors/ImessageTransformer.js +321 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Louis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @chronicle.app/imessage
|
|
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.
|
|
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.
|
|
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.
|
|
8
|
+
|
|
9
|
+
Tests use synthetic Messages and AddressBook databases, account command fixtures, and temporary attachment files. They do not read personal messages or contacts.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
|
|
3
|
+
import ImessageTransformer from './ImessageTransformer.js';
|
|
4
|
+
export declare class ImessageExtractor extends SqliteExtractor<typeof ImessageExtractor> {
|
|
5
|
+
static recordTypes: string[];
|
|
6
|
+
static source: string;
|
|
7
|
+
static description: string;
|
|
8
|
+
static delivery: "local";
|
|
9
|
+
static strategy: string;
|
|
10
|
+
static default: boolean;
|
|
11
|
+
static schema: any;
|
|
12
|
+
static defaultTransformer: typeof ImessageTransformer;
|
|
13
|
+
extract(): AsyncGenerator<Record>;
|
|
14
|
+
private queryMessages;
|
|
15
|
+
private loadChats;
|
|
16
|
+
private loadAttachments;
|
|
17
|
+
private processAttributedBody;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=ImessageExtractor.d.ts.map
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { SqliteExtractor, iosToUnixTimestamp, timeRangeConditions, APPLE_EPOCH_OFFSET_SECONDS, } from '@chronicle.app/etl-sqlite';
|
|
2
|
+
import { getICloudAccount } from '@chronicle.app/icloud';
|
|
3
|
+
import ImessageTransformer from './ImessageTransformer.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
export class ImessageExtractor extends SqliteExtractor {
|
|
6
|
+
static recordTypes = ['messages'];
|
|
7
|
+
static source = 'imessage';
|
|
8
|
+
static description = 'Messages and attachments from chat.db';
|
|
9
|
+
static delivery = 'local';
|
|
10
|
+
static strategy = 'app-db';
|
|
11
|
+
static default = true;
|
|
12
|
+
static schema = SqliteExtractor.schema.omit({ input: true }).extend({
|
|
13
|
+
input: z
|
|
14
|
+
.string()
|
|
15
|
+
.default(`${process.env.HOME}/Library/Messages/chat.db`)
|
|
16
|
+
.describe('The path to the iMessage sqlite database'),
|
|
17
|
+
includeContactNames: z
|
|
18
|
+
.boolean()
|
|
19
|
+
.optional()
|
|
20
|
+
.default(true)
|
|
21
|
+
.describe('Include contact names from AddressBook (default: true)'),
|
|
22
|
+
account: z
|
|
23
|
+
.object({
|
|
24
|
+
accountID: z.string(),
|
|
25
|
+
email: z.string(),
|
|
26
|
+
displayName: z.string(),
|
|
27
|
+
dsid: z.string().optional(),
|
|
28
|
+
})
|
|
29
|
+
.nullable()
|
|
30
|
+
.optional(),
|
|
31
|
+
});
|
|
32
|
+
static defaultTransformer = ImessageTransformer;
|
|
33
|
+
async *extract() {
|
|
34
|
+
const chats = this.loadChats();
|
|
35
|
+
const rows = this.queryMessages();
|
|
36
|
+
const attachments = this.loadAttachments();
|
|
37
|
+
// Get real iCloud account info automatically
|
|
38
|
+
const realIcloudAccount = this.config.account === undefined ? await getICloudAccount() : this.config.account;
|
|
39
|
+
for (const row of rows) {
|
|
40
|
+
const attributedBody = this.processAttributedBody(row.attributedBody);
|
|
41
|
+
const timestamp = iosToUnixTimestamp(Number.parseInt(row.date, 10));
|
|
42
|
+
const participants = chats[row.chat_id] || [];
|
|
43
|
+
const messageAttachments = attachments[row.ROWID] || [];
|
|
44
|
+
yield this.createRecord(row, {
|
|
45
|
+
attributedBody,
|
|
46
|
+
timestamp,
|
|
47
|
+
messageId: row.ROWID,
|
|
48
|
+
guid: row.guid,
|
|
49
|
+
service: row.service,
|
|
50
|
+
isFromMe: row.is_from_me,
|
|
51
|
+
handleId: row.handle_id,
|
|
52
|
+
handleIdText: row.handle_id_text,
|
|
53
|
+
participants,
|
|
54
|
+
attachments: messageAttachments,
|
|
55
|
+
myIcloudAccount: realIcloudAccount,
|
|
56
|
+
includeContactNames: this.config.includeContactNames,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
queryMessages() {
|
|
61
|
+
let sql = `
|
|
62
|
+
SELECT
|
|
63
|
+
m.ROWID, m.guid, m.text, m.service, m.date, m.is_from_me, m.handle_id,
|
|
64
|
+
m.attributedBody, h.id as handle_id_text, h.service as handle_service,
|
|
65
|
+
cm.chat_id
|
|
66
|
+
FROM
|
|
67
|
+
message AS m
|
|
68
|
+
LEFT OUTER JOIN handle AS h ON m.handle_id = h.ROWID
|
|
69
|
+
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
|
+
});
|
|
75
|
+
if (conditions.length > 0) {
|
|
76
|
+
sql += ` WHERE ${conditions.join(' AND ')}`;
|
|
77
|
+
}
|
|
78
|
+
sql += ` ORDER BY m.date DESC`;
|
|
79
|
+
const effectiveLimit = this.getEffectiveLimit();
|
|
80
|
+
if (effectiveLimit !== null) {
|
|
81
|
+
sql += ` LIMIT ?`;
|
|
82
|
+
values.push(effectiveLimit);
|
|
83
|
+
}
|
|
84
|
+
const stmt = this.db.prepare(sql);
|
|
85
|
+
stmt.setReadBigInts(true);
|
|
86
|
+
return stmt
|
|
87
|
+
.all(...values)
|
|
88
|
+
.map(row => Object.fromEntries(Object.entries(row).map(([key, value]) => [
|
|
89
|
+
key,
|
|
90
|
+
typeof value === 'bigint'
|
|
91
|
+
? key === 'date' || value > BigInt(Number.MAX_SAFE_INTEGER)
|
|
92
|
+
? value.toString()
|
|
93
|
+
: Number(value)
|
|
94
|
+
: value,
|
|
95
|
+
])));
|
|
96
|
+
}
|
|
97
|
+
loadChats() {
|
|
98
|
+
const sql = `
|
|
99
|
+
SELECT ch.chat_id, h.ROWID as handle_rowid, h.id, h.service as handle_service
|
|
100
|
+
FROM chat_handle_join AS ch
|
|
101
|
+
INNER JOIN handle AS h ON ch.handle_id = h.ROWID
|
|
102
|
+
INNER JOIN chat AS c ON ch.chat_id = c.ROWID
|
|
103
|
+
`;
|
|
104
|
+
const stmt = this.db.prepare(sql);
|
|
105
|
+
const results = stmt.all();
|
|
106
|
+
// Group handles by chat_id
|
|
107
|
+
const chats = {};
|
|
108
|
+
for (const row of results) {
|
|
109
|
+
const chatId = row.chat_id;
|
|
110
|
+
if (!chats[chatId]) {
|
|
111
|
+
chats[chatId] = [];
|
|
112
|
+
}
|
|
113
|
+
chats[chatId].push({
|
|
114
|
+
handleRowid: row.handle_rowid,
|
|
115
|
+
id: row.id,
|
|
116
|
+
service: row.handle_service,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return chats;
|
|
120
|
+
}
|
|
121
|
+
loadAttachments() {
|
|
122
|
+
const sql = `
|
|
123
|
+
SELECT
|
|
124
|
+
maj.message_id,
|
|
125
|
+
a.ROWID as attachment_id,
|
|
126
|
+
a.guid,
|
|
127
|
+
a.filename,
|
|
128
|
+
a.mime_type,
|
|
129
|
+
a.uti,
|
|
130
|
+
a.total_bytes,
|
|
131
|
+
a.transfer_name,
|
|
132
|
+
a.is_outgoing
|
|
133
|
+
FROM message_attachment_join AS maj
|
|
134
|
+
INNER JOIN attachment AS a ON maj.attachment_id = a.ROWID
|
|
135
|
+
WHERE a.filename IS NOT NULL
|
|
136
|
+
`;
|
|
137
|
+
const stmt = this.db.prepare(sql);
|
|
138
|
+
const results = stmt.all();
|
|
139
|
+
// Group attachments by message_id
|
|
140
|
+
const attachments = {};
|
|
141
|
+
for (const row of results) {
|
|
142
|
+
const messageId = row.message_id;
|
|
143
|
+
if (!attachments[messageId]) {
|
|
144
|
+
attachments[messageId] = [];
|
|
145
|
+
}
|
|
146
|
+
attachments[messageId].push({
|
|
147
|
+
attachmentId: row.attachment_id,
|
|
148
|
+
guid: row.guid,
|
|
149
|
+
filename: row.filename,
|
|
150
|
+
mimeType: row.mime_type,
|
|
151
|
+
uti: row.uti,
|
|
152
|
+
totalBytes: row.total_bytes,
|
|
153
|
+
transferName: row.transfer_name,
|
|
154
|
+
isOutgoing: row.is_outgoing,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return attachments;
|
|
158
|
+
}
|
|
159
|
+
processAttributedBody(attributedBody) {
|
|
160
|
+
if (!attributedBody)
|
|
161
|
+
return '';
|
|
162
|
+
attributedBody = (typeof attributedBody === 'string'
|
|
163
|
+
? Buffer.from(attributedBody, 'binary')
|
|
164
|
+
: Buffer.from(attributedBody)).toString('utf8');
|
|
165
|
+
if (attributedBody.includes('NSNumber')) {
|
|
166
|
+
attributedBody = attributedBody.split('NSNumber')[0];
|
|
167
|
+
if (attributedBody.includes('NSString')) {
|
|
168
|
+
attributedBody = attributedBody.split('NSString')[1];
|
|
169
|
+
if (attributedBody.includes('NSDictionary')) {
|
|
170
|
+
attributedBody = attributedBody.split('NSDictionary')[0];
|
|
171
|
+
attributedBody = attributedBody.slice(6, -12);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return attributedBody;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ChronicleTransformer, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { ActionAndChildren } from '@chronicle.app/schema';
|
|
3
|
+
export default class ImessageTransformer extends ChronicleTransformer {
|
|
4
|
+
static source: string;
|
|
5
|
+
transform(record: Record): Promise<ActionAndChildren[]>;
|
|
6
|
+
private buildMessageAction;
|
|
7
|
+
private buildMessage;
|
|
8
|
+
private buildParticipants;
|
|
9
|
+
/**
|
|
10
|
+
* One chat participant as an Agent in the service's identity namespace
|
|
11
|
+
* (`icloud` for iMessage, `phone` for SMS), keyed on the handle exactly as
|
|
12
|
+
* chat.db stores it — an email address or a phone number; no sourceId, since
|
|
13
|
+
* the database carries no DSID. Linked by `sameAs` to the email identities the
|
|
14
|
+
* same handle is known by, so the participant collapses with the person the
|
|
15
|
+
* email plugin saw.
|
|
16
|
+
*/
|
|
17
|
+
private buildParticipantAgent;
|
|
18
|
+
/**
|
|
19
|
+
* The `email`-namespace identities this participant is: the handle itself when
|
|
20
|
+
* it is an email address (iMessage addresses people by their iCloud email),
|
|
21
|
+
* plus every email on the AddressBook card the handle resolves to when contact
|
|
22
|
+
* lookup is on. Each is a full entity-reference node keyed the way the email
|
|
23
|
+
* plugin keys its own agents — `Agent` / `email` / the address as written, no
|
|
24
|
+
* case folding on either side — so the fold merges the two through the shared
|
|
25
|
+
* keyset. Only identifiers the source actually carries; one address spelled
|
|
26
|
+
* more than once keeps its first spelling.
|
|
27
|
+
*/
|
|
28
|
+
private emailIdentities;
|
|
29
|
+
private buildMyIdentity;
|
|
30
|
+
private buildMyPhoneIdentity;
|
|
31
|
+
private determineAgentAndRecipients;
|
|
32
|
+
private getServiceSource;
|
|
33
|
+
private getAgentSource;
|
|
34
|
+
private normalizedBody;
|
|
35
|
+
private processAttachments;
|
|
36
|
+
private getMimeTypeFromExtension;
|
|
37
|
+
/** First 16 bytes of a file — enough for every signature in sniffMimeType. */
|
|
38
|
+
private readLeadingBytes;
|
|
39
|
+
/** Last-resort detection from magic bytes (e.g. rich-link previews with dyn.* UTIs). */
|
|
40
|
+
private sniffMimeType;
|
|
41
|
+
private resolveAttachmentPath;
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=ImessageTransformer.d.ts.map
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { ChronicleTransformer, normalizePhoneNumber, createMediaObject, } from '@chronicle.app/etl';
|
|
2
|
+
import { lookupContact, buildICloudPersonSchema } from '@chronicle.app/icloud';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
export default class ImessageTransformer extends ChronicleTransformer {
|
|
6
|
+
static source = 'imessage';
|
|
7
|
+
async transform(record) {
|
|
8
|
+
const actions = [];
|
|
9
|
+
if (record.extraction.recordType === 'messages') {
|
|
10
|
+
actions.push(...(await this.buildMessageAction(record)));
|
|
11
|
+
}
|
|
12
|
+
return actions;
|
|
13
|
+
}
|
|
14
|
+
async buildMessageAction(record) {
|
|
15
|
+
const service = this.getServiceSource(record.context.service);
|
|
16
|
+
const participants = this.buildParticipants(record);
|
|
17
|
+
const myIdentity = await this.buildMyIdentity(record);
|
|
18
|
+
const { agent, recipients } = this.determineAgentAndRecipients(record, participants, myIdentity);
|
|
19
|
+
const obj = {
|
|
20
|
+
'@type': 'MessageAction',
|
|
21
|
+
timestamp: new Date(record.context.timestamp),
|
|
22
|
+
source: service,
|
|
23
|
+
sourceId: record.context.guid, // Use GUID instead of ROWID
|
|
24
|
+
'@key': ['source', 'sourceId', '@type'],
|
|
25
|
+
object: this.buildMessage(record, recipients),
|
|
26
|
+
agent,
|
|
27
|
+
};
|
|
28
|
+
return [obj];
|
|
29
|
+
}
|
|
30
|
+
buildMessage(record, recipients) {
|
|
31
|
+
const messageSource = this.getServiceSource(record.context.service);
|
|
32
|
+
const message = {
|
|
33
|
+
'@type': 'Message',
|
|
34
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
35
|
+
source: messageSource, // Use service-based source (sms or imessage)
|
|
36
|
+
sourceId: record.context.guid, // Use GUID for message identity
|
|
37
|
+
body: this.normalizedBody(record),
|
|
38
|
+
};
|
|
39
|
+
// Add recipients if available
|
|
40
|
+
if (recipients.length > 0) {
|
|
41
|
+
message.recipient = recipients;
|
|
42
|
+
}
|
|
43
|
+
// Add attachments using contains property (following Bluesky pattern)
|
|
44
|
+
const attachments = record.context.attachments || [];
|
|
45
|
+
if (attachments.length > 0) {
|
|
46
|
+
message.contains = this.processAttachments(attachments);
|
|
47
|
+
}
|
|
48
|
+
return message;
|
|
49
|
+
}
|
|
50
|
+
buildParticipants(record) {
|
|
51
|
+
const agentSource = this.getAgentSource(record.context.service);
|
|
52
|
+
const chatParticipants = record.context.participants || [];
|
|
53
|
+
return chatParticipants.map(participant => this.buildParticipantAgent(record, agentSource, participant.id));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* One chat participant as an Agent in the service's identity namespace
|
|
57
|
+
* (`icloud` for iMessage, `phone` for SMS), keyed on the handle exactly as
|
|
58
|
+
* chat.db stores it — an email address or a phone number; no sourceId, since
|
|
59
|
+
* the database carries no DSID. Linked by `sameAs` to the email identities the
|
|
60
|
+
* same handle is known by, so the participant collapses with the person the
|
|
61
|
+
* email plugin saw.
|
|
62
|
+
*/
|
|
63
|
+
buildParticipantAgent(record, agentSource, handle) {
|
|
64
|
+
const contact = record.context.includeContactNames
|
|
65
|
+
? (this.config.lookupContact ?? lookupContact)(handle)
|
|
66
|
+
: null;
|
|
67
|
+
const sameAs = this.emailIdentities(handle, contact);
|
|
68
|
+
return {
|
|
69
|
+
'@type': 'Agent',
|
|
70
|
+
source: agentSource,
|
|
71
|
+
handle,
|
|
72
|
+
name: contact?.fullName,
|
|
73
|
+
'@key': ['@type', 'source', 'handle'],
|
|
74
|
+
...(sameAs.length > 0 ? { sameAs } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The `email`-namespace identities this participant is: the handle itself when
|
|
79
|
+
* it is an email address (iMessage addresses people by their iCloud email),
|
|
80
|
+
* plus every email on the AddressBook card the handle resolves to when contact
|
|
81
|
+
* lookup is on. Each is a full entity-reference node keyed the way the email
|
|
82
|
+
* plugin keys its own agents — `Agent` / `email` / the address as written, no
|
|
83
|
+
* case folding on either side — so the fold merges the two through the shared
|
|
84
|
+
* keyset. Only identifiers the source actually carries; one address spelled
|
|
85
|
+
* more than once keeps its first spelling.
|
|
86
|
+
*/
|
|
87
|
+
emailIdentities(handle, contact) {
|
|
88
|
+
const candidates = [...(handle.includes('@') ? [handle] : []), ...(contact?.emails ?? [])];
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
const emails = [];
|
|
91
|
+
for (const email of candidates) {
|
|
92
|
+
const key = email.trim().toLowerCase();
|
|
93
|
+
if (!key || seen.has(key))
|
|
94
|
+
continue;
|
|
95
|
+
seen.add(key);
|
|
96
|
+
emails.push(email);
|
|
97
|
+
}
|
|
98
|
+
return emails.map(email => ({
|
|
99
|
+
'@type': 'Agent',
|
|
100
|
+
source: 'email',
|
|
101
|
+
handle: email,
|
|
102
|
+
'@key': ['@type', 'source', 'handle'],
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
async buildMyIdentity(record) {
|
|
106
|
+
const { service } = record.context;
|
|
107
|
+
const agentSource = this.getAgentSource(service);
|
|
108
|
+
const identity = agentSource === 'icloud'
|
|
109
|
+
? (await buildICloudPersonSchema(record.context.myIcloudAccount))
|
|
110
|
+
: this.buildMyPhoneIdentity(record);
|
|
111
|
+
// Tag the account owner as self. The iCloud branch (buildICloudPersonSchema)
|
|
112
|
+
// already self-tags, so add `@me` only when it isn't already present.
|
|
113
|
+
const sameAs = identity?.sameAs ?? [];
|
|
114
|
+
return { ...identity, sameAs: sameAs.includes('@me') ? sameAs : [...sameAs, '@me'] };
|
|
115
|
+
}
|
|
116
|
+
buildMyPhoneIdentity(record) {
|
|
117
|
+
// Try to find my phone number by matching my iCloud email in AddressBook
|
|
118
|
+
if (record?.context?.myIcloudAccount?.email && record?.context?.includeContactNames) {
|
|
119
|
+
const myContact = (this.config.lookupContact ?? lookupContact)(record.context.myIcloudAccount.email);
|
|
120
|
+
if (myContact?.phoneNumbers && myContact.phoneNumbers.length > 0) {
|
|
121
|
+
// Found my contact with phone numbers - use the first one (normalized)
|
|
122
|
+
const normalizedPhone = normalizePhoneNumber(myContact.phoneNumbers[0]);
|
|
123
|
+
if (normalizedPhone) {
|
|
124
|
+
return {
|
|
125
|
+
'@type': 'Person',
|
|
126
|
+
source: 'phone',
|
|
127
|
+
handle: normalizedPhone,
|
|
128
|
+
'@key': ['@type', 'source', 'handle'],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Fallback when we can't determine my phone number
|
|
134
|
+
// Still need a unique identifier - use iCloud email as fallback handle
|
|
135
|
+
const fallbackHandle = record?.context?.myIcloudAccount?.email;
|
|
136
|
+
if (fallbackHandle) {
|
|
137
|
+
return {
|
|
138
|
+
'@type': 'Person',
|
|
139
|
+
source: 'phone',
|
|
140
|
+
handle: fallbackHandle, // Use iCloud email as unique identifier
|
|
141
|
+
'@key': ['@type', 'source', 'handle'],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// Last resort - no unique info available
|
|
145
|
+
return {
|
|
146
|
+
'@type': 'Person',
|
|
147
|
+
source: 'phone',
|
|
148
|
+
'@key': ['@type', 'source'],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
determineAgentAndRecipients(record, participants, myIdentity) {
|
|
152
|
+
const isFromMe = record.context.isFromMe === 1;
|
|
153
|
+
if (isFromMe) {
|
|
154
|
+
// I sent the message - I'm the agent, everyone else are recipients
|
|
155
|
+
const recipients = myIdentity.handle
|
|
156
|
+
? participants.filter(p => p.handle !== myIdentity.handle)
|
|
157
|
+
: participants; // If I have no handle (SMS fallback), all participants are recipients
|
|
158
|
+
return {
|
|
159
|
+
agent: myIdentity,
|
|
160
|
+
recipients,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// Someone else sent the message - find them as agent, I'm a recipient
|
|
164
|
+
const { handleIdText } = record.context;
|
|
165
|
+
const agent = participants.find(p => p.handle === handleIdText);
|
|
166
|
+
if (!agent) {
|
|
167
|
+
// The sender is not among the chat's participants: build them from the
|
|
168
|
+
// message's own handle, the same way as any other participant.
|
|
169
|
+
const agentSource = this.getAgentSource(record.context.service);
|
|
170
|
+
return {
|
|
171
|
+
agent: this.buildParticipantAgent(record, agentSource, handleIdText),
|
|
172
|
+
recipients: [myIdentity],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
agent,
|
|
177
|
+
recipients: [...participants.filter(p => p.handle !== agent.handle), myIdentity],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
getServiceSource(_service) {
|
|
181
|
+
// All messages come from iMessage database, so use imessage source regardless of service type
|
|
182
|
+
return 'imessage';
|
|
183
|
+
}
|
|
184
|
+
getAgentSource(service) {
|
|
185
|
+
// For Person source - differentiates identity systems
|
|
186
|
+
switch (service?.toLowerCase()) {
|
|
187
|
+
case 'sms':
|
|
188
|
+
return 'phone';
|
|
189
|
+
case 'imessage':
|
|
190
|
+
default:
|
|
191
|
+
return 'icloud';
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
normalizedBody(record) {
|
|
195
|
+
// Use text field first, fallback to processed attributedBody
|
|
196
|
+
return record.data.text || record.context.attributedBody || '';
|
|
197
|
+
}
|
|
198
|
+
processAttachments(attachments) {
|
|
199
|
+
const mediaObjects = [];
|
|
200
|
+
for (const attachment of attachments) {
|
|
201
|
+
try {
|
|
202
|
+
const filePath = this.resolveAttachmentPath(attachment.filename);
|
|
203
|
+
// Check if file exists
|
|
204
|
+
if (!fs.existsSync(filePath)) {
|
|
205
|
+
console.warn(`Attachment file not found: ${filePath}`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// MIME drives the media object @type (ImageObject vs DocumentObject…).
|
|
209
|
+
// chat.db's mime_type is NULL for some transfers (dynamic UTIs like
|
|
210
|
+
// rich-link previews), so fall back to extension, then magic bytes.
|
|
211
|
+
const mimeType = attachment.mimeType ||
|
|
212
|
+
this.getMimeTypeFromExtension(filePath) ||
|
|
213
|
+
this.sniffMimeType(this.readLeadingBytes(filePath)) ||
|
|
214
|
+
'application/octet-stream';
|
|
215
|
+
// Reference the file by path; ingest reads the bytes into the
|
|
216
|
+
// attachment store — no base64 inflation through the pipeline.
|
|
217
|
+
const mediaObject = createMediaObject({
|
|
218
|
+
path: filePath,
|
|
219
|
+
mimeType,
|
|
220
|
+
description: attachment.transferName || path.basename(attachment.filename),
|
|
221
|
+
});
|
|
222
|
+
// Add iMessage attachment GUID for deduplication
|
|
223
|
+
mediaObject.sourceId = attachment.guid;
|
|
224
|
+
mediaObject.source = 'imessage';
|
|
225
|
+
mediaObject['@key'] = ['source', 'sourceId'];
|
|
226
|
+
mediaObjects.push(mediaObject);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
console.warn(`Failed to process attachment ${attachment.filename}:`, error);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return mediaObjects;
|
|
233
|
+
}
|
|
234
|
+
getMimeTypeFromExtension(filePath) {
|
|
235
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
236
|
+
const mimeTypes = {
|
|
237
|
+
'.png': 'image/png',
|
|
238
|
+
'.jpg': 'image/jpeg',
|
|
239
|
+
'.jpeg': 'image/jpeg',
|
|
240
|
+
'.gif': 'image/gif',
|
|
241
|
+
'.webp': 'image/webp',
|
|
242
|
+
'.heic': 'image/heic',
|
|
243
|
+
'.heif': 'image/heif',
|
|
244
|
+
'.tiff': 'image/tiff',
|
|
245
|
+
'.tif': 'image/tiff',
|
|
246
|
+
'.ico': 'image/x-icon',
|
|
247
|
+
'.mp4': 'video/mp4',
|
|
248
|
+
'.mov': 'video/quicktime',
|
|
249
|
+
'.avi': 'video/x-msvideo',
|
|
250
|
+
'.3gp': 'video/3gpp',
|
|
251
|
+
'.mp3': 'audio/mpeg',
|
|
252
|
+
'.wav': 'audio/wav',
|
|
253
|
+
'.m4a': 'audio/mp4',
|
|
254
|
+
'.caf': 'audio/x-caf',
|
|
255
|
+
'.amr': 'audio/amr',
|
|
256
|
+
'.pdf': 'application/pdf',
|
|
257
|
+
'.vcf': 'text/vcard',
|
|
258
|
+
};
|
|
259
|
+
return mimeTypes[ext];
|
|
260
|
+
}
|
|
261
|
+
/** First 16 bytes of a file — enough for every signature in sniffMimeType. */
|
|
262
|
+
readLeadingBytes(filePath) {
|
|
263
|
+
const buf = Buffer.alloc(16);
|
|
264
|
+
const fd = fs.openSync(filePath, 'r');
|
|
265
|
+
try {
|
|
266
|
+
const n = fs.readSync(fd, buf, 0, 16, 0);
|
|
267
|
+
return buf.subarray(0, n);
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
fs.closeSync(fd);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Last-resort detection from magic bytes (e.g. rich-link previews with dyn.* UTIs). */
|
|
274
|
+
sniffMimeType(bytes) {
|
|
275
|
+
const ascii = (start, text) => bytes.length >= start + text.length &&
|
|
276
|
+
bytes.toString('latin1', start, start + text.length) === text;
|
|
277
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
|
|
278
|
+
return 'image/jpeg';
|
|
279
|
+
if (bytes.length >= 4 && bytes[0] === 0x89 && ascii(1, 'PNG'))
|
|
280
|
+
return 'image/png';
|
|
281
|
+
if (ascii(0, 'GIF8'))
|
|
282
|
+
return 'image/gif';
|
|
283
|
+
if (ascii(0, 'RIFF') && ascii(8, 'WEBP'))
|
|
284
|
+
return 'image/webp';
|
|
285
|
+
if (ascii(0, 'RIFF') && ascii(8, 'WAVE'))
|
|
286
|
+
return 'audio/wav';
|
|
287
|
+
if (ascii(4, 'ftypheic') || ascii(4, 'ftypheix') || ascii(4, 'ftypmif1'))
|
|
288
|
+
return 'image/heic';
|
|
289
|
+
if (ascii(4, 'ftypqt'))
|
|
290
|
+
return 'video/quicktime';
|
|
291
|
+
if (ascii(4, 'ftypM4A'))
|
|
292
|
+
return 'audio/mp4';
|
|
293
|
+
if (ascii(4, 'ftyp'))
|
|
294
|
+
return 'video/mp4';
|
|
295
|
+
if (ascii(0, '%PDF'))
|
|
296
|
+
return 'application/pdf';
|
|
297
|
+
if (ascii(0, 'ID3'))
|
|
298
|
+
return 'audio/mpeg';
|
|
299
|
+
if (ascii(0, 'caff'))
|
|
300
|
+
return 'audio/x-caf';
|
|
301
|
+
if (ascii(0, '#!AMR'))
|
|
302
|
+
return 'audio/amr';
|
|
303
|
+
if (bytes.length >= 4 && bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 1 && bytes[3] === 0)
|
|
304
|
+
return 'image/x-icon';
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
resolveAttachmentPath(filename) {
|
|
308
|
+
// iMessage filename field contains paths like ~/Library/Messages/Attachments/...
|
|
309
|
+
// Need to expand the ~ to the actual home directory
|
|
310
|
+
if (filename.startsWith('~/')) {
|
|
311
|
+
return path.join(process.env.HOME || '', filename.slice(2));
|
|
312
|
+
}
|
|
313
|
+
// If it's already an absolute path, use as-is
|
|
314
|
+
if (path.isAbsolute(filename)) {
|
|
315
|
+
return filename;
|
|
316
|
+
}
|
|
317
|
+
// Handle relative paths by prepending the standard iMessage attachments directory
|
|
318
|
+
const attachmentsDir = path.join(process.env.HOME || '', 'Library', 'Messages', 'Attachments');
|
|
319
|
+
return path.join(attachmentsDir, filename);
|
|
320
|
+
}
|
|
321
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chronicle.app/imessage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"types": "./dist/index.d.ts",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist/**/*.js",
|
|
8
|
+
"dist/**/*.d.ts"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -b tsconfig.lib.json",
|
|
12
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
13
|
+
"lint": "eslint src --ext .ts,.js",
|
|
14
|
+
"test": "node --test src/*.test.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [],
|
|
17
|
+
"author": "Andrew Louis",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"description": "Chronicle imessage extraction plugin",
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@chronicle.app/tsconfig": "0.1.0",
|
|
23
|
+
"@types/node": "^22.10.1",
|
|
24
|
+
"typescript": "^5.7.2"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://chronicle.app",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/chronicle-app/chronicle.git",
|
|
30
|
+
"directory": "plugins/imessage"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"registry": "https://registry.npmjs.org/"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@chronicle.app/etl": "0.1.0",
|
|
38
|
+
"@chronicle.app/schema": "0.1.0",
|
|
39
|
+
"zod": "^3.24.1",
|
|
40
|
+
"@chronicle.app/etl-sqlite": "0.1.0",
|
|
41
|
+
"@chronicle.app/icloud": "0.1.0"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22.13.0"
|
|
45
|
+
},
|
|
46
|
+
"exports": {
|
|
47
|
+
".": {
|
|
48
|
+
"types": "./dist/index.d.ts",
|
|
49
|
+
"import": "./dist/index.js"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"chronicle": {
|
|
53
|
+
"plugin": true
|
|
54
|
+
},
|
|
55
|
+
"oclif": {}
|
|
56
|
+
}
|