imsg-mcp 1.3.2 → 1.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.
@@ -4,7 +4,7 @@ import Database from "better-sqlite3";
4
4
  import { createRequire } from "node:module";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { existsSync, readdirSync, mkdirSync } from "node:fs";
7
- import { O as OBJECT_REPLACEMENT_CHAR, p as perf, J as isGroupGuid, K as isGroupChatIdentifier, L as generateThreadSlug, T as Tables, M as AssociatedMessageType, N as macAutoTimestampToDate, P as isReactionType, Q as MAC_EPOCH_OFFSET, R as NANOS_PER_SECOND, S as parseAssociatedMessageGuid$1, U as macTimestampToDate$1 } from "./shutdown-pPAPSnk3.js";
7
+ import { P as OBJECT_REPLACEMENT_CHAR, p as perf, Q as isGroupGuid, R as isGroupChatIdentifier, S as generateThreadSlug, T as Tables, U as AssociatedMessageType, V as macAutoTimestampToDate, W as isReactionType, X as MAC_EPOCH_OFFSET, Y as NANOS_PER_SECOND, Z as macTimestampToDate$1, _ as parseAssociatedMessageGuid$1 } from "./shutdown-Vh0gjb6m.js";
8
8
  import { parseBuffer } from "bplist-parser";
9
9
  function normalizePhoneNumber(phone) {
10
10
  const digits = phone.replace(/\D/g, "");
@@ -633,6 +633,8 @@ function getNativeParser() {
633
633
  }
634
634
  function nativeResultLooksTrustworthy(text) {
635
635
  if (/at_\d+_[0-9A-F-]{8,}/i.test(text)) return false;
636
+ if (/^.?[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(text)) return false;
637
+ if (/^.?at_[0-9A-F][0-9A-F_-]{19,}$/i.test(text)) return false;
636
638
  if (/__kIM[A-Z]/.test(text)) return false;
637
639
  if (/\$class|streamtyped|NSKeyedArchiver/.test(text)) return false;
638
640
  if (/^NS[A-Z][a-z]+/.test(text)) return false;
@@ -656,6 +658,13 @@ const METADATA_PATTERNS = [
656
658
  /^NSNumber$/,
657
659
  /^NSValue$/,
658
660
  /^at_\d+_[0-9A-F-]+$/i,
661
+ // Bare-UUID attribute values (sticker/attachment GUIDs), optionally with the
662
+ // leaked typedstream length byte in front ("$FE7B0D17-…", '$' = 0x24 = 36 =
663
+ // a UUID's length). No human message is exactly a bare UUID.
664
+ /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i,
665
+ /^.[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i,
666
+ // Transfer-GUID with leaked length byte ("Mat_BDA9FB97-…", 'M' = 77).
667
+ /^.at_[0-9A-F][0-9A-F_-]{19,}$/i,
659
668
  /FileTransferGUIDAttributeName/i,
660
669
  /BaseWritingDirectionAttributeName/i,
661
670
  /^\d{2}.*��/
@@ -707,23 +716,30 @@ function extractAttributedBodyText(blob) {
707
716
  if (score > prev) candidates.set(normalized, score);
708
717
  }
709
718
  };
719
+ let structuredTrusted = false;
710
720
  try {
711
721
  if (Date.now() < deadline) {
722
+ const strings = parser.parseAllNSStrings();
723
+ structuredTrusted = strings.some(
724
+ (entry) => normalizeCandidate(entry.content) !== null || /^[\uFFFC\s]+$/.test(entry.content)
725
+ );
712
726
  remember(
713
- parser.parseAllNSStrings().map((entry) => entry.content),
727
+ strings.map((entry) => entry.content),
714
728
  STRUCTURED_BOOST
715
729
  );
716
730
  }
717
731
  } catch {
718
732
  }
719
- try {
733
+ if (!structuredTrusted) {
734
+ try {
735
+ if (Date.now() < deadline) {
736
+ remember(parser.extractReadableText());
737
+ }
738
+ } catch {
739
+ }
720
740
  if (Date.now() < deadline) {
721
- remember(parser.extractReadableText());
741
+ remember(blob.toString("utf8").split(CONTROL_SPLIT_RE));
722
742
  }
723
- } catch {
724
- }
725
- if (Date.now() < deadline) {
726
- remember(blob.toString("utf8").split(CONTROL_SPLIT_RE));
727
743
  }
728
744
  const best = [...candidates.entries()].sort((a, b) => b[1] - a[1])[0];
729
745
  return best?.[0];
@@ -760,6 +776,15 @@ function normalizeText(value) {
760
776
  const normalized = value?.replace(/\s+/g, " ").trim();
761
777
  return normalized && normalized.length >= 4 ? normalized : void 0;
762
778
  }
779
+ function isPlausibleHumanText(s) {
780
+ const wordish = (s.match(/[\p{L}\p{N}\s]/gu) ?? []).length;
781
+ if (wordish / s.length < 0.7 || !new RegExp("\\p{L}{2}", "u").test(s)) return false;
782
+ if (!/\s/.test(s.trim())) {
783
+ const core = s.replace(/^[^\p{L}\p{N}]+/u, "");
784
+ if (!new RegExp("^(\\p{Lu}?\\p{Ll}+|\\p{Lu}+|\\p{N}+)$", "u").test(core)) return false;
785
+ }
786
+ return true;
787
+ }
763
788
  function extractNullPaddedAsciiText(blob) {
764
789
  if (!blob) return void 0;
765
790
  let best = "";
@@ -781,7 +806,8 @@ function extractNullPaddedAsciiText(blob) {
781
806
  flush();
782
807
  }
783
808
  flush();
784
- return normalizeText(best);
809
+ const normalized = normalizeText(best);
810
+ return normalized && isPlausibleHumanText(normalized) ? normalized : void 0;
785
811
  }
786
812
  function extractArchivedAttributedStringText(blob) {
787
813
  if (!blob) return void 0;
@@ -816,7 +842,7 @@ function extractChatSummaryText(blob) {
816
842
  }
817
843
  } catch {
818
844
  }
819
- return extractNullPaddedAsciiText(blob);
845
+ return void 0;
820
846
  }
821
847
  const DEFAULT_DIR = join(homedir(), ".imsg-mcp");
822
848
  const DEFAULT_DB = join(DEFAULT_DIR, "slugs.db");
@@ -1420,6 +1446,7 @@ class IMessageDB {
1420
1446
  m.is_read,
1421
1447
  m.date_read,
1422
1448
  m.is_delivered,
1449
+ m.error,
1423
1450
  m.date_delivered,
1424
1451
  m.associated_message_type,
1425
1452
  m.associated_message_guid,
@@ -1529,13 +1556,73 @@ class IMessageDB {
1529
1556
  const messages = await this.getMessagesForChat(chatIdentifier, 1);
1530
1557
  return messages.length > 0 ? messages[messages.length - 1] : null;
1531
1558
  }
1559
+ /**
1560
+ * The service the conversation ACTUALLY works on, judged by delivery
1561
+ * evidence: the most recent message that either came FROM the other party
1562
+ * (any service they use reaches us) or was sent by us WITHOUT error.
1563
+ * Failed sends are excluded — otherwise one wrong-service attempt (e.g. an
1564
+ * iMessage 'sent' to an SMS-only number, error 22) mints a phantom leg,
1565
+ * flips the thread's apparent service, and every later send repeats the
1566
+ * mistake. Returns null when the conversation has no usable evidence.
1567
+ */
1568
+ getPreferredSendService(chatIdentifier) {
1569
+ const chats = this.resolveChatsForConversation(chatIdentifier);
1570
+ if (chats.length === 0) return null;
1571
+ const placeholders = chats.map(() => "?").join(",");
1572
+ const row = this.raw.prepare(
1573
+ `SELECT m.service
1574
+ FROM ${Tables.MESSAGE} m
1575
+ JOIN ${Tables.CHAT_MESSAGE_JOIN} j ON j.message_id = m.ROWID
1576
+ WHERE j.chat_id IN (${placeholders})
1577
+ AND (m.associated_message_type = 0 OR m.associated_message_type IS NULL)
1578
+ AND (m.is_from_me = 0 OR m.error = 0 OR m.error IS NULL)
1579
+ AND m.service IN ('iMessage', 'SMS')
1580
+ ORDER BY m.date DESC, m.ROWID DESC
1581
+ LIMIT 1`
1582
+ ).get(...chats.map((c) => c.ROWID));
1583
+ if (row?.service === "SMS") return "SMS";
1584
+ if (row?.service === "iMessage") return "iMessage";
1585
+ return null;
1586
+ }
1587
+ /**
1588
+ * Aggregate stats over ALL merged legs of a conversation — one SQL
1589
+ * COUNT/MIN/MAX instead of materializing messages. Reactions and other
1590
+ * associated messages are excluded (normal items only). Used by
1591
+ * init_human to prefill first/last contact + volume.
1592
+ */
1593
+ getChatStats(chatIdentifier) {
1594
+ const chats = this.resolveChatsForConversation(chatIdentifier);
1595
+ if (chats.length === 0) return { count: 0, first: null, last: null };
1596
+ const placeholders = chats.map(() => "?").join(",");
1597
+ const row = this.raw.prepare(
1598
+ // COUNT(DISTINCT m.ROWID): a single message is often joined to more
1599
+ // than one leg of a merged identity (Messages.app links it into both
1600
+ // the iMessage and SMS chat rows). Plain COUNT(*) counted it once per
1601
+ // leg and inflated the humans-file stats (e.g. +727 for one contact).
1602
+ `SELECT COUNT(DISTINCT m.ROWID) as count, MIN(m.date) as first, MAX(m.date) as last
1603
+ FROM ${Tables.MESSAGE} m
1604
+ JOIN ${Tables.CHAT_MESSAGE_JOIN} j ON j.message_id = m.ROWID
1605
+ WHERE j.chat_id IN (${placeholders})
1606
+ AND (m.associated_message_type = 0 OR m.associated_message_type IS NULL)`
1607
+ ).get(...chats.map((c) => c.ROWID));
1608
+ return {
1609
+ count: row.count ?? 0,
1610
+ first: row.first != null ? macTimestampToDate(row.first) : null,
1611
+ last: row.last != null ? macTimestampToDate(row.last) : null
1612
+ };
1613
+ }
1532
1614
  /**
1533
1615
  * Get messages after a specific message ID (for polling new messages).
1534
- * Returns incoming messages only, sorted by date ascending (chronological).
1616
+ * Sorted by date ascending (chronological). By default returns incoming
1617
+ * messages only; `includeSelf` also returns from-me rows (the user's own
1618
+ * interjections from other devices) — callers using it must suppress the
1619
+ * agent's own send echo themselves (see SentEchoRegistry).
1535
1620
  */
1536
- async getMessagesAfter(chatIdentifier, afterMessageId) {
1621
+ async getMessagesAfter(chatIdentifier, afterMessageId, options = {}) {
1537
1622
  const messages = await this.getMessagesForChat(chatIdentifier, 1e3, { afterMessageId });
1538
- const filtered = messages.filter((m) => m.id !== afterMessageId && !m.isFromMe);
1623
+ const filtered = messages.filter(
1624
+ (m) => m.id !== afterMessageId && (options.includeSelf || !m.isFromMe)
1625
+ );
1539
1626
  filtered.sort((a, b) => a.date.getTime() - b.date.getTime());
1540
1627
  return filtered;
1541
1628
  }
@@ -1752,6 +1839,9 @@ class IMessageDB {
1752
1839
  if (result == null) {
1753
1840
  result = this.getPreviousConversationSnippet(last);
1754
1841
  }
1842
+ if (result == null) {
1843
+ result = this.getLastTextfulSnippet(last);
1844
+ }
1755
1845
  if (result == null) {
1756
1846
  result = pickConversationSnippet({
1757
1847
  summaryText: extractChatSummaryText(last.chatProperties) ?? null
@@ -1760,6 +1850,35 @@ class IMessageDB {
1760
1850
  this.cachedSnippets.set(last.lastMessageId, result);
1761
1851
  return result;
1762
1852
  }
1853
+ /**
1854
+ * Most recent message in the chat (any sender) with real extractable text,
1855
+ * looking back from the current last message. Unlike
1856
+ * getPreviousConversationSnippet this does NOT stop at a sender change or a
1857
+ * time gap — it exists for the "last message is a text-less tombstone"
1858
+ * (unsent/retracted) case, where the useful preview is simply the previous
1859
+ * real message.
1860
+ */
1861
+ getLastTextfulSnippet(last) {
1862
+ const rows = this.raw.prepare(`
1863
+ SELECT m.text, m.attributedBody, m.ROWID
1864
+ FROM ${Tables.MESSAGE} m
1865
+ JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON cmj.message_id = m.ROWID
1866
+ WHERE cmj.chat_id = ?
1867
+ AND m.associated_message_type = ${AssociatedMessageType.NORMAL}
1868
+ AND COALESCE(m.item_type, 0) = 0
1869
+ AND (m.date < ? OR (m.date = ? AND m.ROWID < ?))
1870
+ ORDER BY m.date DESC, m.ROWID DESC
1871
+ LIMIT 10
1872
+ `).all(last.chatId, last.lastDate, last.lastDate, last.lastMessageId);
1873
+ for (const row of rows) {
1874
+ const snippet = pickConversationSnippet({
1875
+ rawText: row.text,
1876
+ parsedText: this.extractMessageText(row)
1877
+ });
1878
+ if (snippet && !isMetadataOnlySnippet(snippet)) return snippet;
1879
+ }
1880
+ return null;
1881
+ }
1763
1882
  shouldFallbackToPreviousSnippet(snippet, last) {
1764
1883
  return Boolean(
1765
1884
  last.balloonBundleId?.includes("URLBalloonProvider") && isMetadataOnlySnippet(snippet)
@@ -2230,6 +2349,10 @@ class IMessageDB {
2230
2349
  dateDelivered: macTimestampToDate(ext.date_delivered ?? null),
2231
2350
  isRead: ext.is_read != null ? Boolean(ext.is_read) : true,
2232
2351
  isDelivered: ext.is_delivered != null ? Boolean(ext.is_delivered) : true,
2352
+ // Send failure surface: chat.db sets error != 0 (and is_sent = 0) on
2353
+ // from-me messages that never went out — e.g. an iMessage attempt to an
2354
+ // SMS-only number. Messages.app shows "Not Delivered"; so must we.
2355
+ sendError: raw.is_from_me && ext.error ? ext.error : void 0,
2233
2356
  chatId,
2234
2357
  service: this.detectServiceForMessage(ext),
2235
2358
  isReaction,
@@ -2312,6 +2435,7 @@ class IMessageDB {
2312
2435
  m.is_read,
2313
2436
  m.date_read,
2314
2437
  m.is_delivered,
2438
+ m.error,
2315
2439
  m.date_delivered,
2316
2440
  h.id as handle_id,
2317
2441
  h.service as handle_service,
@@ -2348,7 +2472,8 @@ class IMessageDB {
2348
2472
  const stmt = this.raw.prepare(`
2349
2473
  SELECT
2350
2474
  m.ROWID as _rowid,
2351
- m.is_read, m.date_read, m.is_delivered, m.date_delivered,
2475
+ m.is_read, m.date_read, m.is_delivered,
2476
+ m.error, m.date_delivered,
2352
2477
  h.id as handle_id, h.service as handle_service,
2353
2478
  m.associated_message_type, m.associated_message_guid,
2354
2479
  m.associated_message_emoji, m.thread_originator_guid,
@@ -2376,12 +2501,13 @@ class IMessageDB {
2376
2501
  * chat_analytics tool — bounded by date, not by per-chat limit. Reactions
2377
2502
  * are included (tapback analytics need them); hidden system items dropped.
2378
2503
  */
2379
- async getMessagesInWindow(cutoffMs, capPerWindow = 2e5) {
2504
+ async getMessagesInWindow(cutoffMs, capPerWindow = 8e4) {
2380
2505
  const span = perf("getMessagesInWindow");
2381
2506
  const cutoffNanos = Math.floor((cutoffMs / 1e3 - MAC_EPOCH_OFFSET) * NANOS_PER_SECOND);
2382
2507
  const sql = `
2383
2508
  SELECT m.ROWID, m.guid, m.text, m.attributedBody, m.date, m.date_read, m.date_delivered,
2384
- m.is_read, m.is_delivered, m.is_from_me, h.id as handle_id,
2509
+ m.is_read, m.is_delivered,
2510
+ m.error, m.is_from_me, h.id as handle_id,
2385
2511
  h.service as handle_service,
2386
2512
  m.cache_has_attachments, m.associated_message_type,
2387
2513
  m.associated_message_guid, m.associated_message_emoji,
@@ -2394,10 +2520,10 @@ class IMessageDB {
2394
2520
  LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
2395
2521
  LEFT JOIN ${Tables.CHAT} c ON cmj.chat_id = c.ROWID
2396
2522
  WHERE m.date >= ?
2397
- ORDER BY m.date ASC
2523
+ ORDER BY m.date DESC
2398
2524
  LIMIT ?
2399
2525
  `;
2400
- const rows = this.raw.prepare(sql).all(cutoffNanos, capPerWindow);
2526
+ const rows = this.raw.prepare(sql).all(cutoffNanos, capPerWindow).reverse();
2401
2527
  const out = [];
2402
2528
  for (const r of rows) {
2403
2529
  if (isHiddenSystemItem(r.item_type)) continue;
@@ -2517,6 +2643,7 @@ class IMessageDB {
2517
2643
  fetchAttachments(messageRowId) {
2518
2644
  const stmt = this.raw.prepare(`
2519
2645
  SELECT
2646
+ a.ROWID as row_id,
2520
2647
  a.filename,
2521
2648
  a.mime_type,
2522
2649
  a.transfer_name,
@@ -2527,6 +2654,7 @@ class IMessageDB {
2527
2654
  `);
2528
2655
  const rows = stmt.all(messageRowId);
2529
2656
  return rows.map((r) => ({
2657
+ rowId: r.row_id,
2530
2658
  filename: r.filename || "",
2531
2659
  mimeType: r.mime_type,
2532
2660
  transferName: r.transfer_name,
@@ -2662,4 +2790,4 @@ export {
2662
2790
  imessageDb as i,
2663
2791
  normalizedPhoneVariants as n
2664
2792
  };
2665
- //# sourceMappingURL=imessage-db-Cz8MIIpG.js.map
2793
+ //# sourceMappingURL=imessage-db-BrHJPOq7.js.map