@elizaos/plugin-imessage 2.0.3-beta.2 → 2.0.3-beta.3

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.
Files changed (58) hide show
  1. package/dist/accounts.d.ts +135 -0
  2. package/dist/accounts.d.ts.map +1 -0
  3. package/dist/accounts.js +209 -0
  4. package/dist/accounts.js.map +1 -0
  5. package/dist/api/bluebubbles-routes.d.ts +10 -0
  6. package/dist/api/bluebubbles-routes.d.ts.map +1 -0
  7. package/dist/api/bluebubbles-routes.js +132 -0
  8. package/dist/api/bluebubbles-routes.js.map +1 -0
  9. package/dist/api/imessage-routes.d.ts +68 -0
  10. package/dist/api/imessage-routes.d.ts.map +1 -0
  11. package/dist/api/imessage-routes.js +228 -0
  12. package/dist/api/imessage-routes.js.map +1 -0
  13. package/dist/chatdb-reader.d.ts +240 -0
  14. package/dist/chatdb-reader.d.ts.map +1 -0
  15. package/dist/chatdb-reader.js +667 -0
  16. package/dist/chatdb-reader.js.map +1 -0
  17. package/dist/config.d.ts +60 -0
  18. package/dist/config.d.ts.map +1 -0
  19. package/dist/config.js +8 -0
  20. package/dist/config.js.map +1 -0
  21. package/dist/connector-account-provider.d.ts +18 -0
  22. package/dist/connector-account-provider.d.ts.map +1 -0
  23. package/dist/connector-account-provider.js +83 -0
  24. package/dist/connector-account-provider.js.map +1 -0
  25. package/dist/contacts-reader.d.ts +141 -0
  26. package/dist/contacts-reader.d.ts.map +1 -0
  27. package/dist/contacts-reader.js +359 -0
  28. package/dist/contacts-reader.js.map +1 -0
  29. package/dist/data-routes.d.ts +21 -0
  30. package/dist/data-routes.d.ts.map +1 -0
  31. package/dist/data-routes.js +280 -0
  32. package/dist/data-routes.js.map +1 -0
  33. package/dist/index.d.ts +24 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +83 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/providers/index.d.ts +4 -0
  38. package/dist/providers/index.d.ts.map +1 -0
  39. package/dist/providers/index.js +5 -0
  40. package/dist/providers/index.js.map +1 -0
  41. package/dist/rpc.d.ts +206 -0
  42. package/dist/rpc.d.ts.map +1 -0
  43. package/dist/rpc.js +393 -0
  44. package/dist/rpc.js.map +1 -0
  45. package/dist/service.d.ts +264 -0
  46. package/dist/service.d.ts.map +1 -0
  47. package/dist/service.js +1705 -0
  48. package/dist/service.js.map +1 -0
  49. package/dist/setup-routes.d.ts +26 -0
  50. package/dist/setup-routes.d.ts.map +1 -0
  51. package/dist/setup-routes.js +139 -0
  52. package/dist/setup-routes.js.map +1 -0
  53. package/dist/types.d.ts +192 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +138 -0
  56. package/dist/types.js.map +1 -0
  57. package/package.json +4 -3
  58. package/registry-entry.json +103 -0
@@ -0,0 +1,667 @@
1
+ /**
2
+ * macOS chat.db reader for @elizaos/plugin-imessage.
3
+ *
4
+ * iMessage stores every message in a SQLite database at
5
+ * `~/Library/Messages/chat.db`. Reading it requires Full Disk Access on
6
+ * whichever process hosts the plugin (the Eliza agent, typically). This
7
+ * module opens that file read-only and exposes a single `fetchNewMessages`
8
+ * method the polling loop uses to walk forward by ROWID.
9
+ *
10
+ * ---
11
+ *
12
+ * Backend: runtime SQLite built-ins. Bun exposes `bun:sqlite`; Node 22+
13
+ * exposes `node:sqlite`. We normalize both to the small query surface this
14
+ * module needs so live chat.db reads keep working in test runners and under
15
+ * either runtime.
16
+ *
17
+ * Prior to this module, the plugin attempted to read messages by running
18
+ * AppleScript against Messages.app's `get messages` verb — a verb that
19
+ * does not exist in Messages.app's scripting dictionary. That code path
20
+ * silently returned an empty list on every poll, so inbound messages
21
+ * never reached the agent.
22
+ */
23
+ import { createRequire } from "node:module";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { logger } from "@elizaos/core";
27
+ /**
28
+ * Default path to macOS's iMessage database. Requires Full Disk Access
29
+ * on whichever process opens it.
30
+ */
31
+ export const DEFAULT_CHAT_DB_PATH = join(homedir(), "Library", "Messages", "chat.db");
32
+ export const MACOS_FULL_DISK_ACCESS_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
33
+ export function createFullDiskAccessAction() {
34
+ return {
35
+ type: "full_disk_access",
36
+ label: "Open Full Disk Access",
37
+ url: MACOS_FULL_DISK_ACCESS_SETTINGS_URL,
38
+ instructions: [
39
+ "Open System Settings > Privacy & Security > Full Disk Access.",
40
+ "Enable Eliza. If you run Eliza from a terminal, enable that terminal app too.",
41
+ "Quit and relaunch Eliza after changing Full Disk Access.",
42
+ ],
43
+ };
44
+ }
45
+ /**
46
+ * Apple Cocoa reference date: 2001-01-01T00:00:00Z. The `message.date`
47
+ * column stores a delta from this instant. Modern macOS stores the delta
48
+ * in nanoseconds; older macOS (< 10.13) stored it in seconds. We detect
49
+ * which by magnitude — any plausible seconds-since-2001 value fits in ~10
50
+ * digits, any nanoseconds-since-2001 value is at least 13 digits.
51
+ */
52
+ const APPLE_EPOCH_MS = Date.UTC(2001, 0, 1);
53
+ /**
54
+ * Convert an Apple Cocoa date delta to JavaScript milliseconds since
55
+ * epoch. Handles both legacy (seconds) and modern (nanoseconds) storage.
56
+ */
57
+ export function appleDateToJsMs(appleDate) {
58
+ if (typeof appleDate === "string") {
59
+ const trimmed = appleDate.trim();
60
+ if (!trimmed)
61
+ return 0;
62
+ try {
63
+ return appleDateToJsMs(BigInt(trimmed));
64
+ }
65
+ catch {
66
+ const parsed = Number(trimmed);
67
+ return Number.isFinite(parsed) ? appleDateToJsMs(parsed) : 0;
68
+ }
69
+ }
70
+ if (typeof appleDate === "bigint") {
71
+ if (appleDate <= 0n)
72
+ return 0;
73
+ if (appleDate > 1000000000000n) {
74
+ return APPLE_EPOCH_MS + Number(appleDate / 1000000n);
75
+ }
76
+ return APPLE_EPOCH_MS + Number(appleDate) * 1000;
77
+ }
78
+ if (!appleDate || appleDate < 0)
79
+ return 0;
80
+ // Nanosecond-scale values are enormous (> 1e15 for any date after 2002).
81
+ // Second-scale values top out around 1e9 for dates decades from now.
82
+ // Split at 1e12 to be safe.
83
+ const deltaMs = appleDate > 1e12 ? appleDate / 1e6 : appleDate * 1000;
84
+ return APPLE_EPOCH_MS + deltaMs;
85
+ }
86
+ /**
87
+ * Extract the plain UTF-8 text from an `attributedBody` BLOB.
88
+ *
89
+ * Modern macOS (~10.13+) stores message text as an `NSMutableAttributedString`
90
+ * serialised via Apple's legacy `typedstream` / NSArchiver format, because
91
+ * Messages.app wants to attach attributes (links, mentions, formatting)
92
+ * that plain text can't carry. Empirically, on a fresh macOS chat.db,
93
+ * ~97% of message rows have `text=NULL` and their actual readable content
94
+ * only exists in `attributedBody`. Reading chat.db without decoding this
95
+ * blob means being blind to almost every real message.
96
+ *
97
+ * Full typedstream parsing is complex (class inheritance chains, object
98
+ * references, multiple string encodings) and worth ~500 lines of code.
99
+ * The good news: Messages.app uses a narrow, stable subset for its own
100
+ * message text, and the text always appears after the same marker
101
+ * sequence: `NSString\x00\x01\x94\x84\x01\x2b` (class name, object flags,
102
+ * then `+` which is typedstream's "cstring" verb), followed by a length
103
+ * byte, followed by the UTF-8 bytes. For strings longer than 254 bytes
104
+ * typedstream escapes the length with `0x81` followed by a little-endian
105
+ * uint16 length, then the bytes. Everything else we don't care about.
106
+ *
107
+ * Verified against real blobs from a live chat.db — hit rate is ~100% on
108
+ * the messages checked, including short replies like "Yo" and longer
109
+ * messages with emoji. Returns null if no marker is found, which the
110
+ * caller uses as a signal to fall back to the raw `text` column or
111
+ * skip the row.
112
+ *
113
+ * References:
114
+ * - Apple's typedstream format: see darling-gnustep-base, GSTypedStream.m
115
+ * - imessage-exporter's Rust implementation (MIT) for the full parser
116
+ * - NSAttributedString serialisation in Cocoa Foundation
117
+ */
118
+ export function decodeAttributedBody(blob) {
119
+ if (!blob)
120
+ return null;
121
+ const buf = blob instanceof Buffer ? blob : Buffer.from(blob);
122
+ if (buf.length < 20)
123
+ return null;
124
+ // Locate the NSString class marker. Messages.app uses either "NSString"
125
+ // or (rarely) "NSMutableString" depending on whether the attributed
126
+ // string wraps a mutable backing store. Try both.
127
+ const MARKERS = [Buffer.from("NSString", "latin1"), Buffer.from("NSMutableString", "latin1")];
128
+ let start = -1;
129
+ for (const marker of MARKERS) {
130
+ const idx = buf.indexOf(marker);
131
+ if (idx !== -1) {
132
+ start = idx + marker.length;
133
+ break;
134
+ }
135
+ }
136
+ if (start === -1)
137
+ return null;
138
+ // After the class name is a short preamble: the byte sequence varies
139
+ // slightly depending on object graph position but always ends with
140
+ // `\x2b` (the typedstream `+` cstring verb). We scan forward a small
141
+ // bounded window for the `+` so the decoder doesn't drift into the
142
+ // attributes dictionary if Apple changes the exact preamble length.
143
+ const MAX_PREAMBLE = 16;
144
+ let plusAt = -1;
145
+ for (let i = start; i < Math.min(start + MAX_PREAMBLE, buf.length); i++) {
146
+ if (buf[i] === 0x2b) {
147
+ plusAt = i;
148
+ break;
149
+ }
150
+ }
151
+ if (plusAt === -1)
152
+ return null;
153
+ // Read the length. Typedstream uses:
154
+ // - single byte length for 0..254
155
+ // - 0x81 + little-endian uint16 for 255..65535
156
+ // - 0x82 + little-endian uint32 for larger (rare in chat.db)
157
+ let cursor = plusAt + 1;
158
+ if (cursor >= buf.length)
159
+ return null;
160
+ let length;
161
+ const first = buf[cursor];
162
+ if (first < 0x80 || first === 0xff) {
163
+ // Direct length byte
164
+ length = first;
165
+ cursor += 1;
166
+ }
167
+ else if (first === 0x81 && cursor + 2 < buf.length) {
168
+ length = buf.readUInt16LE(cursor + 1);
169
+ cursor += 3;
170
+ }
171
+ else if (first === 0x82 && cursor + 4 < buf.length) {
172
+ length = buf.readUInt32LE(cursor + 1);
173
+ cursor += 5;
174
+ }
175
+ else {
176
+ // Unknown length encoding; give up.
177
+ return null;
178
+ }
179
+ if (length === 0)
180
+ return "";
181
+ if (cursor + length > buf.length) {
182
+ // Truncated blob — return what we can without overrunning.
183
+ length = buf.length - cursor;
184
+ if (length <= 0)
185
+ return null;
186
+ }
187
+ // The bytes are UTF-8. Buffer.toString("utf8") silently replaces
188
+ // invalid sequences with U+FFFD, which is the right behaviour here —
189
+ // the agent would rather see a slightly-mangled message than nothing.
190
+ return buf.slice(cursor, cursor + length).toString("utf8");
191
+ }
192
+ /**
193
+ * Parse the reaction fields out of a chat.db row when `associated_message_type`
194
+ * is in the reaction range (2000-3005). Returns null for non-reaction rows.
195
+ */
196
+ function parseReaction(row) {
197
+ const type = row.associated_message_type ?? 0;
198
+ if (type < 2000 || type >= 4000)
199
+ return null;
200
+ const isRemove = type >= 3000;
201
+ const baseType = isRemove ? type - 1000 : type;
202
+ const kind = baseType === 2000
203
+ ? "love"
204
+ : baseType === 2001
205
+ ? "like"
206
+ : baseType === 2002
207
+ ? "dislike"
208
+ : baseType === 2003
209
+ ? "laugh"
210
+ : baseType === 2004
211
+ ? "emphasis"
212
+ : baseType === 2005
213
+ ? "question"
214
+ : baseType === 2006
215
+ ? "sticker"
216
+ : "unknown";
217
+ // associated_message_guid comes back as either a plain guid or prefixed
218
+ // with `p:<partIndex>/<guid>` for messages that target a specific part
219
+ // of a multipart message. Strip the prefix so downstream handlers get
220
+ // a clean guid they can match against other rows.
221
+ let targetGuid = row.associated_message_guid ?? "";
222
+ const slashIdx = targetGuid.lastIndexOf("/");
223
+ if (slashIdx !== -1)
224
+ targetGuid = targetGuid.slice(slashIdx + 1);
225
+ return {
226
+ kind,
227
+ add: !isRemove,
228
+ rawType: type,
229
+ targetGuid,
230
+ emoji: row.associated_message_emoji,
231
+ };
232
+ }
233
+ const runtimeRequire = createRequire(import.meta.url);
234
+ const loggedChatDbOpenFailures = new Set();
235
+ const lastChatDbAccessIssues = new Map();
236
+ let loggedSqliteUnavailable = false;
237
+ export function getLastChatDbAccessIssue(dbPath = DEFAULT_CHAT_DB_PATH) {
238
+ return lastChatDbAccessIssues.get(dbPath) ?? null;
239
+ }
240
+ /**
241
+ * Dynamically resolve a SQLite backend. We keep the specifiers opaque so the
242
+ * module still loads under runtimes that only support one of them.
243
+ */
244
+ async function tryLoadSqlite() {
245
+ try {
246
+ const mod = runtimeRequire("bun:sqlite");
247
+ const Database = mod.Database ?? mod.default;
248
+ if (Database) {
249
+ return (path, options) => new Database(path, options);
250
+ }
251
+ }
252
+ catch {
253
+ // Fall through to Node's built-in SQLite runtime.
254
+ }
255
+ try {
256
+ const mod = (await import("node:sqlite"));
257
+ const DatabaseSync = mod.DatabaseSync ?? mod.default?.DatabaseSync;
258
+ if (!DatabaseSync) {
259
+ return null;
260
+ }
261
+ return (path, options) => {
262
+ const db = new DatabaseSync(path, {
263
+ readOnly: options?.readonly ?? false,
264
+ });
265
+ return {
266
+ query(sql) {
267
+ const statement = db.prepare(sql);
268
+ return {
269
+ all(...params) {
270
+ return statement.all(...params);
271
+ },
272
+ };
273
+ },
274
+ close() {
275
+ db.close();
276
+ },
277
+ };
278
+ };
279
+ }
280
+ catch {
281
+ return null;
282
+ }
283
+ }
284
+ /**
285
+ * Open macOS chat.db read-only and return a reader bound to it.
286
+ *
287
+ * Returns `null` — and logs a human-readable reason — in every failure
288
+ * mode so the caller can degrade to send-only operation instead of
289
+ * crashing the runtime:
290
+ *
291
+ * - Not running under Bun (bun:sqlite built-in unavailable)
292
+ * - chat.db does not exist at the given path
293
+ * - chat.db exists but cannot be opened (missing Full Disk Access, etc.)
294
+ */
295
+ export async function openChatDb(dbPath = DEFAULT_CHAT_DB_PATH, options = {}) {
296
+ const diagnosticsLogger = options.diagnosticsLogger ?? logger;
297
+ const openDatabase = await tryLoadSqlite();
298
+ if (!openDatabase) {
299
+ lastChatDbAccessIssues.set(dbPath, {
300
+ code: "sqlite_unavailable",
301
+ path: dbPath,
302
+ reason: "No supported SQLite runtime is available.",
303
+ permissionAction: null,
304
+ });
305
+ if (!loggedSqliteUnavailable) {
306
+ loggedSqliteUnavailable = true;
307
+ diagnosticsLogger.warn("[imessage] no supported SQLite runtime is available — inbound polling is disabled. " +
308
+ "Run the agent under Bun or Node 22+, or disable polling with IMESSAGE_POLL_INTERVAL_MS=0. " +
309
+ "Outbound send via AppleScript still works regardless. Further identical startup checks will log at debug.");
310
+ }
311
+ else {
312
+ diagnosticsLogger.debug("[imessage] SQLite runtime still unavailable; inbound polling remains disabled");
313
+ }
314
+ return null;
315
+ }
316
+ let db;
317
+ try {
318
+ db = openDatabase(dbPath, { readonly: true });
319
+ }
320
+ catch (error) {
321
+ const reason = error instanceof Error ? error.message : String(error);
322
+ lastChatDbAccessIssues.set(dbPath, {
323
+ code: "open_failed",
324
+ path: dbPath,
325
+ reason,
326
+ permissionAction: createFullDiskAccessAction(),
327
+ });
328
+ const failureKey = `${dbPath}\0${reason}`;
329
+ if (!loggedChatDbOpenFailures.has(failureKey)) {
330
+ loggedChatDbOpenFailures.add(failureKey);
331
+ diagnosticsLogger.warn(`[imessage] Failed to open chat.db at ${dbPath}: ${reason}. ` +
332
+ "Ensure the path is correct and the host process has Full Disk Access " +
333
+ "(macOS → System Settings → Privacy & Security → Full Disk Access). " +
334
+ `Open it directly with ${MACOS_FULL_DISK_ACCESS_SETTINGS_URL}. ` +
335
+ "Plugin will continue in send-only mode. Further identical startup failures will log at debug.");
336
+ }
337
+ else {
338
+ diagnosticsLogger.debug(`[imessage] chat.db at ${dbPath} is still unavailable (${reason}); continuing in send-only mode`);
339
+ }
340
+ return null;
341
+ }
342
+ lastChatDbAccessIssues.delete(dbPath);
343
+ // Prepared statement reused on every poll. We join `message` to
344
+ // `handle` (for the sender identity) and to `chat` (for the room
345
+ // identity and display name) via the `chat_message_join` edge table.
346
+ // We also pull `attributedBody` so the reader can recover the text for
347
+ // the ~97% of messages that store their content there instead of in
348
+ // the plain `text` column, and enough status columns to surface
349
+ // reactions, replies, edits, and read receipts to the caller.
350
+ const pollStmt = db.query(`
351
+ SELECT
352
+ m.ROWID AS row_id,
353
+ m.guid AS guid,
354
+ m.text AS text,
355
+ m.attributedBody AS attributed_body,
356
+ m.date AS apple_date,
357
+ m.date_read AS apple_date_read,
358
+ m.date_edited AS apple_date_edited,
359
+ m.date_retracted AS apple_date_retracted,
360
+ m.is_from_me AS is_from_me,
361
+ m.is_read AS is_read,
362
+ m.is_sent AS is_sent,
363
+ m.is_delivered AS is_delivered,
364
+ m.item_type AS item_type,
365
+ m.reply_to_guid AS reply_to_guid,
366
+ m.associated_message_guid AS associated_message_guid,
367
+ m.associated_message_type AS associated_message_type,
368
+ m.associated_message_emoji AS associated_message_emoji,
369
+ m.cache_has_attachments AS cache_has_attachments,
370
+ m.service AS message_service,
371
+ h.id AS handle,
372
+ h.service AS handle_service,
373
+ c.chat_identifier AS chat_identifier,
374
+ c.display_name AS display_name,
375
+ c.style AS chat_style
376
+ FROM message m
377
+ LEFT JOIN handle h ON m.handle_id = h.ROWID
378
+ LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
379
+ LEFT JOIN chat c ON cmj.chat_id = c.ROWID
380
+ WHERE m.ROWID > ?
381
+ ORDER BY m.ROWID ASC
382
+ LIMIT ?
383
+ `);
384
+ // Secondary statement: fetch every attachment row attached to a given
385
+ // message ROWID. Called lazily per-message when `cache_has_attachments`
386
+ // is set, so zero-attachment polls pay nothing.
387
+ const attachmentsStmt = db.query(`
388
+ SELECT
389
+ a.guid AS guid,
390
+ a.transfer_name AS transfer_name,
391
+ a.filename AS filename,
392
+ a.mime_type AS mime_type,
393
+ a.uti AS uti,
394
+ a.total_bytes AS total_bytes,
395
+ a.is_sticker AS is_sticker
396
+ FROM attachment a
397
+ JOIN message_attachment_join maj ON a.ROWID = maj.attachment_id
398
+ WHERE maj.message_id = ?
399
+ `);
400
+ // Separate prepared statement for the cheap "what's the tip?" query.
401
+ // Used once on service start to seed the polling cursor.
402
+ const tipStmt = db.query("SELECT MAX(ROWID) AS max_row_id FROM message");
403
+ const latestOwnMessageStmt = db.query("SELECT CAST(MAX(date) AS TEXT) AS max_apple_date FROM message WHERE is_from_me = 1");
404
+ const recentMessagesStmt = db.query(`
405
+ SELECT
406
+ m.ROWID AS row_id,
407
+ m.guid AS guid,
408
+ m.text AS text,
409
+ m.attributedBody AS attributed_body,
410
+ m.date AS apple_date,
411
+ m.date_read AS apple_date_read,
412
+ m.date_edited AS apple_date_edited,
413
+ m.date_retracted AS apple_date_retracted,
414
+ m.is_from_me AS is_from_me,
415
+ m.is_read AS is_read,
416
+ m.is_sent AS is_sent,
417
+ m.is_delivered AS is_delivered,
418
+ m.item_type AS item_type,
419
+ m.reply_to_guid AS reply_to_guid,
420
+ m.associated_message_guid AS associated_message_guid,
421
+ m.associated_message_type AS associated_message_type,
422
+ m.associated_message_emoji AS associated_message_emoji,
423
+ m.cache_has_attachments AS cache_has_attachments,
424
+ m.service AS message_service,
425
+ h.id AS handle,
426
+ h.service AS handle_service,
427
+ c.chat_identifier AS chat_identifier,
428
+ c.display_name AS display_name,
429
+ c.style AS chat_style
430
+ FROM message m
431
+ LEFT JOIN handle h ON m.handle_id = h.ROWID
432
+ LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
433
+ LEFT JOIN chat c ON cmj.chat_id = c.ROWID
434
+ ORDER BY m.ROWID DESC
435
+ LIMIT ?
436
+ `);
437
+ const recentMessagesByChatStmt = db.query(`
438
+ SELECT
439
+ m.ROWID AS row_id,
440
+ m.guid AS guid,
441
+ m.text AS text,
442
+ m.attributedBody AS attributed_body,
443
+ m.date AS apple_date,
444
+ m.date_read AS apple_date_read,
445
+ m.date_edited AS apple_date_edited,
446
+ m.date_retracted AS apple_date_retracted,
447
+ m.is_from_me AS is_from_me,
448
+ m.is_read AS is_read,
449
+ m.is_sent AS is_sent,
450
+ m.is_delivered AS is_delivered,
451
+ m.item_type AS item_type,
452
+ m.reply_to_guid AS reply_to_guid,
453
+ m.associated_message_guid AS associated_message_guid,
454
+ m.associated_message_type AS associated_message_type,
455
+ m.associated_message_emoji AS associated_message_emoji,
456
+ m.cache_has_attachments AS cache_has_attachments,
457
+ m.service AS message_service,
458
+ h.id AS handle,
459
+ h.service AS handle_service,
460
+ c.chat_identifier AS chat_identifier,
461
+ c.display_name AS display_name,
462
+ c.style AS chat_style
463
+ FROM message m
464
+ LEFT JOIN handle h ON m.handle_id = h.ROWID
465
+ LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
466
+ LEFT JOIN chat c ON cmj.chat_id = c.ROWID
467
+ WHERE c.chat_identifier = ?
468
+ ORDER BY m.ROWID DESC
469
+ LIMIT ?
470
+ `);
471
+ // List-chats statement: every chat joined to handles via
472
+ // chat_handle_join, grouped so each chat returns one row with an
473
+ // aggregated participant list.
474
+ const chatsStmt = db.query(`
475
+ SELECT
476
+ c.ROWID AS row_id,
477
+ c.chat_identifier AS chat_identifier,
478
+ c.display_name AS display_name,
479
+ c.service_name AS service_name,
480
+ c.style AS chat_style,
481
+ c.last_read_message_timestamp AS last_read_apple_date,
482
+ GROUP_CONCAT(h.id, ',') AS participant_handles
483
+ FROM chat c
484
+ LEFT JOIN chat_handle_join chj ON c.ROWID = chj.chat_id
485
+ LEFT JOIN handle h ON chj.handle_id = h.ROWID
486
+ GROUP BY c.ROWID
487
+ ORDER BY c.last_read_message_timestamp DESC
488
+ `);
489
+ let closed = false;
490
+ function materializeMessages(rows) {
491
+ const out = [];
492
+ let undecodable = 0;
493
+ for (const row of rows) {
494
+ // Resolve the visible text: prefer the plain `text` column, fall
495
+ // back to decoding `attributedBody`, then empty string.
496
+ let text = "";
497
+ if (row.text && row.text.length > 0) {
498
+ text = row.text;
499
+ }
500
+ else if (row.attributed_body) {
501
+ const decoded = decodeAttributedBody(row.attributed_body);
502
+ if (decoded != null) {
503
+ text = decoded;
504
+ }
505
+ else {
506
+ undecodable++;
507
+ }
508
+ }
509
+ // Classify the row. Reactions get their own kind + a parsed
510
+ // reaction payload; system messages (group add/remove/rename)
511
+ // surface as `"system"` so the caller can log or ignore.
512
+ const assocType = row.associated_message_type ?? 0;
513
+ let kind = "text";
514
+ let reaction = null;
515
+ if (assocType >= 2000 && assocType < 4000) {
516
+ kind = "reaction";
517
+ reaction = parseReaction(row);
518
+ }
519
+ else if (row.item_type != null && row.item_type !== 0) {
520
+ kind = "system";
521
+ }
522
+ // Attachments — only fetched when the cache bit indicates any.
523
+ let attachments = [];
524
+ if (row.cache_has_attachments === 1) {
525
+ try {
526
+ const attRows = attachmentsStmt.all(row.row_id);
527
+ attachments = attRows.map((a) => ({
528
+ guid: a.guid,
529
+ filename: a.transfer_name ?? a.filename ?? null,
530
+ uti: a.uti,
531
+ mimeType: a.mime_type,
532
+ totalBytes: a.total_bytes,
533
+ isSticker: a.is_sticker === 1,
534
+ }));
535
+ }
536
+ catch (error) {
537
+ logger.debug(`[imessage] attachment query failed for rowid=${row.row_id}: ${error instanceof Error ? error.message : String(error)}`);
538
+ }
539
+ }
540
+ // Service resolution: prefer the message row's own service field,
541
+ // fall back to the handle's service (stable across messages from
542
+ // the same sender), else unknown.
543
+ const service = row.message_service ?? row.handle_service ?? null;
544
+ out.push({
545
+ rowId: row.row_id,
546
+ guid: row.guid,
547
+ text,
548
+ kind,
549
+ handle: row.handle ?? "",
550
+ chatId: row.chat_identifier ?? "",
551
+ chatType: row.chat_style === 43 ? "group" : "direct",
552
+ displayName: row.display_name,
553
+ timestamp: appleDateToJsMs(row.apple_date),
554
+ isFromMe: row.is_from_me === 1,
555
+ service,
556
+ isSent: row.is_sent === 1,
557
+ isDelivered: row.is_delivered === 1,
558
+ isRead: row.is_read === 1,
559
+ dateRead: appleDateToJsMs(row.apple_date_read ?? 0),
560
+ dateEdited: appleDateToJsMs(row.apple_date_edited ?? 0),
561
+ dateRetracted: appleDateToJsMs(row.apple_date_retracted ?? 0),
562
+ replyToGuid: row.reply_to_guid,
563
+ reaction,
564
+ attachments,
565
+ });
566
+ }
567
+ if (undecodable > 0) {
568
+ logger.debug(`[imessage] chat.db poll: ${undecodable} row(s) had attributedBody that could not be decoded; their text is empty`);
569
+ }
570
+ return out;
571
+ }
572
+ return {
573
+ fetchNewMessages(sinceRowId, limit) {
574
+ if (closed)
575
+ return [];
576
+ let rows;
577
+ try {
578
+ rows = pollStmt.all(sinceRowId, limit);
579
+ }
580
+ catch (error) {
581
+ logger.error(`[imessage] chat.db query failed: ${error instanceof Error ? error.message : String(error)}`);
582
+ return [];
583
+ }
584
+ return materializeMessages(rows);
585
+ },
586
+ getLatestRowId() {
587
+ if (closed)
588
+ return 0;
589
+ try {
590
+ const rows = tipStmt.all();
591
+ return rows[0]?.max_row_id ?? 0;
592
+ }
593
+ catch (error) {
594
+ logger.error(`[imessage] chat.db tip query failed: ${error instanceof Error ? error.message : String(error)}`);
595
+ return 0;
596
+ }
597
+ },
598
+ getLatestOwnMessageTimestamp() {
599
+ if (closed)
600
+ return null;
601
+ try {
602
+ const rows = latestOwnMessageStmt.all();
603
+ const appleDate = rows[0]?.max_apple_date ?? null;
604
+ return appleDate === null ? null : appleDateToJsMs(appleDate);
605
+ }
606
+ catch (error) {
607
+ logger.error(`[imessage] chat.db latest own message query failed: ${error instanceof Error ? error.message : String(error)}`);
608
+ return null;
609
+ }
610
+ },
611
+ listMessages(options = {}) {
612
+ if (closed)
613
+ return [];
614
+ const chatId = options.chatId?.trim();
615
+ const requestedLimit = typeof options.limit === "number" && Number.isFinite(options.limit) ? options.limit : 50;
616
+ const limit = Math.max(1, Math.trunc(requestedLimit));
617
+ let rows;
618
+ try {
619
+ rows = chatId
620
+ ? recentMessagesByChatStmt.all(chatId, limit)
621
+ : recentMessagesStmt.all(limit);
622
+ }
623
+ catch (error) {
624
+ logger.error(`[imessage] chat.db listMessages query failed: ${error instanceof Error ? error.message : String(error)}`);
625
+ return [];
626
+ }
627
+ // Queries run DESC for efficiency on "latest N" reads. Reverse
628
+ // back to chronological order so API/UI callers can render in the
629
+ // natural oldest→newest sequence without a second sort.
630
+ return materializeMessages(rows).reverse();
631
+ },
632
+ listChats() {
633
+ if (closed)
634
+ return [];
635
+ try {
636
+ const rows = chatsStmt.all();
637
+ return rows.map((row) => ({
638
+ chatId: row.chat_identifier ?? `chat-${row.row_id}`,
639
+ chatType: row.chat_style === 43 ? "group" : "direct",
640
+ displayName: row.display_name,
641
+ serviceName: row.service_name,
642
+ participants: row.participant_handles
643
+ ? row.participant_handles.split(",").filter(Boolean)
644
+ : [],
645
+ lastReadMessageTimestamp: appleDateToJsMs(row.last_read_apple_date ?? 0),
646
+ }));
647
+ }
648
+ catch (error) {
649
+ logger.error(`[imessage] chat.db listChats query failed: ${error instanceof Error ? error.message : String(error)}`);
650
+ return [];
651
+ }
652
+ },
653
+ close() {
654
+ if (closed)
655
+ return;
656
+ closed = true;
657
+ try {
658
+ db.close();
659
+ }
660
+ catch {
661
+ // Closing a read-only handle on a file we don't own should
662
+ // never throw in practice, but we swallow to stay idempotent.
663
+ }
664
+ },
665
+ };
666
+ }
667
+ //# sourceMappingURL=chatdb-reader.js.map