imsg-mcp 1.0.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/CHANGELOG.md +6 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/cli.js +611 -0
- package/dist/cli.js.map +1 -0
- package/dist/dateParse-DJXMfq3a.js +74 -0
- package/dist/dateParse-DJXMfq3a.js.map +1 -0
- package/dist/exportFormats-CWWiy5uz.js +108 -0
- package/dist/exportFormats-CWWiy5uz.js.map +1 -0
- package/dist/exportStream-BaheQ6M4.js +130 -0
- package/dist/exportStream-BaheQ6M4.js.map +1 -0
- package/dist/imessage-db-BVDtx0Sn.js +2263 -0
- package/dist/imessage-db-BVDtx0Sn.js.map +1 -0
- package/dist/index.js +3503 -0
- package/dist/index.js.map +1 -0
- package/dist/meta-D3NoTAjA.js +7 -0
- package/dist/meta-D3NoTAjA.js.map +1 -0
- package/dist/setup-DMckHRnI.js +103 -0
- package/dist/setup-DMckHRnI.js.map +1 -0
- package/dist/shutdown-B9ClCyco.js +775 -0
- package/dist/shutdown-B9ClCyco.js.map +1 -0
- package/dist/tui-config-Crn6TZPg.js +122 -0
- package/dist/tui-config-Crn6TZPg.js.map +1 -0
- package/dist/tui.js +2706 -0
- package/dist/tui.js.map +1 -0
- package/dist/watchdog-V3lgEhMp.js +207 -0
- package/dist/watchdog-V3lgEhMp.js.map +1 -0
- package/native/imsg-native.darwin-arm64.node +0 -0
- package/native/index.d.ts +86 -0
- package/native/index.js +582 -0
- package/package.json +135 -0
- package/skills/imsg-mcp/SKILL.md +168 -0
|
@@ -0,0 +1,2263 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { existsSync, readdirSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { O as OBJECT_REPLACEMENT_CHAR, p as perf, T as Tables, G as AssociatedMessageType, H as macTimestampToDate$1, I as isReactionType, M as MAC_EPOCH_OFFSET, N as NANOS_PER_SECOND, J as parseAssociatedMessageGuid$1 } from "./shutdown-B9ClCyco.js";
|
|
8
|
+
import { parseBuffer } from "bplist-parser";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
const __dirname$1 = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
let _native;
|
|
12
|
+
function tryLoadNative() {
|
|
13
|
+
if (_native !== void 0) return _native;
|
|
14
|
+
if (process.env.IMSG_DISABLE_NATIVE === "1") {
|
|
15
|
+
_native = null;
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const require2 = createRequire(import.meta.url);
|
|
20
|
+
const nativePath = join(__dirname$1, "..", "native", "index.js");
|
|
21
|
+
_native = require2(nativePath);
|
|
22
|
+
return _native;
|
|
23
|
+
} catch {
|
|
24
|
+
_native = null;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function hasNativeModule() {
|
|
29
|
+
return tryLoadNative() !== null;
|
|
30
|
+
}
|
|
31
|
+
class BufferReader {
|
|
32
|
+
buffer;
|
|
33
|
+
offset;
|
|
34
|
+
constructor(buffer, initialOffset = 0) {
|
|
35
|
+
this.buffer = buffer;
|
|
36
|
+
this.offset = initialOffset;
|
|
37
|
+
}
|
|
38
|
+
get position() {
|
|
39
|
+
return this.offset;
|
|
40
|
+
}
|
|
41
|
+
get length() {
|
|
42
|
+
return this.buffer.length;
|
|
43
|
+
}
|
|
44
|
+
get remaining() {
|
|
45
|
+
return this.buffer.length - this.offset;
|
|
46
|
+
}
|
|
47
|
+
seek(position) {
|
|
48
|
+
if (position < 0 || position > this.buffer.length) {
|
|
49
|
+
throw new Error(`Invalid seek position: ${position}`);
|
|
50
|
+
}
|
|
51
|
+
this.offset = position;
|
|
52
|
+
}
|
|
53
|
+
skip(bytes) {
|
|
54
|
+
this.offset += bytes;
|
|
55
|
+
}
|
|
56
|
+
readUInt8() {
|
|
57
|
+
const value = this.buffer.readUInt8(this.offset);
|
|
58
|
+
this.offset += 1;
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
readUInt16LE() {
|
|
62
|
+
const value = this.buffer.readUInt16LE(this.offset);
|
|
63
|
+
this.offset += 2;
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
readBytes(length) {
|
|
67
|
+
if (this.offset + length > this.buffer.length) {
|
|
68
|
+
throw new Error("Attempt to read beyond buffer length");
|
|
69
|
+
}
|
|
70
|
+
const bytes = this.buffer.subarray(this.offset, this.offset + length);
|
|
71
|
+
this.offset += length;
|
|
72
|
+
return bytes;
|
|
73
|
+
}
|
|
74
|
+
readString(length, encoding = "utf8") {
|
|
75
|
+
const bytes = this.readBytes(length);
|
|
76
|
+
return bytes.toString(encoding);
|
|
77
|
+
}
|
|
78
|
+
findPattern(pattern) {
|
|
79
|
+
const searchBuffer = typeof pattern === "string" ? Buffer.from(pattern) : pattern;
|
|
80
|
+
const index = this.buffer.indexOf(searchBuffer, this.offset);
|
|
81
|
+
return index >= 0 ? index : -1;
|
|
82
|
+
}
|
|
83
|
+
peekByte() {
|
|
84
|
+
if (this.offset >= this.buffer.length) return null;
|
|
85
|
+
return this.buffer[this.offset];
|
|
86
|
+
}
|
|
87
|
+
peekBytes(length) {
|
|
88
|
+
if (this.offset + length > this.buffer.length) return null;
|
|
89
|
+
return this.buffer.subarray(this.offset, this.offset + length);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const PREAMBLE_BYTE_2_VARIANTS = /* @__PURE__ */ new Set([148, 149]);
|
|
93
|
+
const PREAMBLE_LEN = 5;
|
|
94
|
+
function matchesNSStringPreamble(bytes) {
|
|
95
|
+
if (!bytes || bytes.length < PREAMBLE_LEN) return false;
|
|
96
|
+
return bytes[0] === 1 && PREAMBLE_BYTE_2_VARIANTS.has(bytes[1]) && bytes[2] === 132 && bytes[3] === 1 && bytes[4] === 43;
|
|
97
|
+
}
|
|
98
|
+
const METADATA_KEYWORDS = [
|
|
99
|
+
"streamtyped",
|
|
100
|
+
"NSMutableAttributedString",
|
|
101
|
+
"NSAttributedString",
|
|
102
|
+
"NSObject",
|
|
103
|
+
"NSMutableString",
|
|
104
|
+
"NSString",
|
|
105
|
+
"NSDictionary",
|
|
106
|
+
"NSNumber",
|
|
107
|
+
"NSValue",
|
|
108
|
+
"NSFont",
|
|
109
|
+
"NSParagraphStyle",
|
|
110
|
+
"__kIM",
|
|
111
|
+
"NSData",
|
|
112
|
+
"bplist",
|
|
113
|
+
"NSKeyedArchiver",
|
|
114
|
+
"NS.rangeval",
|
|
115
|
+
"Z$classname",
|
|
116
|
+
"$class",
|
|
117
|
+
"$classname"
|
|
118
|
+
];
|
|
119
|
+
class TypedStreamParser {
|
|
120
|
+
reader;
|
|
121
|
+
headerParsed = false;
|
|
122
|
+
constructor(buffer) {
|
|
123
|
+
this.reader = new BufferReader(buffer);
|
|
124
|
+
}
|
|
125
|
+
/** Parse all NSString objects found in the buffer. */
|
|
126
|
+
parseAllNSStrings() {
|
|
127
|
+
const strings = [];
|
|
128
|
+
this.parseHeader();
|
|
129
|
+
const maxIter = this.reader.length;
|
|
130
|
+
let iter = 0;
|
|
131
|
+
while (this.reader.remaining > 0 && iter++ < maxIter) {
|
|
132
|
+
const posBefore = this.reader.position;
|
|
133
|
+
const found = this.parseNSString();
|
|
134
|
+
if (found) {
|
|
135
|
+
strings.push(found);
|
|
136
|
+
} else if (this.reader.remaining > 0) {
|
|
137
|
+
if (this.reader.position <= posBefore) {
|
|
138
|
+
this.reader.skip(1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (this.reader.position <= posBefore && this.reader.remaining > 0) {
|
|
142
|
+
this.reader.skip(1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return strings;
|
|
146
|
+
}
|
|
147
|
+
/** Fallback: extract readable text segments by scanning for printable byte runs. */
|
|
148
|
+
extractReadableText() {
|
|
149
|
+
this.reader.seek(0);
|
|
150
|
+
const texts = [];
|
|
151
|
+
let current = "";
|
|
152
|
+
let inText = false;
|
|
153
|
+
const maxIter = this.reader.length;
|
|
154
|
+
let iter = 0;
|
|
155
|
+
while (this.reader.remaining > 0 && iter++ < maxIter) {
|
|
156
|
+
const posBefore = this.reader.position;
|
|
157
|
+
const byte = this.reader.readUInt8();
|
|
158
|
+
if (byte >= 32 && byte <= 126 || byte === 9 || byte === 10 || byte === 13) {
|
|
159
|
+
current += String.fromCharCode(byte);
|
|
160
|
+
inText = true;
|
|
161
|
+
} else if (byte >= 192 && byte <= 247) {
|
|
162
|
+
const extraBytes = byte < 224 ? 1 : byte < 240 ? 2 : 3;
|
|
163
|
+
if (this.reader.remaining >= extraBytes) {
|
|
164
|
+
const bytes = [byte];
|
|
165
|
+
let valid = true;
|
|
166
|
+
for (let i = 0; i < extraBytes; i++) {
|
|
167
|
+
const cont = this.reader.readUInt8();
|
|
168
|
+
if ((cont & 192) !== 128) {
|
|
169
|
+
valid = false;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
bytes.push(cont);
|
|
173
|
+
}
|
|
174
|
+
if (valid) {
|
|
175
|
+
try {
|
|
176
|
+
current += Buffer.from(bytes).toString("utf8");
|
|
177
|
+
inText = true;
|
|
178
|
+
} catch {
|
|
179
|
+
this.flushSegment(current, inText, texts);
|
|
180
|
+
current = "";
|
|
181
|
+
inText = false;
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
this.flushSegment(current, inText, texts);
|
|
185
|
+
current = "";
|
|
186
|
+
inText = false;
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
this.flushSegment(current, inText, texts);
|
|
190
|
+
current = "";
|
|
191
|
+
inText = false;
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
this.flushSegment(current, inText, texts);
|
|
195
|
+
current = "";
|
|
196
|
+
inText = false;
|
|
197
|
+
}
|
|
198
|
+
if (this.reader.position <= posBefore) {
|
|
199
|
+
if (this.reader.remaining > 0) this.reader.skip(1);
|
|
200
|
+
else break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
this.flushSegment(current, true, texts);
|
|
204
|
+
return texts;
|
|
205
|
+
}
|
|
206
|
+
// ── Private ──────────────────────────────────────────────────────────
|
|
207
|
+
parseHeader() {
|
|
208
|
+
if (this.headerParsed) return;
|
|
209
|
+
const magic = this.reader.peekBytes(11);
|
|
210
|
+
if (!magic || magic.toString("ascii") !== "streamtyped") return;
|
|
211
|
+
this.reader.skip(11);
|
|
212
|
+
const maxSkip = Math.min(this.reader.remaining, 256);
|
|
213
|
+
let skipped = 0;
|
|
214
|
+
while (this.reader.remaining > 0 && skipped++ < maxSkip) {
|
|
215
|
+
const next4 = this.reader.peekBytes(4);
|
|
216
|
+
if (next4 && /^[A-Z]/.test(next4.toString("ascii"))) break;
|
|
217
|
+
this.reader.skip(1);
|
|
218
|
+
}
|
|
219
|
+
this.headerParsed = true;
|
|
220
|
+
}
|
|
221
|
+
parseNSString() {
|
|
222
|
+
const pos = this.reader.findPattern("NSString");
|
|
223
|
+
if (pos === -1) {
|
|
224
|
+
this.reader.seek(this.reader.length);
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
this.reader.seek(pos + 8);
|
|
228
|
+
const preamble = this.reader.peekBytes(PREAMBLE_LEN);
|
|
229
|
+
if (matchesNSStringPreamble(preamble)) {
|
|
230
|
+
this.reader.skip(PREAMBLE_LEN);
|
|
231
|
+
}
|
|
232
|
+
if (this.reader.remaining < 1) return null;
|
|
233
|
+
const lengthByte = this.reader.readUInt8();
|
|
234
|
+
let length;
|
|
235
|
+
if (lengthByte === 129) {
|
|
236
|
+
if (this.reader.remaining < 2) return null;
|
|
237
|
+
length = this.reader.readUInt16LE();
|
|
238
|
+
} else {
|
|
239
|
+
length = lengthByte;
|
|
240
|
+
}
|
|
241
|
+
if (length === 0 || length > this.reader.remaining) return null;
|
|
242
|
+
return {
|
|
243
|
+
className: "NSString",
|
|
244
|
+
content: this.reader.readString(length, "utf8"),
|
|
245
|
+
encoding: "utf8"
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
flushSegment(text, inText, out) {
|
|
249
|
+
if (!inText || text.length <= 3) return;
|
|
250
|
+
const cleaned = this.cleanText(text);
|
|
251
|
+
if (cleaned.length > 3) out.push(cleaned);
|
|
252
|
+
}
|
|
253
|
+
cleanText(text) {
|
|
254
|
+
let cleaned = text;
|
|
255
|
+
for (const kw of METADATA_KEYWORDS) {
|
|
256
|
+
cleaned = cleaned.replaceAll(kw, "");
|
|
257
|
+
}
|
|
258
|
+
return cleaned.replace(/\{[^}]*\}/g, "").replace(/\[[^\]]*\]/g, "").replace(/[\x00-\x1f]/g, "").replace(/\s+/g, " ").trim();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
let _nativeParseAttributedBody;
|
|
262
|
+
function getNativeParser() {
|
|
263
|
+
if (_nativeParseAttributedBody !== void 0) return _nativeParseAttributedBody;
|
|
264
|
+
const native = tryLoadNative();
|
|
265
|
+
_nativeParseAttributedBody = native ? native.parseAttributedBody.bind(native) : null;
|
|
266
|
+
return _nativeParseAttributedBody;
|
|
267
|
+
}
|
|
268
|
+
function nativeResultLooksTrustworthy(text) {
|
|
269
|
+
if (/at_\d+_[0-9A-F-]{8,}/i.test(text)) return false;
|
|
270
|
+
if (/__kIM[A-Z]/.test(text)) return false;
|
|
271
|
+
if (/\$class|streamtyped|NSKeyedArchiver/.test(text)) return false;
|
|
272
|
+
if (/^NS[A-Z][a-z]+/.test(text)) return false;
|
|
273
|
+
if (/^([A-Z])\1[a-z]/.test(text)) return false;
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
const METADATA_PATTERNS = [
|
|
277
|
+
/^streamtyped$/i,
|
|
278
|
+
/^NS[A-Z]/,
|
|
279
|
+
/^[^A-Za-z0-9]*__kIM/,
|
|
280
|
+
/^MessagePartAttributeName$/i,
|
|
281
|
+
/^DataDetectedAttributeName$/i,
|
|
282
|
+
/^CalendarEventAttributeName$/i,
|
|
283
|
+
/^X\$version/,
|
|
284
|
+
/^WversionYdd-result/,
|
|
285
|
+
/^bplist/,
|
|
286
|
+
/^\$class/,
|
|
287
|
+
/^RMSV\$class/,
|
|
288
|
+
/^NSData$/,
|
|
289
|
+
/^NSDictionary$/,
|
|
290
|
+
/^NSNumber$/,
|
|
291
|
+
/^NSValue$/,
|
|
292
|
+
/^at_\d+_[0-9A-F-]+$/i,
|
|
293
|
+
/FileTransferGUIDAttributeName/i,
|
|
294
|
+
/BaseWritingDirectionAttributeName/i,
|
|
295
|
+
/^\d{2}.*��/
|
|
296
|
+
];
|
|
297
|
+
const CONTROL_OR_DEL_RE = /[\x00-\x1F\x7F]/g;
|
|
298
|
+
const CONTROL_SPLIT_RE = /[\x00-\x1F\x7F]+/g;
|
|
299
|
+
const PARSE_TIMEOUT_MS = 200;
|
|
300
|
+
function normalizeCandidate(text) {
|
|
301
|
+
const normalized = text.replace(/^[+;:"'()&\s]+/, "").replace(/^[^A-Za-z\d\s](?=[A-Z])/, "").replace(/^[a-z\d](?=[A-Z])/, "").replace(/^[A-Z](?=[A-Z][a-z])/, "").replace(CONTROL_OR_DEL_RE, " ").replace(/\uFFFD+/g, "").replace(/\s+/g, " ").trim();
|
|
302
|
+
if (!normalized || normalized === "�" || normalized === "") return null;
|
|
303
|
+
if (normalized.length < 2 && !/[\u{1F300}-\u{1FAFF}]/u.test(normalized)) return null;
|
|
304
|
+
if (/^[A-Za-z0-9]{1,2}$/.test(normalized)) return null;
|
|
305
|
+
if (METADATA_PATTERNS.some((pattern) => pattern.test(normalized))) return null;
|
|
306
|
+
return normalized;
|
|
307
|
+
}
|
|
308
|
+
function scoreCandidate(text) {
|
|
309
|
+
let score = 0;
|
|
310
|
+
if (/[A-Za-z]/.test(text)) score += 50;
|
|
311
|
+
if (/\d/.test(text)) score += 10;
|
|
312
|
+
if (/\s/.test(text)) score += 30;
|
|
313
|
+
if (/[.!?]/.test(text)) score += 15;
|
|
314
|
+
if (/[\u{1F300}-\u{1FAFF}]/u.test(text)) score += 100;
|
|
315
|
+
if (/^[A-Za-z0-9]{1,3}$/.test(text)) score -= 100;
|
|
316
|
+
score += Math.min(text.length, 200);
|
|
317
|
+
return score;
|
|
318
|
+
}
|
|
319
|
+
const STRUCTURED_BOOST = 500;
|
|
320
|
+
function extractAttributedBodyText(blob) {
|
|
321
|
+
if (!blob) return void 0;
|
|
322
|
+
const native = getNativeParser();
|
|
323
|
+
if (native) {
|
|
324
|
+
try {
|
|
325
|
+
const result = native(blob);
|
|
326
|
+
if (result && result.length > 1 && nativeResultLooksTrustworthy(result)) {
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const parser = new TypedStreamParser(blob);
|
|
333
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
334
|
+
const deadline = Date.now() + PARSE_TIMEOUT_MS;
|
|
335
|
+
const remember = (values, boost = 0) => {
|
|
336
|
+
for (const value of values) {
|
|
337
|
+
const normalized = normalizeCandidate(value);
|
|
338
|
+
if (!normalized) continue;
|
|
339
|
+
const score = scoreCandidate(normalized) + boost;
|
|
340
|
+
const prev = candidates.get(normalized) ?? -Infinity;
|
|
341
|
+
if (score > prev) candidates.set(normalized, score);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
try {
|
|
345
|
+
if (Date.now() < deadline) {
|
|
346
|
+
remember(
|
|
347
|
+
parser.parseAllNSStrings().map((entry) => entry.content),
|
|
348
|
+
STRUCTURED_BOOST
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
if (Date.now() < deadline) {
|
|
355
|
+
remember(parser.extractReadableText());
|
|
356
|
+
}
|
|
357
|
+
} catch {
|
|
358
|
+
}
|
|
359
|
+
if (Date.now() < deadline) {
|
|
360
|
+
remember(blob.toString("utf8").split(CONTROL_SPLIT_RE));
|
|
361
|
+
}
|
|
362
|
+
const best = [...candidates.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
363
|
+
return best?.[0];
|
|
364
|
+
}
|
|
365
|
+
function normalizePhoneNumber(phone) {
|
|
366
|
+
const digits = phone.replace(/\D/g, "");
|
|
367
|
+
if (digits.length === 11 && digits.startsWith("1")) {
|
|
368
|
+
return digits.slice(1);
|
|
369
|
+
}
|
|
370
|
+
return digits;
|
|
371
|
+
}
|
|
372
|
+
function normalizedPhoneVariants(phone) {
|
|
373
|
+
const normalized = normalizePhoneNumber(phone);
|
|
374
|
+
const variants = /* @__PURE__ */ new Set([normalized]);
|
|
375
|
+
if (normalized.length === 11 && normalized.startsWith("61")) {
|
|
376
|
+
const localMobile = normalized.slice(2);
|
|
377
|
+
variants.add(localMobile);
|
|
378
|
+
if (localMobile.length === 9 && localMobile.startsWith("4")) {
|
|
379
|
+
variants.add(`0${localMobile}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (normalized.length === 10 && normalized.startsWith("04")) {
|
|
383
|
+
const mobileDigits = normalized.slice(1);
|
|
384
|
+
variants.add(mobileDigits);
|
|
385
|
+
variants.add(`61${mobileDigits}`);
|
|
386
|
+
}
|
|
387
|
+
if (normalized.length === 10) {
|
|
388
|
+
variants.add(`1${normalized}`);
|
|
389
|
+
}
|
|
390
|
+
if (normalized.length === 9 && normalized.startsWith("4")) {
|
|
391
|
+
variants.add(`61${normalized}`);
|
|
392
|
+
variants.add(`0${normalized}`);
|
|
393
|
+
}
|
|
394
|
+
return [...variants];
|
|
395
|
+
}
|
|
396
|
+
function normalizeEmail(email) {
|
|
397
|
+
return email.toLowerCase().trim();
|
|
398
|
+
}
|
|
399
|
+
const ADDRESS_BOOK_DIR = join(homedir(), "Library", "Application Support", "AddressBook");
|
|
400
|
+
const MAIN_DB_NAME = "AddressBook-v22.abcddb";
|
|
401
|
+
function discoverContactDbPaths(customPaths) {
|
|
402
|
+
if (customPaths) {
|
|
403
|
+
const list = Array.isArray(customPaths) ? customPaths : [customPaths];
|
|
404
|
+
return list.filter((p) => existsSync(p));
|
|
405
|
+
}
|
|
406
|
+
const paths = [];
|
|
407
|
+
const mainDb = join(ADDRESS_BOOK_DIR, MAIN_DB_NAME);
|
|
408
|
+
if (existsSync(mainDb)) paths.push(mainDb);
|
|
409
|
+
const sourcesDir = join(ADDRESS_BOOK_DIR, "Sources");
|
|
410
|
+
if (existsSync(sourcesDir)) {
|
|
411
|
+
try {
|
|
412
|
+
const subdirs = readdirSync(sourcesDir, { withFileTypes: true });
|
|
413
|
+
for (const d of subdirs) {
|
|
414
|
+
if (!d.isDirectory()) continue;
|
|
415
|
+
const sourceDb = join(sourcesDir, d.name, MAIN_DB_NAME);
|
|
416
|
+
if (existsSync(sourceDb)) paths.push(sourceDb);
|
|
417
|
+
}
|
|
418
|
+
} catch {
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return paths;
|
|
422
|
+
}
|
|
423
|
+
class ContactsDB {
|
|
424
|
+
dbPaths = [];
|
|
425
|
+
databases = [];
|
|
426
|
+
phoneMap = /* @__PURE__ */ new Map();
|
|
427
|
+
emailMap = /* @__PURE__ */ new Map();
|
|
428
|
+
contactCache = /* @__PURE__ */ new Map();
|
|
429
|
+
initialized = false;
|
|
430
|
+
/** Next id when loading from multiple DBs (avoids Z_PK collisions across sources). */
|
|
431
|
+
nextContactId = 1;
|
|
432
|
+
constructor(dbPaths) {
|
|
433
|
+
this.dbPaths = discoverContactDbPaths(dbPaths);
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Initialize the contact lookup maps from all discovered DBs (local + iCloud/sources).
|
|
437
|
+
*/
|
|
438
|
+
initialize() {
|
|
439
|
+
if (this.initialized) return;
|
|
440
|
+
for (const path of this.dbPaths) {
|
|
441
|
+
try {
|
|
442
|
+
const db = new Database(path, { readonly: true });
|
|
443
|
+
this.databases.push(db);
|
|
444
|
+
this.loadContactsFromDb(db);
|
|
445
|
+
} catch (err) {
|
|
446
|
+
console.warn(`ContactsDB: could not load ${path}:`, err);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
this.initialized = true;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Load contacts from a single Address Book SQLite DB into the shared maps.
|
|
453
|
+
*/
|
|
454
|
+
loadContactsFromDb(db) {
|
|
455
|
+
const contacts = db.prepare(`
|
|
456
|
+
SELECT
|
|
457
|
+
Z_PK as localId,
|
|
458
|
+
ZFIRSTNAME as firstName,
|
|
459
|
+
ZLASTNAME as lastName,
|
|
460
|
+
ZMIDDLENAME as middleName,
|
|
461
|
+
ZNICKNAME as nickname,
|
|
462
|
+
ZORGANIZATION as organization
|
|
463
|
+
FROM ZABCDRECORD
|
|
464
|
+
WHERE ZFIRSTNAME IS NOT NULL OR ZLASTNAME IS NOT NULL OR ZORGANIZATION IS NOT NULL
|
|
465
|
+
`).all();
|
|
466
|
+
for (const row of contacts) {
|
|
467
|
+
const globalId = this.nextContactId++;
|
|
468
|
+
const contact = {
|
|
469
|
+
id: globalId,
|
|
470
|
+
firstName: row.firstName,
|
|
471
|
+
lastName: row.lastName,
|
|
472
|
+
middleName: row.middleName,
|
|
473
|
+
nickname: row.nickname,
|
|
474
|
+
organization: row.organization,
|
|
475
|
+
displayName: this.buildDisplayName(row),
|
|
476
|
+
phoneNumbers: [],
|
|
477
|
+
emails: []
|
|
478
|
+
};
|
|
479
|
+
const localId = row.localId;
|
|
480
|
+
const phones = db.prepare(`
|
|
481
|
+
SELECT ZFULLNUMBER as number, ZLABEL as label
|
|
482
|
+
FROM ZABCDPHONENUMBER
|
|
483
|
+
WHERE ZOWNER = ? OR Z22_OWNER = ?
|
|
484
|
+
`).all(localId, localId);
|
|
485
|
+
for (const phone of phones) {
|
|
486
|
+
if (phone.number) {
|
|
487
|
+
contact.phoneNumbers.push(phone.number);
|
|
488
|
+
const lookup = {
|
|
489
|
+
contactId: contact.id,
|
|
490
|
+
displayName: contact.displayName,
|
|
491
|
+
label: phone.label
|
|
492
|
+
};
|
|
493
|
+
for (const variant of normalizedPhoneVariants(phone.number)) {
|
|
494
|
+
this.phoneMap.set(variant, lookup);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const emails = db.prepare(`
|
|
499
|
+
SELECT ZADDRESS as email, ZLABEL as label
|
|
500
|
+
FROM ZABCDEMAILADDRESS
|
|
501
|
+
WHERE ZOWNER = ? OR Z22_OWNER = ?
|
|
502
|
+
`).all(localId, localId);
|
|
503
|
+
for (const email of emails) {
|
|
504
|
+
if (email.email) {
|
|
505
|
+
contact.emails.push(email.email);
|
|
506
|
+
const normalized = normalizeEmail(email.email);
|
|
507
|
+
this.emailMap.set(normalized, {
|
|
508
|
+
contactId: contact.id,
|
|
509
|
+
displayName: contact.displayName,
|
|
510
|
+
label: email.label
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
this.contactCache.set(contact.id, contact);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Look up a contact by phone number or email.
|
|
519
|
+
*/
|
|
520
|
+
lookupContact(handle) {
|
|
521
|
+
if (!this.initialized) {
|
|
522
|
+
this.initialize();
|
|
523
|
+
}
|
|
524
|
+
if (/[\d+\-()\s]/.test(handle)) {
|
|
525
|
+
for (const variant of normalizedPhoneVariants(handle)) {
|
|
526
|
+
const contact = this.phoneMap.get(variant);
|
|
527
|
+
if (contact) {
|
|
528
|
+
return contact;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (handle.includes("@")) {
|
|
533
|
+
const normalized = normalizeEmail(handle);
|
|
534
|
+
const contact = this.emailMap.get(normalized);
|
|
535
|
+
if (contact) {
|
|
536
|
+
return contact;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Look up a contact by phone number or email
|
|
543
|
+
* Returns display name if found, or the original handle if not
|
|
544
|
+
*/
|
|
545
|
+
lookupHandle(handle) {
|
|
546
|
+
return this.lookupContact(handle)?.displayName ?? handle;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Get full contact details by ID
|
|
550
|
+
*/
|
|
551
|
+
getContact(id) {
|
|
552
|
+
if (!this.initialized) {
|
|
553
|
+
this.initialize();
|
|
554
|
+
}
|
|
555
|
+
return this.contactCache.get(id) || null;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Search contacts by name
|
|
559
|
+
*/
|
|
560
|
+
searchContacts(query) {
|
|
561
|
+
if (!this.initialized) {
|
|
562
|
+
this.initialize();
|
|
563
|
+
}
|
|
564
|
+
const lowerQuery = query.toLowerCase();
|
|
565
|
+
return Array.from(this.contactCache.values()).filter(
|
|
566
|
+
(c) => c.displayName.toLowerCase().includes(lowerQuery) || c.phoneNumbers.some((p) => p.includes(query)) || c.emails.some((e) => e.toLowerCase().includes(lowerQuery))
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Build display name from contact fields
|
|
571
|
+
*/
|
|
572
|
+
buildDisplayName(row) {
|
|
573
|
+
if (row.nickname) return row.nickname;
|
|
574
|
+
const parts = [];
|
|
575
|
+
if (row.firstName) parts.push(row.firstName);
|
|
576
|
+
if (row.lastName) parts.push(row.lastName);
|
|
577
|
+
if (parts.length > 0) {
|
|
578
|
+
return parts.join(" ");
|
|
579
|
+
}
|
|
580
|
+
if (row.organization) return row.organization;
|
|
581
|
+
return "Unknown Contact";
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Close all database connections
|
|
585
|
+
*/
|
|
586
|
+
close() {
|
|
587
|
+
for (const db of this.databases) {
|
|
588
|
+
try {
|
|
589
|
+
db.close();
|
|
590
|
+
} catch {
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
this.databases = [];
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Paginated list of all loaded contacts, sorted by displayName.
|
|
597
|
+
* Used by the MCP `list_contacts` tool.
|
|
598
|
+
*/
|
|
599
|
+
listContacts(offset = 0, limit = 20) {
|
|
600
|
+
if (!this.initialized) {
|
|
601
|
+
this.initialize();
|
|
602
|
+
}
|
|
603
|
+
const all = Array.from(this.contactCache.values()).sort(
|
|
604
|
+
(a, b) => a.displayName.localeCompare(b.displayName)
|
|
605
|
+
);
|
|
606
|
+
const slice = limit === 0 ? all.slice(offset) : all.slice(offset, offset + limit);
|
|
607
|
+
return { contacts: slice, total: all.length };
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Get statistics about loaded contacts
|
|
611
|
+
*/
|
|
612
|
+
getStats() {
|
|
613
|
+
if (!this.initialized) {
|
|
614
|
+
this.initialize();
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
totalContacts: this.contactCache.size,
|
|
618
|
+
phoneNumbers: this.phoneMap.size,
|
|
619
|
+
emails: this.emailMap.size
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function normalizeSnippetText(text) {
|
|
624
|
+
if (!text) return null;
|
|
625
|
+
const attachmentMarker = new RegExp(
|
|
626
|
+
`${OBJECT_REPLACEMENT_CHAR.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}+`,
|
|
627
|
+
"g"
|
|
628
|
+
);
|
|
629
|
+
const normalized = text.replace(/^[#$]/, "").replace(attachmentMarker, "📎 ").replace(/\uFFFD/g, "").replace(/\s+/g, " ").trim();
|
|
630
|
+
if (!normalized || normalized === "📎" || /^(📎\s*)+$/.test(normalized)) {
|
|
631
|
+
return normalized ? "(image/attachment)" : null;
|
|
632
|
+
}
|
|
633
|
+
return normalized;
|
|
634
|
+
}
|
|
635
|
+
function isMetadataOnlySnippet(text) {
|
|
636
|
+
if (!text) return false;
|
|
637
|
+
const normalized = text.trim();
|
|
638
|
+
return /^[$#]?https?:\/\//i.test(normalized) || /HttpURL\/?$/i.test(normalized) || /^[$#]https?:\/\/.*HttpURL\/?$/i.test(normalized);
|
|
639
|
+
}
|
|
640
|
+
function normalizeRichMetadataText(text) {
|
|
641
|
+
const normalized = normalizeSnippetText(text);
|
|
642
|
+
if (!normalized) return null;
|
|
643
|
+
if (/^https?:\/\/.*HttpURL\/?$/i.test(normalized)) {
|
|
644
|
+
return normalized.replace(/HttpURL\/?$/i, "");
|
|
645
|
+
}
|
|
646
|
+
return normalized;
|
|
647
|
+
}
|
|
648
|
+
function pickConversationSnippet(options) {
|
|
649
|
+
return normalizeSnippetText(options.rawText) ?? normalizeSnippetText(options.parsedText) ?? normalizeSnippetText(options.summaryText);
|
|
650
|
+
}
|
|
651
|
+
function normalizeText(value) {
|
|
652
|
+
const normalized = value?.replace(/\s+/g, " ").trim();
|
|
653
|
+
return normalized && normalized.length >= 4 ? normalized : void 0;
|
|
654
|
+
}
|
|
655
|
+
function extractNullPaddedAsciiText(blob) {
|
|
656
|
+
if (!blob) return void 0;
|
|
657
|
+
let best = "";
|
|
658
|
+
let current = "";
|
|
659
|
+
const flush = () => {
|
|
660
|
+
if (current.length > best.length) {
|
|
661
|
+
best = current;
|
|
662
|
+
}
|
|
663
|
+
current = "";
|
|
664
|
+
};
|
|
665
|
+
for (let index = 0; index < blob.length - 1; index += 1) {
|
|
666
|
+
const first = blob[index];
|
|
667
|
+
const second = blob[index + 1];
|
|
668
|
+
if (first === 0 && second >= 32 && second <= 126) {
|
|
669
|
+
current += String.fromCharCode(second);
|
|
670
|
+
index += 1;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
flush();
|
|
674
|
+
}
|
|
675
|
+
flush();
|
|
676
|
+
return normalizeText(best);
|
|
677
|
+
}
|
|
678
|
+
function extractArchivedAttributedStringText(blob) {
|
|
679
|
+
if (!blob) return void 0;
|
|
680
|
+
try {
|
|
681
|
+
const [plist] = parseBuffer(blob);
|
|
682
|
+
const objects = plist?.$objects;
|
|
683
|
+
if (!Array.isArray(objects)) {
|
|
684
|
+
return extractNullPaddedAsciiText(blob);
|
|
685
|
+
}
|
|
686
|
+
const attributedString = objects.find(
|
|
687
|
+
(value) => typeof value === "object" && value !== null && "NSString" in value
|
|
688
|
+
);
|
|
689
|
+
const stringUid = attributedString?.NSString?.UID;
|
|
690
|
+
if (typeof stringUid === "number" && typeof objects[stringUid] === "string") {
|
|
691
|
+
return normalizeText(objects[stringUid]);
|
|
692
|
+
}
|
|
693
|
+
const firstString = objects.find(
|
|
694
|
+
(value) => typeof value === "string" && !value.startsWith("$") && !value.startsWith("NS")
|
|
695
|
+
);
|
|
696
|
+
return normalizeText(firstString) ?? extractNullPaddedAsciiText(blob);
|
|
697
|
+
} catch {
|
|
698
|
+
return extractNullPaddedAsciiText(blob);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
function extractChatSummaryText(blob) {
|
|
702
|
+
if (!blob) return void 0;
|
|
703
|
+
try {
|
|
704
|
+
const [plist] = parseBuffer(blob);
|
|
705
|
+
const chatSummary = plist?.chatSummaryDictionary?.chatSummary;
|
|
706
|
+
if (chatSummary) {
|
|
707
|
+
return extractArchivedAttributedStringText(chatSummary);
|
|
708
|
+
}
|
|
709
|
+
} catch {
|
|
710
|
+
}
|
|
711
|
+
return extractNullPaddedAsciiText(blob);
|
|
712
|
+
}
|
|
713
|
+
const DEFAULT_DIR = join(homedir(), ".imsg-mcp");
|
|
714
|
+
const DEFAULT_DB = join(DEFAULT_DIR, "slugs.db");
|
|
715
|
+
class SlugStore {
|
|
716
|
+
db;
|
|
717
|
+
constructor(dbPath) {
|
|
718
|
+
const path = dbPath ?? DEFAULT_DB;
|
|
719
|
+
const dir = dirname(path);
|
|
720
|
+
if (!existsSync(dir)) {
|
|
721
|
+
mkdirSync(dir, { recursive: true });
|
|
722
|
+
}
|
|
723
|
+
this.db = new Database(path);
|
|
724
|
+
this.db.pragma("journal_mode = WAL");
|
|
725
|
+
this.migrate();
|
|
726
|
+
}
|
|
727
|
+
migrate() {
|
|
728
|
+
this.db.exec(`
|
|
729
|
+
CREATE TABLE IF NOT EXISTS thread_slugs (
|
|
730
|
+
slug TEXT PRIMARY KEY,
|
|
731
|
+
chat_guid TEXT UNIQUE NOT NULL,
|
|
732
|
+
chat_identifier TEXT NOT NULL,
|
|
733
|
+
display_name TEXT,
|
|
734
|
+
service TEXT NOT NULL DEFAULT 'iMessage',
|
|
735
|
+
is_group INTEGER NOT NULL DEFAULT 0,
|
|
736
|
+
participants TEXT NOT NULL DEFAULT '',
|
|
737
|
+
updated_at INTEGER NOT NULL DEFAULT 0
|
|
738
|
+
)
|
|
739
|
+
`);
|
|
740
|
+
}
|
|
741
|
+
upsert(record) {
|
|
742
|
+
const params = {
|
|
743
|
+
slug: record.slug,
|
|
744
|
+
chatGuid: record.chatGuid,
|
|
745
|
+
chatIdentifier: record.chatIdentifier,
|
|
746
|
+
displayName: record.displayName,
|
|
747
|
+
service: record.service,
|
|
748
|
+
isGroup: record.isGroup ? 1 : 0,
|
|
749
|
+
participants: record.participants,
|
|
750
|
+
updatedAt: record.updatedAt
|
|
751
|
+
};
|
|
752
|
+
const updateByGuid = this.db.prepare(`
|
|
753
|
+
UPDATE thread_slugs
|
|
754
|
+
SET
|
|
755
|
+
slug = @slug,
|
|
756
|
+
chat_identifier = @chatIdentifier,
|
|
757
|
+
display_name = @displayName,
|
|
758
|
+
service = @service,
|
|
759
|
+
is_group = @isGroup,
|
|
760
|
+
participants = @participants,
|
|
761
|
+
updated_at = @updatedAt
|
|
762
|
+
WHERE chat_guid = @chatGuid
|
|
763
|
+
`);
|
|
764
|
+
const updateResult = updateByGuid.run(params);
|
|
765
|
+
if (updateResult.changes > 0) return;
|
|
766
|
+
const insertOrUpdateBySlug = this.db.prepare(`
|
|
767
|
+
INSERT INTO thread_slugs (slug, chat_guid, chat_identifier, display_name, service, is_group, participants, updated_at)
|
|
768
|
+
VALUES (@slug, @chatGuid, @chatIdentifier, @displayName, @service, @isGroup, @participants, @updatedAt)
|
|
769
|
+
ON CONFLICT(slug) DO UPDATE SET
|
|
770
|
+
chat_guid = excluded.chat_guid,
|
|
771
|
+
chat_identifier = excluded.chat_identifier,
|
|
772
|
+
display_name = excluded.display_name,
|
|
773
|
+
service = excluded.service,
|
|
774
|
+
is_group = excluded.is_group,
|
|
775
|
+
participants = excluded.participants,
|
|
776
|
+
updated_at = excluded.updated_at
|
|
777
|
+
`);
|
|
778
|
+
insertOrUpdateBySlug.run(params);
|
|
779
|
+
}
|
|
780
|
+
upsertMany(records) {
|
|
781
|
+
const tx = this.db.transaction((recs) => {
|
|
782
|
+
for (const r of recs) this.upsert(r);
|
|
783
|
+
});
|
|
784
|
+
tx(records);
|
|
785
|
+
}
|
|
786
|
+
lookupBySlug(slug) {
|
|
787
|
+
const row = this.db.prepare("SELECT * FROM thread_slugs WHERE slug = ?").get(slug);
|
|
788
|
+
return row ? this.rowToRecord(row) : null;
|
|
789
|
+
}
|
|
790
|
+
lookupByGuid(chatGuid) {
|
|
791
|
+
const row = this.db.prepare("SELECT * FROM thread_slugs WHERE chat_guid = ?").get(chatGuid);
|
|
792
|
+
return row ? this.rowToRecord(row) : null;
|
|
793
|
+
}
|
|
794
|
+
lookupByChatIdentifier(chatIdentifier) {
|
|
795
|
+
const row = this.db.prepare("SELECT * FROM thread_slugs WHERE chat_identifier = ?").get(chatIdentifier);
|
|
796
|
+
return row ? this.rowToRecord(row) : null;
|
|
797
|
+
}
|
|
798
|
+
all() {
|
|
799
|
+
const rows = this.db.prepare("SELECT * FROM thread_slugs ORDER BY updated_at DESC").all();
|
|
800
|
+
return rows.map((r) => this.rowToRecord(r));
|
|
801
|
+
}
|
|
802
|
+
/** Remove slugs whose chat_guid is not in the given set of valid guids. */
|
|
803
|
+
prune(validGuids) {
|
|
804
|
+
const all = this.db.prepare("SELECT slug, chat_guid FROM thread_slugs").all();
|
|
805
|
+
const toDelete = all.filter((r) => !validGuids.has(r.chat_guid));
|
|
806
|
+
if (toDelete.length === 0) return 0;
|
|
807
|
+
const del = this.db.prepare("DELETE FROM thread_slugs WHERE slug = ?");
|
|
808
|
+
const tx = this.db.transaction((slugs) => {
|
|
809
|
+
for (const s of slugs) del.run(s);
|
|
810
|
+
});
|
|
811
|
+
tx(toDelete.map((r) => r.slug));
|
|
812
|
+
return toDelete.length;
|
|
813
|
+
}
|
|
814
|
+
close() {
|
|
815
|
+
this.db.close();
|
|
816
|
+
}
|
|
817
|
+
rowToRecord(row) {
|
|
818
|
+
return {
|
|
819
|
+
slug: row.slug,
|
|
820
|
+
chatGuid: row.chat_guid,
|
|
821
|
+
chatIdentifier: row.chat_identifier,
|
|
822
|
+
displayName: row.display_name,
|
|
823
|
+
service: row.service,
|
|
824
|
+
isGroup: Boolean(row.is_group),
|
|
825
|
+
participants: row.participants,
|
|
826
|
+
updatedAt: row.updated_at
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
function sanitizeSlugPart(name) {
|
|
831
|
+
return name.toLowerCase().replace(/['']/g, "").replace(/[\s_]+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-{2,}/g, "-").replace(/^-|-$/g, "");
|
|
832
|
+
}
|
|
833
|
+
function shortHash(input) {
|
|
834
|
+
return createHash("md5").update(input).digest("hex").slice(0, 4);
|
|
835
|
+
}
|
|
836
|
+
function serviceAbbrev(service) {
|
|
837
|
+
const lower = service.toLowerCase();
|
|
838
|
+
if (lower === "imessage") return "imsg";
|
|
839
|
+
if (lower === "sms") return "sms";
|
|
840
|
+
return sanitizeSlugPart(lower) || "msg";
|
|
841
|
+
}
|
|
842
|
+
function isGroupChatIdentifier(chatIdentifier) {
|
|
843
|
+
return chatIdentifier.startsWith("chat");
|
|
844
|
+
}
|
|
845
|
+
function isGroupGuid(guid) {
|
|
846
|
+
return guid.includes(";+;");
|
|
847
|
+
}
|
|
848
|
+
function generateThreadSlug(input) {
|
|
849
|
+
const svc = serviceAbbrev(input.serviceName || "iMessage");
|
|
850
|
+
const hash = shortHash(input.guid);
|
|
851
|
+
const isGroup = isGroupGuid(input.guid) || isGroupChatIdentifier(input.chatIdentifier);
|
|
852
|
+
let namePart;
|
|
853
|
+
if (isGroup) {
|
|
854
|
+
if (input.displayName && !input.displayName.startsWith("chat")) {
|
|
855
|
+
namePart = sanitizeSlugPart(input.displayName);
|
|
856
|
+
} else {
|
|
857
|
+
namePart = "group";
|
|
858
|
+
}
|
|
859
|
+
} else {
|
|
860
|
+
if (input.resolvedContactName) {
|
|
861
|
+
namePart = sanitizeSlugPart(input.resolvedContactName);
|
|
862
|
+
} else if (input.displayName && input.displayName !== input.chatIdentifier) {
|
|
863
|
+
namePart = sanitizeSlugPart(input.displayName);
|
|
864
|
+
} else {
|
|
865
|
+
namePart = sanitizeSlugPart(input.chatIdentifier.replace(/^\+/, ""));
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (!namePart) namePart = "unknown";
|
|
869
|
+
return `${namePart}~${svc}~${hash}`;
|
|
870
|
+
}
|
|
871
|
+
function toChatRow(c) {
|
|
872
|
+
return {
|
|
873
|
+
ROWID: c.ROWID,
|
|
874
|
+
guid: c.guid,
|
|
875
|
+
chat_identifier: c.chat_identifier,
|
|
876
|
+
display_name: c.display_name
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
const TAPBACK_TYPE_MAP = {
|
|
880
|
+
2e3: { type: "love", isRemoval: false },
|
|
881
|
+
2001: { type: "like", isRemoval: false },
|
|
882
|
+
2002: { type: "dislike", isRemoval: false },
|
|
883
|
+
2003: { type: "laugh", isRemoval: false },
|
|
884
|
+
2004: { type: "emphasize", isRemoval: false },
|
|
885
|
+
2005: { type: "question", isRemoval: false },
|
|
886
|
+
2006: { type: "emoji", isRemoval: false },
|
|
887
|
+
// iOS 18+ custom emoji
|
|
888
|
+
3e3: { type: "love", isRemoval: true },
|
|
889
|
+
3001: { type: "like", isRemoval: true },
|
|
890
|
+
3002: { type: "dislike", isRemoval: true },
|
|
891
|
+
3003: { type: "laugh", isRemoval: true },
|
|
892
|
+
3004: { type: "emphasize", isRemoval: true },
|
|
893
|
+
3005: { type: "question", isRemoval: true },
|
|
894
|
+
3006: { type: "emoji", isRemoval: true },
|
|
895
|
+
1e3: { type: "sticker", isRemoval: false }
|
|
896
|
+
};
|
|
897
|
+
const parseAssociatedMessageGuid = parseAssociatedMessageGuid$1;
|
|
898
|
+
function isPlaceholderText(text) {
|
|
899
|
+
if (!text) return true;
|
|
900
|
+
return /^[\uFFFC\uFFFD\s]+$/u.test(text);
|
|
901
|
+
}
|
|
902
|
+
function isHiddenSystemItem(itemType) {
|
|
903
|
+
return (itemType ?? 0) !== 0;
|
|
904
|
+
}
|
|
905
|
+
function getRichContentType(balloonBundleId) {
|
|
906
|
+
if (!balloonBundleId) return void 0;
|
|
907
|
+
if (balloonBundleId === "com.apple.messages.URLBalloonProvider") {
|
|
908
|
+
return "link_preview";
|
|
909
|
+
}
|
|
910
|
+
if (balloonBundleId === "com.apple.DigitalTouchBalloonProvider") {
|
|
911
|
+
return "digital_touch";
|
|
912
|
+
}
|
|
913
|
+
if (balloonBundleId === "com.apple.Handwriting.HandwritingProvider") {
|
|
914
|
+
return "handwriting";
|
|
915
|
+
}
|
|
916
|
+
if (balloonBundleId.includes("findmy") || balloonBundleId.includes("Maps")) {
|
|
917
|
+
return "location";
|
|
918
|
+
}
|
|
919
|
+
if (balloonBundleId.includes("MSMessageExtensionBalloonPlugin")) {
|
|
920
|
+
return "app_message";
|
|
921
|
+
}
|
|
922
|
+
return "unknown";
|
|
923
|
+
}
|
|
924
|
+
const macTimestampToDate = macTimestampToDate$1;
|
|
925
|
+
class IMessageDB {
|
|
926
|
+
raw;
|
|
927
|
+
dbPath;
|
|
928
|
+
/** Address-book reader. Public so MCP `*_contacts` tools can wrap it. */
|
|
929
|
+
contacts;
|
|
930
|
+
slugStore;
|
|
931
|
+
/** In-memory slug -> ChatWithLastDate for fast lookups during a session. */
|
|
932
|
+
slugMap = /* @__PURE__ */ new Map();
|
|
933
|
+
/** In-memory chat guid -> slug for stable per-chat slug lookups. */
|
|
934
|
+
guidToSlug = /* @__PURE__ */ new Map();
|
|
935
|
+
// ── TTL caches ───────────────────────────────────────────────────────
|
|
936
|
+
static CACHE_TTL_MS = 3e4;
|
|
937
|
+
cachedAllChats = null;
|
|
938
|
+
cachedLastByChat = null;
|
|
939
|
+
cachedUnreadByChat = null;
|
|
940
|
+
cachedParticipants = /* @__PURE__ */ new Map();
|
|
941
|
+
cachedMergeKeys = /* @__PURE__ */ new Map();
|
|
942
|
+
cachedSnippets = /* @__PURE__ */ new Map();
|
|
943
|
+
backgroundSyncNeeded = true;
|
|
944
|
+
backgroundRefreshScheduled = false;
|
|
945
|
+
constructor(dbPath, contactsDbPaths, slugStorePath) {
|
|
946
|
+
const span = perf("IMessageDB.constructor");
|
|
947
|
+
this.dbPath = dbPath || join(homedir(), "Library", "Messages", "chat.db");
|
|
948
|
+
this.raw = new Database(this.dbPath, { readonly: true });
|
|
949
|
+
this.contacts = new ContactsDB(contactsDbPaths);
|
|
950
|
+
this.slugStore = new SlugStore(slugStorePath);
|
|
951
|
+
const contactsSpan = perf("contacts.initialize");
|
|
952
|
+
try {
|
|
953
|
+
this.contacts.initialize();
|
|
954
|
+
contactsSpan.end();
|
|
955
|
+
} catch (err) {
|
|
956
|
+
contactsSpan.end({ error: String(err) });
|
|
957
|
+
console.warn("Failed to initialize contacts database:", err);
|
|
958
|
+
}
|
|
959
|
+
this.loadCachedSlugs();
|
|
960
|
+
span.end();
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Fast startup: load persisted slugs from SlugStore into in-memory maps.
|
|
964
|
+
* No chat.db queries -- uses the SQLite slug store populated by previous runs.
|
|
965
|
+
*/
|
|
966
|
+
loadCachedSlugs() {
|
|
967
|
+
const span = perf("loadCachedSlugs");
|
|
968
|
+
const records = this.slugStore.all();
|
|
969
|
+
for (const r of records) {
|
|
970
|
+
this.guidToSlug.set(r.chatGuid, r.slug);
|
|
971
|
+
this.slugMap.set(r.slug, {
|
|
972
|
+
ROWID: 0,
|
|
973
|
+
// placeholder -- will be filled on full sync
|
|
974
|
+
guid: r.chatGuid,
|
|
975
|
+
chat_identifier: r.chatIdentifier,
|
|
976
|
+
display_name: r.displayName,
|
|
977
|
+
service_name: r.service ?? null,
|
|
978
|
+
last_date: null
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
span.end({ loaded: records.length });
|
|
982
|
+
}
|
|
983
|
+
/** Generate and cache a slug for a single chat. */
|
|
984
|
+
syncSlugForChat(chat) {
|
|
985
|
+
const isGroup = isGroupGuid(chat.guid) || isGroupChatIdentifier(chat.chat_identifier);
|
|
986
|
+
const resolvedName = !isGroup && chat.chat_identifier ? this.contacts.lookupHandle(chat.chat_identifier) : null;
|
|
987
|
+
const slug = generateThreadSlug({
|
|
988
|
+
chatIdentifier: chat.chat_identifier,
|
|
989
|
+
guid: chat.guid,
|
|
990
|
+
displayName: chat.display_name,
|
|
991
|
+
serviceName: chat.service_name ?? null,
|
|
992
|
+
resolvedContactName: resolvedName !== chat.chat_identifier ? resolvedName : null
|
|
993
|
+
});
|
|
994
|
+
const participants = isGroup ? this.fetchChatParticipants(chat.ROWID) : [chat.chat_identifier];
|
|
995
|
+
this.slugStore.upsert({
|
|
996
|
+
slug,
|
|
997
|
+
chatGuid: chat.guid,
|
|
998
|
+
chatIdentifier: chat.chat_identifier,
|
|
999
|
+
displayName: resolvedName !== chat.chat_identifier ? resolvedName : chat.display_name || null,
|
|
1000
|
+
service: this.detectServiceForChat(chat),
|
|
1001
|
+
isGroup,
|
|
1002
|
+
participants: participants.join(","),
|
|
1003
|
+
updatedAt: Date.now()
|
|
1004
|
+
});
|
|
1005
|
+
this.slugMap.set(slug, chat);
|
|
1006
|
+
this.guidToSlug.set(chat.guid, slug);
|
|
1007
|
+
return slug;
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Schedule a full slug sync in the background using setImmediate chunks.
|
|
1011
|
+
* Each chunk processes up to 100 chats then yields the event loop.
|
|
1012
|
+
*/
|
|
1013
|
+
scheduleBackgroundSlugSync() {
|
|
1014
|
+
if (!this.backgroundSyncNeeded) return;
|
|
1015
|
+
this.backgroundSyncNeeded = false;
|
|
1016
|
+
const span = perf("backgroundSlugSync");
|
|
1017
|
+
const chats = this.getAllChatsWithLastDate();
|
|
1018
|
+
const validGuids = /* @__PURE__ */ new Set();
|
|
1019
|
+
let index = 0;
|
|
1020
|
+
let synced = 0;
|
|
1021
|
+
const CHUNK = 100;
|
|
1022
|
+
const processChunk = () => {
|
|
1023
|
+
const end = Math.min(index + CHUNK, chats.length);
|
|
1024
|
+
for (; index < end; index++) {
|
|
1025
|
+
const chat = chats[index];
|
|
1026
|
+
validGuids.add(chat.guid);
|
|
1027
|
+
if (!this.guidToSlug.has(chat.guid)) {
|
|
1028
|
+
this.syncSlugForChat(chat);
|
|
1029
|
+
synced++;
|
|
1030
|
+
} else {
|
|
1031
|
+
const existingSlug = this.guidToSlug.get(chat.guid);
|
|
1032
|
+
this.slugMap.set(existingSlug, chat);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
if (index < chats.length) {
|
|
1036
|
+
setImmediate(processChunk);
|
|
1037
|
+
} else {
|
|
1038
|
+
this.slugStore.prune(validGuids);
|
|
1039
|
+
span.end({ chats: chats.length, newSlugs: synced });
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
setImmediate(processChunk);
|
|
1043
|
+
}
|
|
1044
|
+
/** Look up a chat by thread slug. */
|
|
1045
|
+
findChatBySlug(slug) {
|
|
1046
|
+
const cached = this.slugMap.get(slug);
|
|
1047
|
+
if (cached) return toChatRow(cached);
|
|
1048
|
+
const record = this.slugStore.lookupBySlug(slug);
|
|
1049
|
+
if (!record) return null;
|
|
1050
|
+
return this.findChatByIdentifier(record.chatIdentifier);
|
|
1051
|
+
}
|
|
1052
|
+
/** Get the slug for a specific chat GUID. */
|
|
1053
|
+
getSlugForChatGuid(chatGuid) {
|
|
1054
|
+
const cached = this.guidToSlug.get(chatGuid);
|
|
1055
|
+
if (cached) return cached;
|
|
1056
|
+
const record = this.slugStore.lookupByGuid(chatGuid);
|
|
1057
|
+
return record?.slug ?? null;
|
|
1058
|
+
}
|
|
1059
|
+
/** Get the slug record for a chat_identifier (for attaching to output). */
|
|
1060
|
+
getSlugForChatIdentifier(chatIdentifier) {
|
|
1061
|
+
const matches = [...this.slugMap.entries()].filter(
|
|
1062
|
+
([, chat]) => chat.chat_identifier === chatIdentifier
|
|
1063
|
+
);
|
|
1064
|
+
if (matches.length === 1) return matches[0][0];
|
|
1065
|
+
if (matches.length > 1) return null;
|
|
1066
|
+
const record = this.slugStore.lookupByChatIdentifier(chatIdentifier);
|
|
1067
|
+
return record?.slug ?? null;
|
|
1068
|
+
}
|
|
1069
|
+
/** Get slug record by slug string. */
|
|
1070
|
+
getSlugRecord(slug) {
|
|
1071
|
+
return this.slugStore.lookupBySlug(slug);
|
|
1072
|
+
}
|
|
1073
|
+
/** Get all slug records. */
|
|
1074
|
+
getAllSlugs() {
|
|
1075
|
+
return this.slugStore.all();
|
|
1076
|
+
}
|
|
1077
|
+
/** Fetch group chat participants from chat_handle_join. */
|
|
1078
|
+
fetchChatParticipants(chatRowId) {
|
|
1079
|
+
const cached = this.cachedParticipants.get(chatRowId);
|
|
1080
|
+
if (cached) return cached;
|
|
1081
|
+
const stmt = this.raw.prepare(`
|
|
1082
|
+
SELECT h.id
|
|
1083
|
+
FROM ${Tables.CHAT_HANDLE_JOIN} chj
|
|
1084
|
+
JOIN ${Tables.HANDLE} h ON chj.handle_id = h.ROWID
|
|
1085
|
+
WHERE chj.chat_id = ?
|
|
1086
|
+
`);
|
|
1087
|
+
const rows = stmt.all(chatRowId);
|
|
1088
|
+
const result = rows.map((r) => r.id);
|
|
1089
|
+
this.cachedParticipants.set(chatRowId, result);
|
|
1090
|
+
return result;
|
|
1091
|
+
}
|
|
1092
|
+
/** Detect service type from chat data. */
|
|
1093
|
+
detectServiceForChat(chat) {
|
|
1094
|
+
if (chat.service_name) {
|
|
1095
|
+
return chat.service_name.toLowerCase().includes("sms") ? "SMS" : "iMessage";
|
|
1096
|
+
}
|
|
1097
|
+
if (chat.guid) {
|
|
1098
|
+
return chat.guid.toLowerCase().startsWith("sms") ? "SMS" : "iMessage";
|
|
1099
|
+
}
|
|
1100
|
+
return "iMessage";
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Get the N most recent messages across all conversations
|
|
1104
|
+
* By default excludes reactions (tapbacks) for cleaner output
|
|
1105
|
+
*/
|
|
1106
|
+
async getRecentMessages(limit = 20, includeReactions = false) {
|
|
1107
|
+
const span = perf("getRecentMessages");
|
|
1108
|
+
const chats = this.getAllChatsWithLastDate().sort((a, b) => (b.last_date ?? 0) - (a.last_date ?? 0)).slice(0, 10);
|
|
1109
|
+
const allMessages = [];
|
|
1110
|
+
for (const chat of chats) {
|
|
1111
|
+
try {
|
|
1112
|
+
const rows = this.fetchMessagesForChatRowId(chat.ROWID, Math.min(limit * 2, 40));
|
|
1113
|
+
const extBatch = this.fetchExtendedMessageDataBatch(rows.map((r) => r.ROWID));
|
|
1114
|
+
for (const msg of rows) {
|
|
1115
|
+
const text = this.parseMessageText(msg);
|
|
1116
|
+
const ext = extBatch.get(msg.ROWID) ?? {};
|
|
1117
|
+
if (isHiddenSystemItem(ext.item_type)) continue;
|
|
1118
|
+
const converted = this.convertMessage(msg, text, chat.chat_identifier, ext);
|
|
1119
|
+
if (!includeReactions && converted.isReaction) continue;
|
|
1120
|
+
allMessages.push(converted);
|
|
1121
|
+
}
|
|
1122
|
+
} catch {
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
const result = allMessages.sort((a, b) => b.date.getTime() - a.date.getTime()).slice(0, limit);
|
|
1126
|
+
span.end({ limit, returned: result.length });
|
|
1127
|
+
return result;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Get messages from a specific conversation, sorted by date ascending (chronological).
|
|
1131
|
+
* By default excludes reactions (tapbacks) for cleaner output.
|
|
1132
|
+
*/
|
|
1133
|
+
async getMessagesForChat(chatIdentifier, limit = 50, options = {}) {
|
|
1134
|
+
const span = perf("getMessagesForChat");
|
|
1135
|
+
const {
|
|
1136
|
+
includeReactions = false,
|
|
1137
|
+
includeReactionDetails = false,
|
|
1138
|
+
beforeMessageId,
|
|
1139
|
+
afterMessageId
|
|
1140
|
+
} = options;
|
|
1141
|
+
const chats = this.resolveChatsForConversation(chatIdentifier);
|
|
1142
|
+
if (chats.length === 0) {
|
|
1143
|
+
span.end({ limit, returned: 0 });
|
|
1144
|
+
return [];
|
|
1145
|
+
}
|
|
1146
|
+
const perChatLimit = Math.max(limit * 2, 50);
|
|
1147
|
+
const result = /* @__PURE__ */ new Map();
|
|
1148
|
+
for (const chat of chats) {
|
|
1149
|
+
const rows = this.fetchMessagesForChatRowId(
|
|
1150
|
+
chat.ROWID,
|
|
1151
|
+
perChatLimit,
|
|
1152
|
+
beforeMessageId,
|
|
1153
|
+
afterMessageId
|
|
1154
|
+
);
|
|
1155
|
+
const extBatch = this.fetchExtendedMessageDataBatch(rows.map((r) => r.ROWID));
|
|
1156
|
+
const reactionsByGuid = includeReactionDetails ? this.fetchReactionsForChat(chat.ROWID) : void 0;
|
|
1157
|
+
for (const msg of rows) {
|
|
1158
|
+
const text = this.parseMessageText(msg);
|
|
1159
|
+
const ext = extBatch.get(msg.ROWID) ?? {};
|
|
1160
|
+
if (isHiddenSystemItem(ext.item_type)) continue;
|
|
1161
|
+
const converted = this.convertMessage(
|
|
1162
|
+
msg,
|
|
1163
|
+
text,
|
|
1164
|
+
chat.chat_identifier,
|
|
1165
|
+
ext,
|
|
1166
|
+
false
|
|
1167
|
+
// don't let convertMessage fetch reactions individually
|
|
1168
|
+
);
|
|
1169
|
+
if (reactionsByGuid && !converted.isReaction) {
|
|
1170
|
+
const rxns = reactionsByGuid.get(msg.guid);
|
|
1171
|
+
if (rxns && rxns.length > 0) {
|
|
1172
|
+
converted.reactions = this.consolidateReactions(rxns);
|
|
1173
|
+
if (converted.reactions.length === 0) converted.reactions = void 0;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
if (!includeReactions && converted.isReaction) continue;
|
|
1177
|
+
result.set(converted.id, converted);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
const sorted = [...result.values()].sort((a, b) => a.date.getTime() - b.date.getTime()).slice(-limit);
|
|
1181
|
+
span.end({ limit, chats: chats.length, returned: sorted.length });
|
|
1182
|
+
return sorted;
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Get unread messages across all conversations, sorted by date descending (newest first).
|
|
1186
|
+
* Excludes reactions for cleaner output.
|
|
1187
|
+
* @param limit Max number of messages to return (default 100).
|
|
1188
|
+
*/
|
|
1189
|
+
async getUnreadMessages(limit = 100) {
|
|
1190
|
+
const span = perf("getUnreadMessages");
|
|
1191
|
+
const stmt = this.raw.prepare(`
|
|
1192
|
+
SELECT
|
|
1193
|
+
m.ROWID,
|
|
1194
|
+
m.guid,
|
|
1195
|
+
m.text,
|
|
1196
|
+
m.attributedBody,
|
|
1197
|
+
m.date,
|
|
1198
|
+
m.is_from_me,
|
|
1199
|
+
h.id as handle_id,
|
|
1200
|
+
m.cache_has_attachments,
|
|
1201
|
+
c.chat_identifier
|
|
1202
|
+
FROM ${Tables.MESSAGE} m
|
|
1203
|
+
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
1204
|
+
LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
1205
|
+
LEFT JOIN ${Tables.CHAT} c ON cmj.chat_id = c.ROWID
|
|
1206
|
+
WHERE m.is_from_me = 0
|
|
1207
|
+
AND m.is_read = 0
|
|
1208
|
+
AND m.associated_message_type = ${AssociatedMessageType.NORMAL}
|
|
1209
|
+
AND COALESCE(m.item_type, 0) = 0
|
|
1210
|
+
ORDER BY m.date DESC
|
|
1211
|
+
LIMIT ?
|
|
1212
|
+
`);
|
|
1213
|
+
const rows = stmt.all(limit);
|
|
1214
|
+
const validRows = rows.filter((r) => r.chat_identifier != null);
|
|
1215
|
+
const extBatch = this.fetchExtendedMessageDataBatch(validRows.map((r) => r.ROWID));
|
|
1216
|
+
const result = [];
|
|
1217
|
+
for (const row of validRows) {
|
|
1218
|
+
const text = this.parseMessageText(row);
|
|
1219
|
+
const ext = extBatch.get(row.ROWID) ?? {};
|
|
1220
|
+
result.push(this.convertMessage(row, text, row.chat_identifier, ext));
|
|
1221
|
+
}
|
|
1222
|
+
result.sort((a, b) => b.date.getTime() - a.date.getTime());
|
|
1223
|
+
const sliced = result.slice(0, limit);
|
|
1224
|
+
span.end({ limit, rows: rows.length, returned: sliced.length });
|
|
1225
|
+
return sliced;
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Get the most recent message in a conversation
|
|
1229
|
+
*/
|
|
1230
|
+
async getLastMessage(chatIdentifier) {
|
|
1231
|
+
const messages = await this.getMessagesForChat(chatIdentifier, 1);
|
|
1232
|
+
return messages.length > 0 ? messages[messages.length - 1] : null;
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Get messages after a specific message ID (for polling new messages).
|
|
1236
|
+
* Returns incoming messages only, sorted by date ascending (chronological).
|
|
1237
|
+
*/
|
|
1238
|
+
async getMessagesAfter(chatIdentifier, afterMessageId) {
|
|
1239
|
+
const messages = await this.getMessagesForChat(chatIdentifier, 1e3, { afterMessageId });
|
|
1240
|
+
const filtered = messages.filter((m) => m.id > afterMessageId && !m.isFromMe);
|
|
1241
|
+
filtered.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
1242
|
+
return filtered;
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* List all conversations with metadata, sorted by last message date (newest first).
|
|
1246
|
+
* Populates lastMessageDate, lastMessageSnippet, and unreadCount to match Messages.app left pane.
|
|
1247
|
+
*/
|
|
1248
|
+
async listConversations(limit = 200) {
|
|
1249
|
+
const span = perf("listConversations");
|
|
1250
|
+
const chats = this.getAllChats();
|
|
1251
|
+
const lastByChat = this.getLastMessageByChat();
|
|
1252
|
+
const unreadByChat = this.getUnreadByChat();
|
|
1253
|
+
const sortEntries = chats.map((chat) => ({
|
|
1254
|
+
chat,
|
|
1255
|
+
lastDate: lastByChat[chat.ROWID]?.lastDate ?? 0,
|
|
1256
|
+
isGroup: isGroupGuid(chat.guid) || isGroupChatIdentifier(chat.chat_identifier),
|
|
1257
|
+
last: lastByChat[chat.ROWID]
|
|
1258
|
+
}));
|
|
1259
|
+
sortEntries.sort((a, b) => b.lastDate - a.lastDate);
|
|
1260
|
+
const candidates = sortEntries.slice(0, limit * 3);
|
|
1261
|
+
const prepared = candidates.map(({ chat, isGroup, last }) => {
|
|
1262
|
+
const lastDate = last ? macTimestampToDate(last.lastDate) : null;
|
|
1263
|
+
const rawIdentifier = chat.chat_identifier;
|
|
1264
|
+
let displayName = chat.display_name;
|
|
1265
|
+
if (!displayName && rawIdentifier && !isGroup) {
|
|
1266
|
+
const resolved = this.contacts.lookupHandle(rawIdentifier);
|
|
1267
|
+
displayName = resolved !== rawIdentifier ? resolved : null;
|
|
1268
|
+
}
|
|
1269
|
+
const participants = isGroup ? this.fetchChatParticipants(chat.ROWID) : [rawIdentifier];
|
|
1270
|
+
const mergeKey = this.getConversationMergeKey(rawIdentifier, chat.guid, isGroup);
|
|
1271
|
+
const slug = this.getSlugForChatGuid(chat.guid) ?? rawIdentifier;
|
|
1272
|
+
const chatData = this.slugMap.get(slug);
|
|
1273
|
+
const serviceType = chatData ? this.detectServiceForChat(chatData) : "iMessage";
|
|
1274
|
+
return {
|
|
1275
|
+
last,
|
|
1276
|
+
mergeKey,
|
|
1277
|
+
conversation: {
|
|
1278
|
+
chatId: chat.guid,
|
|
1279
|
+
chatIdentifier: rawIdentifier,
|
|
1280
|
+
displayName: displayName || null,
|
|
1281
|
+
rawIdentifier,
|
|
1282
|
+
participants,
|
|
1283
|
+
lastMessageDate: lastDate,
|
|
1284
|
+
lastMessageSnippet: null,
|
|
1285
|
+
unreadCount: unreadByChat[chat.ROWID] ?? 0,
|
|
1286
|
+
threadSlug: slug,
|
|
1287
|
+
isGroupChat: isGroup,
|
|
1288
|
+
serviceType
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
});
|
|
1292
|
+
const deduped = this.mergeDuplicateConversations(prepared);
|
|
1293
|
+
const selected = deduped.slice(0, limit);
|
|
1294
|
+
const result = selected.map(({ conversation, last }) => ({
|
|
1295
|
+
...conversation,
|
|
1296
|
+
lastMessageSnippet: this.resolveConversationSnippet(last)
|
|
1297
|
+
}));
|
|
1298
|
+
span.end({
|
|
1299
|
+
chats: chats.length,
|
|
1300
|
+
candidates: candidates.length,
|
|
1301
|
+
deduped: deduped.length,
|
|
1302
|
+
returned: result.length
|
|
1303
|
+
});
|
|
1304
|
+
return result;
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Find a chat by phone number, email, or chat identifier.
|
|
1308
|
+
* Uses a single join (chats + last message date), then filters and sorts by date.
|
|
1309
|
+
* When multiple chats match (e.g. same number, different threads), returns the one with the most recent message.
|
|
1310
|
+
*/
|
|
1311
|
+
async findChatByHandle(handle) {
|
|
1312
|
+
const chats = this.getAllChatsWithLastDate();
|
|
1313
|
+
const normalized = handle.replace(/[\s\-()]/g, "").toLowerCase();
|
|
1314
|
+
const matches = chats.filter((chat) => {
|
|
1315
|
+
const chatNorm = chat.chat_identifier?.replace(/[\s\-()]/g, "").toLowerCase() || "";
|
|
1316
|
+
return chatNorm.includes(normalized) || normalized.includes(chatNorm);
|
|
1317
|
+
});
|
|
1318
|
+
if (matches.length === 0) return null;
|
|
1319
|
+
const found = this.pickMostRecentChat(matches);
|
|
1320
|
+
const rawIdentifier = found.chat_identifier;
|
|
1321
|
+
const isGroup = isGroupGuid(found.guid) || isGroupChatIdentifier(rawIdentifier);
|
|
1322
|
+
let displayName = found.display_name;
|
|
1323
|
+
if (!displayName && rawIdentifier && !isGroup) {
|
|
1324
|
+
const resolved = this.contacts.lookupHandle(rawIdentifier);
|
|
1325
|
+
displayName = resolved !== rawIdentifier ? resolved : null;
|
|
1326
|
+
}
|
|
1327
|
+
const slug = this.getSlugForChatGuid(found.guid) ?? rawIdentifier;
|
|
1328
|
+
const chatData = this.slugMap.get(slug);
|
|
1329
|
+
const serviceType = chatData ? this.detectServiceForChat(chatData) : "iMessage";
|
|
1330
|
+
return {
|
|
1331
|
+
chatId: found.guid,
|
|
1332
|
+
chatIdentifier: rawIdentifier,
|
|
1333
|
+
displayName: displayName || null,
|
|
1334
|
+
rawIdentifier,
|
|
1335
|
+
participants: isGroup ? this.fetchChatParticipants(found.ROWID) : [rawIdentifier],
|
|
1336
|
+
lastMessageDate: null,
|
|
1337
|
+
lastMessageSnippet: null,
|
|
1338
|
+
unreadCount: 0,
|
|
1339
|
+
threadSlug: slug,
|
|
1340
|
+
isGroupChat: isGroup,
|
|
1341
|
+
serviceType
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Search messages across all conversations.
|
|
1346
|
+
*
|
|
1347
|
+
* Strategy: search the `text` column via LIKE first (covers most messages).
|
|
1348
|
+
* For messages where text is NULL but attributedBody exists, we fetch a larger
|
|
1349
|
+
* window and post-filter after parsing the blob. The upstream imessage-parser
|
|
1350
|
+
* had a bug here: its SQL `WHERE text LIKE ? OR attributedBody IS NOT NULL`
|
|
1351
|
+
* with a LIMIT meant the LIMIT was consumed by non-matching attributedBody rows,
|
|
1352
|
+
* hiding older text matches entirely.
|
|
1353
|
+
*/
|
|
1354
|
+
async searchMessages(query, limit = 20) {
|
|
1355
|
+
const span = perf("searchMessages");
|
|
1356
|
+
const textStmt = this.raw.prepare(`
|
|
1357
|
+
SELECT
|
|
1358
|
+
m.ROWID, m.guid, m.text, m.attributedBody, m.date,
|
|
1359
|
+
m.is_from_me, h.id as handle_id, m.cache_has_attachments,
|
|
1360
|
+
c.chat_identifier
|
|
1361
|
+
FROM ${Tables.MESSAGE} m
|
|
1362
|
+
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
1363
|
+
LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
1364
|
+
LEFT JOIN ${Tables.CHAT} c ON cmj.chat_id = c.ROWID
|
|
1365
|
+
WHERE m.text LIKE ?
|
|
1366
|
+
AND m.associated_message_type = ${AssociatedMessageType.NORMAL}
|
|
1367
|
+
AND COALESCE(m.item_type, 0) = 0
|
|
1368
|
+
ORDER BY m.date DESC
|
|
1369
|
+
LIMIT ?
|
|
1370
|
+
`);
|
|
1371
|
+
const textRows = textStmt.all(`%${query}%`, limit * 2);
|
|
1372
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
1373
|
+
const messages = [];
|
|
1374
|
+
for (const row of textRows) {
|
|
1375
|
+
if (!row.chat_identifier) continue;
|
|
1376
|
+
seenIds.add(row.ROWID);
|
|
1377
|
+
const text = this.parseMessageText(row);
|
|
1378
|
+
const ext = this.fetchExtendedMessageData(row.ROWID);
|
|
1379
|
+
if (isHiddenSystemItem(ext.item_type)) continue;
|
|
1380
|
+
const converted = this.convertMessage(row, text, row.chat_identifier, ext);
|
|
1381
|
+
if (converted.isReaction) continue;
|
|
1382
|
+
messages.push(converted);
|
|
1383
|
+
if (messages.length >= limit) return messages;
|
|
1384
|
+
}
|
|
1385
|
+
const blobStmt = this.raw.prepare(`
|
|
1386
|
+
SELECT
|
|
1387
|
+
m.ROWID, m.guid, m.text, m.attributedBody, m.date,
|
|
1388
|
+
m.is_from_me, h.id as handle_id, m.cache_has_attachments,
|
|
1389
|
+
c.chat_identifier
|
|
1390
|
+
FROM ${Tables.MESSAGE} m
|
|
1391
|
+
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
1392
|
+
LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
1393
|
+
LEFT JOIN ${Tables.CHAT} c ON cmj.chat_id = c.ROWID
|
|
1394
|
+
WHERE m.text IS NULL
|
|
1395
|
+
AND m.attributedBody IS NOT NULL
|
|
1396
|
+
AND m.associated_message_type = ${AssociatedMessageType.NORMAL}
|
|
1397
|
+
AND COALESCE(m.item_type, 0) = 0
|
|
1398
|
+
ORDER BY m.date DESC
|
|
1399
|
+
LIMIT ?
|
|
1400
|
+
`);
|
|
1401
|
+
const blobRows = blobStmt.all(limit * 20);
|
|
1402
|
+
const queryLower = query.toLowerCase();
|
|
1403
|
+
for (const row of blobRows) {
|
|
1404
|
+
if (!row.chat_identifier || seenIds.has(row.ROWID)) continue;
|
|
1405
|
+
const text = this.parseMessageText(row);
|
|
1406
|
+
if (!text || !text.toLowerCase().includes(queryLower)) continue;
|
|
1407
|
+
const ext = this.fetchExtendedMessageData(row.ROWID);
|
|
1408
|
+
if (isHiddenSystemItem(ext.item_type)) continue;
|
|
1409
|
+
const converted = this.convertMessage(row, text, row.chat_identifier, ext);
|
|
1410
|
+
if (converted.isReaction) continue;
|
|
1411
|
+
messages.push(converted);
|
|
1412
|
+
if (messages.length >= limit) break;
|
|
1413
|
+
}
|
|
1414
|
+
span.end({
|
|
1415
|
+
query,
|
|
1416
|
+
limit,
|
|
1417
|
+
textHits: textRows.length,
|
|
1418
|
+
blobScanned: blobRows.length,
|
|
1419
|
+
returned: messages.length
|
|
1420
|
+
});
|
|
1421
|
+
return messages;
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Get a deep link to open a specific conversation in Messages.app
|
|
1425
|
+
*/
|
|
1426
|
+
getConversationLink(chatIdentifier) {
|
|
1427
|
+
return `imessage://${encodeURIComponent(chatIdentifier)}`;
|
|
1428
|
+
}
|
|
1429
|
+
resolveConversationSnippet(last) {
|
|
1430
|
+
if (!last) return null;
|
|
1431
|
+
const cached = this.cachedSnippets.get(last.lastMessageId);
|
|
1432
|
+
if (cached !== void 0) return cached;
|
|
1433
|
+
let result = null;
|
|
1434
|
+
const directSnippet = pickConversationSnippet({ rawText: last.snippet });
|
|
1435
|
+
if (directSnippet && !this.shouldFallbackToPreviousSnippet(directSnippet, last)) {
|
|
1436
|
+
result = directSnippet;
|
|
1437
|
+
}
|
|
1438
|
+
if (result == null) {
|
|
1439
|
+
const parsedSnippet = pickConversationSnippet({
|
|
1440
|
+
parsedText: this.getMessageTextByRowId(last.lastMessageId)
|
|
1441
|
+
});
|
|
1442
|
+
if (parsedSnippet && !this.shouldFallbackToPreviousSnippet(parsedSnippet, last)) {
|
|
1443
|
+
result = parsedSnippet;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
if (result == null) {
|
|
1447
|
+
result = this.getPreviousConversationSnippet(last);
|
|
1448
|
+
}
|
|
1449
|
+
if (result == null) {
|
|
1450
|
+
result = pickConversationSnippet({
|
|
1451
|
+
summaryText: extractChatSummaryText(last.chatProperties) ?? null
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
this.cachedSnippets.set(last.lastMessageId, result);
|
|
1455
|
+
return result;
|
|
1456
|
+
}
|
|
1457
|
+
shouldFallbackToPreviousSnippet(snippet, last) {
|
|
1458
|
+
return Boolean(
|
|
1459
|
+
last.balloonBundleId?.includes("URLBalloonProvider") && isMetadataOnlySnippet(snippet)
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
getPreviousConversationSnippet(last) {
|
|
1463
|
+
const rows = this.raw.prepare(`
|
|
1464
|
+
SELECT m.ROWID, m.text, m.attributedBody, m.date, m.is_from_me
|
|
1465
|
+
FROM ${Tables.MESSAGE} m
|
|
1466
|
+
JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON cmj.message_id = m.ROWID
|
|
1467
|
+
WHERE cmj.chat_id = ?
|
|
1468
|
+
AND m.associated_message_type = ${AssociatedMessageType.NORMAL}
|
|
1469
|
+
AND COALESCE(m.item_type, 0) = 0
|
|
1470
|
+
AND (m.date < ? OR (m.date = ? AND m.ROWID < ?))
|
|
1471
|
+
ORDER BY m.date DESC, m.ROWID DESC
|
|
1472
|
+
LIMIT 5
|
|
1473
|
+
`).all(last.chatId, last.lastDate, last.lastDate, last.lastMessageId);
|
|
1474
|
+
for (const row of rows) {
|
|
1475
|
+
if (Boolean(row.is_from_me) !== last.lastIsFromMe) break;
|
|
1476
|
+
if (Math.abs(last.lastDate - row.date) > 6e10) break;
|
|
1477
|
+
const snippet = pickConversationSnippet({
|
|
1478
|
+
rawText: row.text,
|
|
1479
|
+
parsedText: this.extractMessageText(row)
|
|
1480
|
+
});
|
|
1481
|
+
if (snippet && !isMetadataOnlySnippet(snippet)) {
|
|
1482
|
+
return snippet;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
return null;
|
|
1486
|
+
}
|
|
1487
|
+
mergeDuplicateConversations(prepared) {
|
|
1488
|
+
const merged = [];
|
|
1489
|
+
const indexByKey = /* @__PURE__ */ new Map();
|
|
1490
|
+
for (const entry of prepared) {
|
|
1491
|
+
const existingIndex = indexByKey.get(entry.mergeKey);
|
|
1492
|
+
if (existingIndex === void 0) {
|
|
1493
|
+
indexByKey.set(entry.mergeKey, merged.length);
|
|
1494
|
+
merged.push(entry);
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
merged[existingIndex] = this.mergeConversationEntries(merged[existingIndex], entry);
|
|
1498
|
+
}
|
|
1499
|
+
return merged;
|
|
1500
|
+
}
|
|
1501
|
+
mergeConversationEntries(left, right) {
|
|
1502
|
+
const preferred = this.pickPreferredConversationEntry(left, right);
|
|
1503
|
+
const other = preferred === left ? right : left;
|
|
1504
|
+
const sameIdentifier = preferred.conversation.chatIdentifier === other.conversation.chatIdentifier;
|
|
1505
|
+
return {
|
|
1506
|
+
mergeKey: preferred.mergeKey,
|
|
1507
|
+
last: preferred.last ?? other.last,
|
|
1508
|
+
conversation: {
|
|
1509
|
+
...preferred.conversation,
|
|
1510
|
+
displayName: preferred.conversation.displayName ?? other.conversation.displayName,
|
|
1511
|
+
participants: [
|
|
1512
|
+
.../* @__PURE__ */ new Set([...preferred.conversation.participants, ...other.conversation.participants])
|
|
1513
|
+
],
|
|
1514
|
+
unreadCount: sameIdentifier ? Math.max(preferred.conversation.unreadCount, other.conversation.unreadCount) : preferred.conversation.unreadCount + other.conversation.unreadCount
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
pickPreferredConversationEntry(left, right) {
|
|
1519
|
+
const leftTime = left.conversation.lastMessageDate?.getTime() ?? 0;
|
|
1520
|
+
const rightTime = right.conversation.lastMessageDate?.getTime() ?? 0;
|
|
1521
|
+
if (leftTime !== rightTime) {
|
|
1522
|
+
return leftTime > rightTime ? left : right;
|
|
1523
|
+
}
|
|
1524
|
+
const preferredService = left.last?.lastService ?? right.last?.lastService ?? null;
|
|
1525
|
+
if (preferredService) {
|
|
1526
|
+
const preferredType = preferredService.toLowerCase().includes("sms") ? "SMS" : "iMessage";
|
|
1527
|
+
if (left.conversation.serviceType === preferredType && right.conversation.serviceType !== preferredType) {
|
|
1528
|
+
return left;
|
|
1529
|
+
}
|
|
1530
|
+
if (right.conversation.serviceType === preferredType && left.conversation.serviceType !== preferredType) {
|
|
1531
|
+
return right;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
if (left.conversation.displayName && !right.conversation.displayName) return left;
|
|
1535
|
+
if (right.conversation.displayName && !left.conversation.displayName) return right;
|
|
1536
|
+
if (left.conversation.serviceType === "iMessage" && right.conversation.serviceType === "SMS") {
|
|
1537
|
+
return left;
|
|
1538
|
+
}
|
|
1539
|
+
if (right.conversation.serviceType === "iMessage" && left.conversation.serviceType === "SMS") {
|
|
1540
|
+
return right;
|
|
1541
|
+
}
|
|
1542
|
+
return left;
|
|
1543
|
+
}
|
|
1544
|
+
getConversationMergeKey(chatIdentifier, chatGuid, isGroup) {
|
|
1545
|
+
const cacheKey = `${chatIdentifier}::${chatGuid}::${isGroup}`;
|
|
1546
|
+
const cached = this.cachedMergeKeys.get(cacheKey);
|
|
1547
|
+
if (cached) return cached;
|
|
1548
|
+
let key;
|
|
1549
|
+
if (isGroup) {
|
|
1550
|
+
key = `group:${chatGuid}`;
|
|
1551
|
+
} else {
|
|
1552
|
+
const contact = this.contacts.lookupContact(chatIdentifier);
|
|
1553
|
+
key = contact ? `contact:${contact.contactId}` : `identifier:${chatIdentifier.replace(/[\s\-()]/g, "").toLowerCase()}`;
|
|
1554
|
+
}
|
|
1555
|
+
this.cachedMergeKeys.set(cacheKey, key);
|
|
1556
|
+
return key;
|
|
1557
|
+
}
|
|
1558
|
+
resolveChatsForConversation(identifier) {
|
|
1559
|
+
const chats = this.getAllChatsWithLastDate();
|
|
1560
|
+
const normalized = identifier.replace(/[\s\-()]/g, "").toLowerCase();
|
|
1561
|
+
const directMatches = chats.filter(
|
|
1562
|
+
(chat) => chat.chat_identifier === identifier || chat.guid === identifier || chat.chat_identifier != null && (chat.chat_identifier.replace(/[\s\-()]/g, "").toLowerCase().includes(normalized) || normalized.includes(chat.chat_identifier.replace(/[\s\-()]/g, "").toLowerCase()))
|
|
1563
|
+
);
|
|
1564
|
+
if (directMatches.length === 0) return [];
|
|
1565
|
+
const representative = this.pickMostRecentChat(directMatches);
|
|
1566
|
+
const isGroup = isGroupGuid(representative.guid) || isGroupChatIdentifier(representative.chat_identifier);
|
|
1567
|
+
if (isGroup) return [representative];
|
|
1568
|
+
const mergeKey = this.getConversationMergeKey(
|
|
1569
|
+
representative.chat_identifier,
|
|
1570
|
+
representative.guid,
|
|
1571
|
+
false
|
|
1572
|
+
);
|
|
1573
|
+
return chats.filter(
|
|
1574
|
+
(chat) => !(isGroupGuid(chat.guid) || isGroupChatIdentifier(chat.chat_identifier)) && this.getConversationMergeKey(chat.chat_identifier, chat.guid, false) === mergeKey
|
|
1575
|
+
).map((chat) => toChatRow(chat));
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Get all chats with their last message date in one join query.
|
|
1579
|
+
* Used to resolve the correct chat for a contact when multiple chats exist (e.g. same number).
|
|
1580
|
+
* Results are suitable for filtering by handle/identifier then sorting by last_date descending.
|
|
1581
|
+
*/
|
|
1582
|
+
getAllChatsWithLastDate() {
|
|
1583
|
+
const now = Date.now();
|
|
1584
|
+
if (this.cachedAllChats && now - this.cachedAllChats.ts < IMessageDB.CACHE_TTL_MS) {
|
|
1585
|
+
return this.cachedAllChats.data;
|
|
1586
|
+
}
|
|
1587
|
+
const stmt = this.raw.prepare(`
|
|
1588
|
+
SELECT
|
|
1589
|
+
c.ROWID as rowid,
|
|
1590
|
+
c.guid,
|
|
1591
|
+
c.chat_identifier,
|
|
1592
|
+
c.display_name,
|
|
1593
|
+
c.service_name,
|
|
1594
|
+
(SELECT MAX(m.date)
|
|
1595
|
+
FROM ${Tables.CHAT_MESSAGE_JOIN} cmj
|
|
1596
|
+
JOIN ${Tables.MESSAGE} m ON cmj.message_id = m.ROWID
|
|
1597
|
+
WHERE cmj.chat_id = c.ROWID
|
|
1598
|
+
AND m.associated_message_type = ${AssociatedMessageType.NORMAL}
|
|
1599
|
+
AND COALESCE(m.item_type, 0) = 0) as last_date
|
|
1600
|
+
FROM ${Tables.CHAT} c
|
|
1601
|
+
`);
|
|
1602
|
+
const rows = stmt.all();
|
|
1603
|
+
const data = rows.map((r) => ({
|
|
1604
|
+
ROWID: r.rowid,
|
|
1605
|
+
guid: r.guid,
|
|
1606
|
+
chat_identifier: r.chat_identifier,
|
|
1607
|
+
display_name: r.display_name,
|
|
1608
|
+
service_name: r.service_name,
|
|
1609
|
+
last_date: r.last_date
|
|
1610
|
+
}));
|
|
1611
|
+
this.cachedAllChats = { data, ts: now };
|
|
1612
|
+
return data;
|
|
1613
|
+
}
|
|
1614
|
+
/** Last message metadata per chat (window function over message table). Cached with TTL. */
|
|
1615
|
+
getLastMessageByChat() {
|
|
1616
|
+
const now = Date.now();
|
|
1617
|
+
if (this.cachedLastByChat && now - this.cachedLastByChat.ts < IMessageDB.CACHE_TTL_MS) {
|
|
1618
|
+
return this.cachedLastByChat.data;
|
|
1619
|
+
}
|
|
1620
|
+
const stmt = this.raw.prepare(`
|
|
1621
|
+
SELECT chat_id, last_date, last_message_id, last_service, last_is_from_me, balloon_bundle_id, snippet, chat_properties FROM (
|
|
1622
|
+
SELECT cmj.chat_id, m.date as last_date, m.ROWID as last_message_id,
|
|
1623
|
+
m.service as last_service,
|
|
1624
|
+
m.is_from_me as last_is_from_me,
|
|
1625
|
+
m.balloon_bundle_id as balloon_bundle_id,
|
|
1626
|
+
COALESCE(TRIM(SUBSTR(m.text, 1, 200)), '') as snippet,
|
|
1627
|
+
c.properties as chat_properties,
|
|
1628
|
+
ROW_NUMBER() OVER (PARTITION BY cmj.chat_id ORDER BY m.date DESC) as rn
|
|
1629
|
+
FROM ${Tables.MESSAGE} m
|
|
1630
|
+
JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
1631
|
+
JOIN ${Tables.CHAT} c ON c.ROWID = cmj.chat_id
|
|
1632
|
+
WHERE m.associated_message_type = ${AssociatedMessageType.NORMAL}
|
|
1633
|
+
AND COALESCE(m.item_type, 0) = 0
|
|
1634
|
+
) WHERE rn = 1
|
|
1635
|
+
`);
|
|
1636
|
+
const data = stmt.all().reduce(
|
|
1637
|
+
(acc, row) => {
|
|
1638
|
+
acc[row.chat_id] = {
|
|
1639
|
+
chatId: row.chat_id,
|
|
1640
|
+
lastDate: row.last_date,
|
|
1641
|
+
lastMessageId: row.last_message_id,
|
|
1642
|
+
lastService: row.last_service,
|
|
1643
|
+
lastIsFromMe: Boolean(row.last_is_from_me),
|
|
1644
|
+
balloonBundleId: row.balloon_bundle_id,
|
|
1645
|
+
snippet: row.snippet || null,
|
|
1646
|
+
chatProperties: row.chat_properties
|
|
1647
|
+
};
|
|
1648
|
+
return acc;
|
|
1649
|
+
},
|
|
1650
|
+
{}
|
|
1651
|
+
);
|
|
1652
|
+
this.cachedLastByChat = { data, ts: now };
|
|
1653
|
+
return data;
|
|
1654
|
+
}
|
|
1655
|
+
/** Unread count per chat. Cached with TTL. */
|
|
1656
|
+
getUnreadByChat() {
|
|
1657
|
+
const now = Date.now();
|
|
1658
|
+
if (this.cachedUnreadByChat && now - this.cachedUnreadByChat.ts < IMessageDB.CACHE_TTL_MS) {
|
|
1659
|
+
return this.cachedUnreadByChat.data;
|
|
1660
|
+
}
|
|
1661
|
+
const stmt = this.raw.prepare(`
|
|
1662
|
+
SELECT cmj.chat_id, COUNT(*) as unread
|
|
1663
|
+
FROM ${Tables.MESSAGE} m
|
|
1664
|
+
JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
1665
|
+
WHERE m.associated_message_type = ${AssociatedMessageType.NORMAL}
|
|
1666
|
+
AND COALESCE(m.item_type, 0) = 0
|
|
1667
|
+
AND m.is_from_me = 0 AND m.is_read = 0
|
|
1668
|
+
GROUP BY cmj.chat_id
|
|
1669
|
+
`);
|
|
1670
|
+
const data = stmt.all().reduce(
|
|
1671
|
+
(acc, row) => {
|
|
1672
|
+
acc[row.chat_id] = row.unread;
|
|
1673
|
+
return acc;
|
|
1674
|
+
},
|
|
1675
|
+
{}
|
|
1676
|
+
);
|
|
1677
|
+
this.cachedUnreadByChat = { data, ts: now };
|
|
1678
|
+
return data;
|
|
1679
|
+
}
|
|
1680
|
+
/** Get all chats (without last_date subquery -- lighter for listing). */
|
|
1681
|
+
getAllChats() {
|
|
1682
|
+
const rows = this.raw.prepare(
|
|
1683
|
+
`SELECT ROWID, guid, chat_identifier, display_name FROM ${Tables.CHAT} ORDER BY ROWID DESC`
|
|
1684
|
+
).all();
|
|
1685
|
+
return rows;
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Fetch message rows for a chat ROWID, ordered by date DESC.
|
|
1689
|
+
* Replaces the upstream IMessageDatabase.getMessagesFromChat().
|
|
1690
|
+
*/
|
|
1691
|
+
fetchMessagesForChatRowId(chatRowId, limit, beforeMessageId, afterMessageId) {
|
|
1692
|
+
if (beforeMessageId != null && afterMessageId != null) {
|
|
1693
|
+
const stmt2 = this.raw.prepare(`
|
|
1694
|
+
SELECT
|
|
1695
|
+
m.ROWID, m.guid, m.text, m.attributedBody, m.date,
|
|
1696
|
+
m.is_from_me, h.id as handle_id, m.cache_has_attachments
|
|
1697
|
+
FROM ${Tables.MESSAGE} m
|
|
1698
|
+
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
1699
|
+
LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
1700
|
+
WHERE cmj.chat_id = ? AND m.ROWID > ? AND m.ROWID < ?
|
|
1701
|
+
ORDER BY m.date DESC, m.ROWID DESC
|
|
1702
|
+
LIMIT ?
|
|
1703
|
+
`);
|
|
1704
|
+
return stmt2.all(chatRowId, afterMessageId, beforeMessageId, limit);
|
|
1705
|
+
}
|
|
1706
|
+
if (beforeMessageId != null) {
|
|
1707
|
+
const stmt2 = this.raw.prepare(`
|
|
1708
|
+
SELECT
|
|
1709
|
+
m.ROWID, m.guid, m.text, m.attributedBody, m.date,
|
|
1710
|
+
m.is_from_me, h.id as handle_id, m.cache_has_attachments
|
|
1711
|
+
FROM ${Tables.MESSAGE} m
|
|
1712
|
+
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
1713
|
+
LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
1714
|
+
WHERE cmj.chat_id = ? AND m.ROWID < ?
|
|
1715
|
+
ORDER BY m.date DESC, m.ROWID DESC
|
|
1716
|
+
LIMIT ?
|
|
1717
|
+
`);
|
|
1718
|
+
return stmt2.all(chatRowId, beforeMessageId, limit);
|
|
1719
|
+
}
|
|
1720
|
+
if (afterMessageId != null) {
|
|
1721
|
+
const stmt2 = this.raw.prepare(`
|
|
1722
|
+
SELECT
|
|
1723
|
+
m.ROWID, m.guid, m.text, m.attributedBody, m.date,
|
|
1724
|
+
m.is_from_me, h.id as handle_id, m.cache_has_attachments
|
|
1725
|
+
FROM ${Tables.MESSAGE} m
|
|
1726
|
+
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
1727
|
+
LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
1728
|
+
WHERE cmj.chat_id = ? AND m.ROWID > ?
|
|
1729
|
+
ORDER BY m.date DESC, m.ROWID DESC
|
|
1730
|
+
LIMIT ?
|
|
1731
|
+
`);
|
|
1732
|
+
return stmt2.all(chatRowId, afterMessageId, limit);
|
|
1733
|
+
}
|
|
1734
|
+
const stmt = this.raw.prepare(`
|
|
1735
|
+
SELECT
|
|
1736
|
+
m.ROWID, m.guid, m.text, m.attributedBody, m.date,
|
|
1737
|
+
m.is_from_me, h.id as handle_id, m.cache_has_attachments
|
|
1738
|
+
FROM ${Tables.MESSAGE} m
|
|
1739
|
+
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
1740
|
+
LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
1741
|
+
WHERE cmj.chat_id = ?
|
|
1742
|
+
ORDER BY m.date DESC, m.ROWID DESC
|
|
1743
|
+
LIMIT ?
|
|
1744
|
+
`);
|
|
1745
|
+
return stmt.all(chatRowId, limit);
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Extract readable text from a message row.
|
|
1749
|
+
* Prefers the plain text column; falls back to parsing the attributedBody blob.
|
|
1750
|
+
* Replaces the upstream IMessageDatabase.parseMessage().
|
|
1751
|
+
*/
|
|
1752
|
+
parseMessageText(row) {
|
|
1753
|
+
if (row.text && !isPlaceholderText(row.text)) return row.text;
|
|
1754
|
+
return extractAttributedBodyText(row.attributedBody) || null;
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* From a list of chats with last_date, return the one with the most recent message (sort by last_date desc).
|
|
1758
|
+
*/
|
|
1759
|
+
pickMostRecentChat(chats) {
|
|
1760
|
+
if (chats.length === 0) throw new Error("pickMostRecentChat requires at least one chat");
|
|
1761
|
+
if (chats.length === 1) return toChatRow(chats[0]);
|
|
1762
|
+
const sorted = chats.slice().sort((a, b) => {
|
|
1763
|
+
const aDate = a.last_date ?? 0;
|
|
1764
|
+
const bDate = b.last_date ?? 0;
|
|
1765
|
+
return bDate - aDate;
|
|
1766
|
+
});
|
|
1767
|
+
return toChatRow(sorted[0]);
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* Helper to find chat by identifier.
|
|
1771
|
+
* When multiple chats match (e.g. same number in different services), returns the one with the most recent message.
|
|
1772
|
+
*/
|
|
1773
|
+
findChatByIdentifier(identifier) {
|
|
1774
|
+
const chats = this.getAllChatsWithLastDate();
|
|
1775
|
+
const matches = chats.filter(
|
|
1776
|
+
(c) => c.chat_identifier === identifier || c.guid === identifier || c.chat_identifier != null && (c.chat_identifier.includes(identifier) || identifier.includes(c.chat_identifier))
|
|
1777
|
+
);
|
|
1778
|
+
if (matches.length === 0) return null;
|
|
1779
|
+
return this.pickMostRecentChat(matches);
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Convert a raw message row to our Message type with full extended data
|
|
1783
|
+
*/
|
|
1784
|
+
convertMessage(raw, text, chatId, extended, includeReactions = false) {
|
|
1785
|
+
const ext = extended || this.fetchExtendedMessageData(raw.ROWID);
|
|
1786
|
+
let cleanText = text || raw.text || null;
|
|
1787
|
+
if (isPlaceholderText(cleanText)) {
|
|
1788
|
+
const attributedText = extractAttributedBodyText(raw.attributedBody ?? null) || null;
|
|
1789
|
+
if (attributedText) {
|
|
1790
|
+
cleanText = attributedText;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
if (cleanText) {
|
|
1794
|
+
const re = new RegExp(
|
|
1795
|
+
`${OBJECT_REPLACEMENT_CHAR.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}+`,
|
|
1796
|
+
"g"
|
|
1797
|
+
);
|
|
1798
|
+
cleanText = cleanText.replace(re, "📎 ").replace(/\uFFFD/g, "").trim();
|
|
1799
|
+
if (!cleanText || cleanText === "📎" || /^(📎\s*)+$/.test(cleanText)) {
|
|
1800
|
+
cleanText = "(image/attachment)";
|
|
1801
|
+
}
|
|
1802
|
+
if (ext.balloon_bundle_id?.includes("URLBalloonProvider")) {
|
|
1803
|
+
cleanText = normalizeRichMetadataText(cleanText) ?? cleanText;
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
const associatedType = ext.associated_message_type ?? AssociatedMessageType.NORMAL;
|
|
1807
|
+
const isReaction = isReactionType(associatedType);
|
|
1808
|
+
let reaction;
|
|
1809
|
+
if (isReaction && ext.associated_message_guid) {
|
|
1810
|
+
const typeInfo = TAPBACK_TYPE_MAP[associatedType] || {
|
|
1811
|
+
type: "unknown",
|
|
1812
|
+
isRemoval: false
|
|
1813
|
+
};
|
|
1814
|
+
const parsed = parseAssociatedMessageGuid(ext.associated_message_guid);
|
|
1815
|
+
reaction = {
|
|
1816
|
+
type: typeInfo.type,
|
|
1817
|
+
emoji: ext.associated_message_emoji || void 0,
|
|
1818
|
+
fromHandle: raw.is_from_me ? "me" : ext.handle_id || "unknown",
|
|
1819
|
+
isRemoval: typeInfo.isRemoval,
|
|
1820
|
+
targetMessageGuid: parsed?.targetGuid || "",
|
|
1821
|
+
targetMessagePart: parsed?.partIndex || 0
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
const isReply = Boolean(ext.thread_originator_guid);
|
|
1825
|
+
let replyTo;
|
|
1826
|
+
if (isReply && ext.thread_originator_guid) {
|
|
1827
|
+
const originalText = this.getMessageTextByGuid(ext.thread_originator_guid);
|
|
1828
|
+
replyTo = {
|
|
1829
|
+
replyToGuid: ext.thread_originator_guid,
|
|
1830
|
+
replyToText: originalText
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
const richContentType = getRichContentType(ext.balloon_bundle_id || null);
|
|
1834
|
+
const hasAttachments = Boolean(ext.cache_has_attachments);
|
|
1835
|
+
let attachments;
|
|
1836
|
+
if (hasAttachments) {
|
|
1837
|
+
attachments = this.fetchAttachments(raw.ROWID);
|
|
1838
|
+
}
|
|
1839
|
+
let reactions;
|
|
1840
|
+
if (includeReactions && !isReaction) {
|
|
1841
|
+
reactions = this.fetchReactionsForMessage(raw.guid);
|
|
1842
|
+
if (reactions.length > 0) {
|
|
1843
|
+
reactions = this.consolidateReactions(reactions);
|
|
1844
|
+
}
|
|
1845
|
+
if (reactions.length === 0) reactions = void 0;
|
|
1846
|
+
}
|
|
1847
|
+
const rawHandle = raw.is_from_me ? "me" : ext.handle_id || "unknown";
|
|
1848
|
+
const displayName = rawHandle === "me" ? void 0 : this.contacts.lookupHandle(rawHandle);
|
|
1849
|
+
let richContentSummary;
|
|
1850
|
+
if (ext.message_summary_info) {
|
|
1851
|
+
richContentSummary = this.parseRichContentSummary(ext.message_summary_info);
|
|
1852
|
+
}
|
|
1853
|
+
return {
|
|
1854
|
+
id: raw.ROWID,
|
|
1855
|
+
guid: raw.guid,
|
|
1856
|
+
text: cleanText,
|
|
1857
|
+
handle: rawHandle,
|
|
1858
|
+
displayName: displayName !== rawHandle ? displayName : void 0,
|
|
1859
|
+
isFromMe: Boolean(raw.is_from_me),
|
|
1860
|
+
date: macTimestampToDate(raw.date) || /* @__PURE__ */ new Date(0),
|
|
1861
|
+
dateRead: macTimestampToDate(ext.date_read ?? null),
|
|
1862
|
+
dateDelivered: macTimestampToDate(ext.date_delivered ?? null),
|
|
1863
|
+
isRead: ext.is_read != null ? Boolean(ext.is_read) : true,
|
|
1864
|
+
isDelivered: ext.is_delivered != null ? Boolean(ext.is_delivered) : true,
|
|
1865
|
+
chatId,
|
|
1866
|
+
service: this.detectServiceForMessage(ext),
|
|
1867
|
+
isReaction,
|
|
1868
|
+
reaction,
|
|
1869
|
+
isReply,
|
|
1870
|
+
replyTo,
|
|
1871
|
+
reactions,
|
|
1872
|
+
richContentType,
|
|
1873
|
+
richContentSummary,
|
|
1874
|
+
isEdited: Boolean(ext.date_edited && ext.date_edited > 0),
|
|
1875
|
+
isRetracted: Boolean(ext.date_retracted && ext.date_retracted > 0),
|
|
1876
|
+
hasAttachments,
|
|
1877
|
+
attachments
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Consolidate reactions by removing reactions that are canceled by removal messages
|
|
1882
|
+
*/
|
|
1883
|
+
consolidateReactions(reactions) {
|
|
1884
|
+
const reactionMap = /* @__PURE__ */ new Map();
|
|
1885
|
+
for (const r of reactions) {
|
|
1886
|
+
const key = `${r.fromHandle}-${r.type}-${r.targetMessagePart}`;
|
|
1887
|
+
if (r.isRemoval) {
|
|
1888
|
+
reactionMap.delete(key);
|
|
1889
|
+
} else {
|
|
1890
|
+
reactionMap.set(key, r);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
return Array.from(reactionMap.values());
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Detect service from extended message data (handle.service column).
|
|
1897
|
+
*/
|
|
1898
|
+
detectServiceForMessage(ext) {
|
|
1899
|
+
if (ext.handle_service) {
|
|
1900
|
+
return ext.handle_service.toLowerCase().includes("sms") ? "SMS" : "iMessage";
|
|
1901
|
+
}
|
|
1902
|
+
return "iMessage";
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* Schedule a non-blocking background refresh of caches.
|
|
1906
|
+
* Call after responding to an MCP tool call or TUI refresh.
|
|
1907
|
+
*/
|
|
1908
|
+
scheduleBackgroundRefresh() {
|
|
1909
|
+
if (this.backgroundRefreshScheduled) return;
|
|
1910
|
+
this.backgroundRefreshScheduled = true;
|
|
1911
|
+
setImmediate(() => {
|
|
1912
|
+
this.backgroundRefreshScheduled = false;
|
|
1913
|
+
try {
|
|
1914
|
+
this.cachedAllChats = null;
|
|
1915
|
+
this.cachedLastByChat = null;
|
|
1916
|
+
this.cachedUnreadByChat = null;
|
|
1917
|
+
this.getAllChatsWithLastDate();
|
|
1918
|
+
this.scheduleBackgroundSlugSync();
|
|
1919
|
+
} catch {
|
|
1920
|
+
}
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
/** Resolve participant handles to display names via contacts DB. */
|
|
1924
|
+
resolveParticipantNames(handles) {
|
|
1925
|
+
return handles.map((h) => this.contacts.lookupHandle(h));
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1928
|
+
* Close database connections
|
|
1929
|
+
*/
|
|
1930
|
+
async close() {
|
|
1931
|
+
this.raw.close();
|
|
1932
|
+
this.contacts.close();
|
|
1933
|
+
this.slugStore.close();
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* Fetch extended message data using raw DB
|
|
1937
|
+
* Includes: is_read, date_read, handle_id, reaction data, reply data, edit status, attachments
|
|
1938
|
+
*/
|
|
1939
|
+
fetchExtendedMessageData(rowid) {
|
|
1940
|
+
const stmt = this.raw.prepare(`
|
|
1941
|
+
SELECT
|
|
1942
|
+
m.is_read,
|
|
1943
|
+
m.date_read,
|
|
1944
|
+
m.is_delivered,
|
|
1945
|
+
m.date_delivered,
|
|
1946
|
+
h.id as handle_id,
|
|
1947
|
+
h.service as handle_service,
|
|
1948
|
+
m.associated_message_type,
|
|
1949
|
+
m.associated_message_guid,
|
|
1950
|
+
m.associated_message_emoji,
|
|
1951
|
+
m.thread_originator_guid,
|
|
1952
|
+
m.thread_originator_part,
|
|
1953
|
+
m.balloon_bundle_id,
|
|
1954
|
+
m.item_type,
|
|
1955
|
+
m.date_edited,
|
|
1956
|
+
m.date_retracted,
|
|
1957
|
+
m.cache_has_attachments,
|
|
1958
|
+
m.message_summary_info,
|
|
1959
|
+
m.payload_data
|
|
1960
|
+
FROM ${Tables.MESSAGE} m
|
|
1961
|
+
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
1962
|
+
WHERE m.ROWID = ?
|
|
1963
|
+
LIMIT 1
|
|
1964
|
+
`);
|
|
1965
|
+
return stmt.get(rowid) || {};
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* Batch-fetch extended message data for multiple ROWIDs in a single query.
|
|
1969
|
+
* Falls back to individual fetches for very large batches (SQLite bind param limit).
|
|
1970
|
+
*/
|
|
1971
|
+
fetchExtendedMessageDataBatch(rowids) {
|
|
1972
|
+
const result = /* @__PURE__ */ new Map();
|
|
1973
|
+
if (rowids.length === 0) return result;
|
|
1974
|
+
const CHUNK = 500;
|
|
1975
|
+
for (let i = 0; i < rowids.length; i += CHUNK) {
|
|
1976
|
+
const chunk = rowids.slice(i, i + CHUNK);
|
|
1977
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
1978
|
+
const stmt = this.raw.prepare(`
|
|
1979
|
+
SELECT
|
|
1980
|
+
m.ROWID as _rowid,
|
|
1981
|
+
m.is_read, m.date_read, m.is_delivered, m.date_delivered,
|
|
1982
|
+
h.id as handle_id, h.service as handle_service,
|
|
1983
|
+
m.associated_message_type, m.associated_message_guid,
|
|
1984
|
+
m.associated_message_emoji, m.thread_originator_guid,
|
|
1985
|
+
m.thread_originator_part, m.balloon_bundle_id,
|
|
1986
|
+
m.item_type, m.date_edited, m.date_retracted,
|
|
1987
|
+
m.cache_has_attachments, m.message_summary_info, m.payload_data
|
|
1988
|
+
FROM ${Tables.MESSAGE} m
|
|
1989
|
+
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
1990
|
+
WHERE m.ROWID IN (${placeholders})
|
|
1991
|
+
`);
|
|
1992
|
+
const rows = stmt.all(...chunk);
|
|
1993
|
+
for (const row of rows) {
|
|
1994
|
+
result.set(row._rowid, row);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
return result;
|
|
1998
|
+
}
|
|
1999
|
+
/** Max ROWID currently in the message table — used as a cache key. */
|
|
2000
|
+
getMaxMessageRowId() {
|
|
2001
|
+
const row = this.raw.prepare(`SELECT COALESCE(MAX(ROWID), 0) AS m FROM ${Tables.MESSAGE}`).get();
|
|
2002
|
+
return Number(row.m) || 0;
|
|
2003
|
+
}
|
|
2004
|
+
/**
|
|
2005
|
+
* Fetch every message newer than `cutoffMs` across all chats. Used by the
|
|
2006
|
+
* chat_analytics tool — bounded by date, not by per-chat limit. Reactions
|
|
2007
|
+
* are included (tapback analytics need them); hidden system items dropped.
|
|
2008
|
+
*/
|
|
2009
|
+
async getMessagesInWindow(cutoffMs, capPerWindow = 2e5) {
|
|
2010
|
+
const span = perf("getMessagesInWindow");
|
|
2011
|
+
const cutoffNanos = Math.floor((cutoffMs / 1e3 - MAC_EPOCH_OFFSET) * NANOS_PER_SECOND);
|
|
2012
|
+
const sql = `
|
|
2013
|
+
SELECT m.ROWID, m.guid, m.text, m.date, m.date_read, m.date_delivered,
|
|
2014
|
+
m.is_read, m.is_delivered, m.is_from_me, h.id as handle_id,
|
|
2015
|
+
m.cache_has_attachments, m.associated_message_type,
|
|
2016
|
+
m.associated_message_guid, m.associated_message_emoji,
|
|
2017
|
+
m.item_type, c.chat_identifier
|
|
2018
|
+
FROM ${Tables.MESSAGE} m
|
|
2019
|
+
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
2020
|
+
LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
2021
|
+
LEFT JOIN ${Tables.CHAT} c ON cmj.chat_id = c.ROWID
|
|
2022
|
+
WHERE m.date >= ?
|
|
2023
|
+
ORDER BY m.date ASC
|
|
2024
|
+
LIMIT ?
|
|
2025
|
+
`;
|
|
2026
|
+
const rows = this.raw.prepare(sql).all(cutoffNanos, capPerWindow);
|
|
2027
|
+
const out = [];
|
|
2028
|
+
for (const r of rows) {
|
|
2029
|
+
if (isHiddenSystemItem(r.item_type)) continue;
|
|
2030
|
+
const text = this.parseMessageText(r);
|
|
2031
|
+
const ext = { item_type: r.item_type };
|
|
2032
|
+
const msg = this.convertMessage(r, text, r.chat_identifier || "", ext);
|
|
2033
|
+
out.push(msg);
|
|
2034
|
+
}
|
|
2035
|
+
span.end({ cutoffMs, returned: out.length });
|
|
2036
|
+
return out;
|
|
2037
|
+
}
|
|
2038
|
+
/**
|
|
2039
|
+
* Search attachments by MIME prefix, date window, and/or chat identifier.
|
|
2040
|
+
* Excludes stickers (is_sticker=1) and Apple plugin-payload UTIs which
|
|
2041
|
+
* aren't meaningful as user-facing attachments.
|
|
2042
|
+
*
|
|
2043
|
+
* Returns metadata only — use getAttachmentByRowId for the file bytes.
|
|
2044
|
+
*/
|
|
2045
|
+
searchAttachments(opts) {
|
|
2046
|
+
const span = perf("searchAttachments");
|
|
2047
|
+
const conds = [
|
|
2048
|
+
"a.is_sticker = 0",
|
|
2049
|
+
"(a.uti IS NULL OR a.uti NOT LIKE 'com.apple.messages.plugin%')"
|
|
2050
|
+
];
|
|
2051
|
+
const params = [];
|
|
2052
|
+
if (opts.mimePrefix) {
|
|
2053
|
+
conds.push("a.mime_type LIKE ?");
|
|
2054
|
+
params.push(`${opts.mimePrefix}%`);
|
|
2055
|
+
}
|
|
2056
|
+
if (opts.chatIdentifier) {
|
|
2057
|
+
conds.push("c.chat_identifier = ?");
|
|
2058
|
+
params.push(opts.chatIdentifier);
|
|
2059
|
+
}
|
|
2060
|
+
const toMacNanos = (ms) => Math.floor((ms / 1e3 - MAC_EPOCH_OFFSET) * NANOS_PER_SECOND);
|
|
2061
|
+
if (opts.sinceMs !== void 0) {
|
|
2062
|
+
conds.push("a.created_date >= ?");
|
|
2063
|
+
params.push(toMacNanos(opts.sinceMs));
|
|
2064
|
+
}
|
|
2065
|
+
if (opts.untilMs !== void 0) {
|
|
2066
|
+
conds.push("a.created_date <= ?");
|
|
2067
|
+
params.push(toMacNanos(opts.untilMs));
|
|
2068
|
+
}
|
|
2069
|
+
const lim = opts.limit > 0 ? opts.limit : 1e3;
|
|
2070
|
+
params.push(lim);
|
|
2071
|
+
const sql = `
|
|
2072
|
+
SELECT
|
|
2073
|
+
a.ROWID as rowId,
|
|
2074
|
+
a.filename,
|
|
2075
|
+
a.mime_type,
|
|
2076
|
+
a.transfer_name,
|
|
2077
|
+
a.total_bytes,
|
|
2078
|
+
a.created_date,
|
|
2079
|
+
c.chat_identifier
|
|
2080
|
+
FROM ${Tables.ATTACHMENT} a
|
|
2081
|
+
JOIN ${Tables.MESSAGE_ATTACHMENT_JOIN} maj ON a.ROWID = maj.attachment_id
|
|
2082
|
+
JOIN ${Tables.MESSAGE} m ON maj.message_id = m.ROWID
|
|
2083
|
+
JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
2084
|
+
JOIN ${Tables.CHAT} c ON cmj.chat_id = c.ROWID
|
|
2085
|
+
WHERE ${conds.join(" AND ")}
|
|
2086
|
+
ORDER BY a.created_date DESC
|
|
2087
|
+
LIMIT ?
|
|
2088
|
+
`;
|
|
2089
|
+
const rows = this.raw.prepare(sql).all(...params);
|
|
2090
|
+
const out = rows.map((r) => ({
|
|
2091
|
+
rowId: Number(r.rowId),
|
|
2092
|
+
filename: r.filename || "",
|
|
2093
|
+
mimeType: r.mime_type ?? null,
|
|
2094
|
+
transferName: r.transfer_name ?? null,
|
|
2095
|
+
totalBytes: Number(r.total_bytes) || 0,
|
|
2096
|
+
createdDate: macTimestampToDate$1(Number(r.created_date)) ?? /* @__PURE__ */ new Date(0),
|
|
2097
|
+
chatId: r.chat_identifier || ""
|
|
2098
|
+
}));
|
|
2099
|
+
span.end({ count: out.length });
|
|
2100
|
+
return out;
|
|
2101
|
+
}
|
|
2102
|
+
/** Fetch a single attachment record by ROWID. */
|
|
2103
|
+
getAttachmentByRowId(rowId) {
|
|
2104
|
+
const row = this.raw.prepare(
|
|
2105
|
+
`SELECT ROWID as rowId, filename, mime_type, transfer_name, total_bytes
|
|
2106
|
+
FROM ${Tables.ATTACHMENT}
|
|
2107
|
+
WHERE ROWID = ?`
|
|
2108
|
+
).get(rowId);
|
|
2109
|
+
if (!row) return null;
|
|
2110
|
+
return {
|
|
2111
|
+
rowId: Number(row.rowId),
|
|
2112
|
+
filename: row.filename || "",
|
|
2113
|
+
mimeType: row.mime_type ?? null,
|
|
2114
|
+
transferName: row.transfer_name ?? null,
|
|
2115
|
+
totalBytes: Number(row.total_bytes) || 0
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Fetch attachments for a message
|
|
2120
|
+
*/
|
|
2121
|
+
fetchAttachments(messageRowId) {
|
|
2122
|
+
const stmt = this.raw.prepare(`
|
|
2123
|
+
SELECT
|
|
2124
|
+
a.filename,
|
|
2125
|
+
a.mime_type,
|
|
2126
|
+
a.transfer_name,
|
|
2127
|
+
a.total_bytes
|
|
2128
|
+
FROM ${Tables.ATTACHMENT} a
|
|
2129
|
+
JOIN ${Tables.MESSAGE_ATTACHMENT_JOIN} maj ON a.ROWID = maj.attachment_id
|
|
2130
|
+
WHERE maj.message_id = ?
|
|
2131
|
+
`);
|
|
2132
|
+
const rows = stmt.all(messageRowId);
|
|
2133
|
+
return rows.map((r) => ({
|
|
2134
|
+
filename: r.filename || "",
|
|
2135
|
+
mimeType: r.mime_type,
|
|
2136
|
+
transferName: r.transfer_name,
|
|
2137
|
+
totalBytes: r.total_bytes || 0
|
|
2138
|
+
}));
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Batch-fetch all reactions in a chat, grouped by target message GUID.
|
|
2142
|
+
* One query replaces N individual LIKE queries (the main perf bottleneck).
|
|
2143
|
+
*/
|
|
2144
|
+
fetchReactionsForChat(chatRowId) {
|
|
2145
|
+
const stmt = this.raw.prepare(`
|
|
2146
|
+
SELECT
|
|
2147
|
+
m.associated_message_type,
|
|
2148
|
+
m.associated_message_guid,
|
|
2149
|
+
m.associated_message_emoji,
|
|
2150
|
+
h.id as handle_id,
|
|
2151
|
+
m.is_from_me
|
|
2152
|
+
FROM ${Tables.MESSAGE} m
|
|
2153
|
+
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
2154
|
+
JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
2155
|
+
WHERE cmj.chat_id = ?
|
|
2156
|
+
AND m.associated_message_type >= 2000
|
|
2157
|
+
`);
|
|
2158
|
+
const rows = stmt.all(chatRowId);
|
|
2159
|
+
const result = /* @__PURE__ */ new Map();
|
|
2160
|
+
for (const r of rows) {
|
|
2161
|
+
const typeInfo = TAPBACK_TYPE_MAP[r.associated_message_type] || {
|
|
2162
|
+
type: "unknown",
|
|
2163
|
+
isRemoval: false
|
|
2164
|
+
};
|
|
2165
|
+
const parsed = parseAssociatedMessageGuid(r.associated_message_guid);
|
|
2166
|
+
const targetGuid = parsed?.targetGuid;
|
|
2167
|
+
if (!targetGuid) continue;
|
|
2168
|
+
const reaction = {
|
|
2169
|
+
type: typeInfo.type,
|
|
2170
|
+
emoji: r.associated_message_emoji || void 0,
|
|
2171
|
+
fromHandle: r.is_from_me ? "me" : r.handle_id || "unknown",
|
|
2172
|
+
isRemoval: typeInfo.isRemoval,
|
|
2173
|
+
targetMessageGuid: targetGuid,
|
|
2174
|
+
targetMessagePart: parsed?.partIndex || 0
|
|
2175
|
+
};
|
|
2176
|
+
const existing = result.get(targetGuid);
|
|
2177
|
+
if (existing) existing.push(reaction);
|
|
2178
|
+
else result.set(targetGuid, [reaction]);
|
|
2179
|
+
}
|
|
2180
|
+
return result;
|
|
2181
|
+
}
|
|
2182
|
+
/**
|
|
2183
|
+
* Fetch reactions for a specific message GUID
|
|
2184
|
+
*/
|
|
2185
|
+
fetchReactionsForMessage(messageGuid) {
|
|
2186
|
+
const stmt = this.raw.prepare(`
|
|
2187
|
+
SELECT
|
|
2188
|
+
m.associated_message_type,
|
|
2189
|
+
m.associated_message_guid,
|
|
2190
|
+
m.associated_message_emoji,
|
|
2191
|
+
h.id as handle_id,
|
|
2192
|
+
m.is_from_me
|
|
2193
|
+
FROM ${Tables.MESSAGE} m
|
|
2194
|
+
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
2195
|
+
WHERE m.associated_message_guid LIKE ?
|
|
2196
|
+
AND m.associated_message_type >= 2000
|
|
2197
|
+
`);
|
|
2198
|
+
const rows = stmt.all(`%/${messageGuid}`);
|
|
2199
|
+
return rows.map((r) => {
|
|
2200
|
+
const typeInfo = TAPBACK_TYPE_MAP[r.associated_message_type] || {
|
|
2201
|
+
type: "unknown",
|
|
2202
|
+
isRemoval: false
|
|
2203
|
+
};
|
|
2204
|
+
const parsed = parseAssociatedMessageGuid(r.associated_message_guid);
|
|
2205
|
+
return {
|
|
2206
|
+
type: typeInfo.type,
|
|
2207
|
+
emoji: r.associated_message_emoji || void 0,
|
|
2208
|
+
fromHandle: r.is_from_me ? "me" : r.handle_id || "unknown",
|
|
2209
|
+
isRemoval: typeInfo.isRemoval,
|
|
2210
|
+
targetMessageGuid: parsed?.targetGuid || messageGuid,
|
|
2211
|
+
targetMessagePart: parsed?.partIndex || 0
|
|
2212
|
+
};
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
/**
|
|
2216
|
+
* Parse rich content summary from message_summary_info BLOB
|
|
2217
|
+
* This contains metadata about link previews, rich messages, etc.
|
|
2218
|
+
*/
|
|
2219
|
+
parseRichContentSummary(blob) {
|
|
2220
|
+
if (!blob) return void 0;
|
|
2221
|
+
try {
|
|
2222
|
+
const str = blob.toString("utf8");
|
|
2223
|
+
const urlMatch = str.match(/https?:\/\/\S+/);
|
|
2224
|
+
if (urlMatch) {
|
|
2225
|
+
return `Link: ${urlMatch[0]}`;
|
|
2226
|
+
}
|
|
2227
|
+
const titleMatch = str.match(/<string>([^<]+)<\/string>/);
|
|
2228
|
+
if (titleMatch) {
|
|
2229
|
+
return titleMatch[1];
|
|
2230
|
+
}
|
|
2231
|
+
return "[Rich Content]";
|
|
2232
|
+
} catch {
|
|
2233
|
+
return void 0;
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* Look up the text of a message by GUID (for reply context)
|
|
2238
|
+
* Falls back to parsing attributedBody if text is null
|
|
2239
|
+
*/
|
|
2240
|
+
getMessageTextByRowId(rowId) {
|
|
2241
|
+
const row = this.raw.prepare(`SELECT ROWID, text, attributedBody FROM ${Tables.MESSAGE} WHERE ROWID = ? LIMIT 1`).get(rowId);
|
|
2242
|
+
return this.extractMessageText(row);
|
|
2243
|
+
}
|
|
2244
|
+
getMessageTextByGuid(guid) {
|
|
2245
|
+
const row = this.raw.prepare(`SELECT ROWID, text, attributedBody FROM ${Tables.MESSAGE} WHERE guid = ? LIMIT 1`).get(guid);
|
|
2246
|
+
return this.extractMessageText(row);
|
|
2247
|
+
}
|
|
2248
|
+
extractMessageText(row) {
|
|
2249
|
+
if (!row) return null;
|
|
2250
|
+
if (row.text && !isPlaceholderText(row.text)) return row.text;
|
|
2251
|
+
return extractAttributedBodyText(row.attributedBody) || null;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
const imessageDb = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
2255
|
+
__proto__: null,
|
|
2256
|
+
IMessageDB
|
|
2257
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
2258
|
+
export {
|
|
2259
|
+
IMessageDB as I,
|
|
2260
|
+
hasNativeModule as h,
|
|
2261
|
+
imessageDb as i
|
|
2262
|
+
};
|
|
2263
|
+
//# sourceMappingURL=imessage-db-BVDtx0Sn.js.map
|