@mgcrea/mcp-apple-messages 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +29 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +806 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/server-DH4U3LBX.js +2137 -0
- package/dist/server-DH4U3LBX.js.map +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,2137 @@
|
|
|
1
|
+
import { AppleAutomationError, AppleAutomationError as AppleMessagesError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, IndexUnavailableError as IndexUnavailableError$1, PreconditionError, SchemaDriftError, SchemaDriftError as SchemaDriftError$1, columnsOf, confirmArg, createOsascriptRunner, describeStore, escapeLike, fail, fingerprintSchema, limitArg, ok, openReadOnly, parseBool, parseConfig, parseIntOpt, promptArg, readPackageIdentity, registerSurfaceResources, registerWorkflowPrompt, requiredPromptArg, trimmed, withBusyRetry, wrap, wrapResult } from "@mgcrea/mcp-apple-core";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
5
|
+
import { AppleContactsClient, emailKey, handleKind, loadConfig, suffixKey } from "@mgcrea/mcp-apple-contacts";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
//#region src/build-info.ts
|
|
9
|
+
const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
|
|
10
|
+
name: "@mgcrea/mcp-apple-messages",
|
|
11
|
+
version: "0.0.0"
|
|
12
|
+
});
|
|
13
|
+
const BUILD_INFO = {
|
|
14
|
+
name: pkg.name,
|
|
15
|
+
version: pkg.version,
|
|
16
|
+
gitCommit: "b0f25be",
|
|
17
|
+
gitCommitDate: "2026-08-26T18:45:17+02:00"
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/client/dates.ts
|
|
21
|
+
/**
|
|
22
|
+
* Messages' dates, which do not fit in a JavaScript number.
|
|
23
|
+
*
|
|
24
|
+
* `docs/messages.md` measured every populated date column as **nanoseconds since
|
|
25
|
+
* 2001-01-01** — eighteen digits, about 7.9e17, two orders of magnitude past
|
|
26
|
+
* `Number.MAX_SAFE_INTEGER`. `node:sqlite` throws on those rather than
|
|
27
|
+
* truncating, which is correct of it and fatal to a naive `SELECT date`.
|
|
28
|
+
*
|
|
29
|
+
* The failure mode is what makes this worth its own module. Wrapped in the usual
|
|
30
|
+
* try/catch the throw is swallowed and the column reports as EMPTY — the probe
|
|
31
|
+
* did exactly that on its first granted run and announced "no dates present" for
|
|
32
|
+
* all seven columns across 97,414 messages. A section written to catch a silent
|
|
33
|
+
* 31-year error was itself silently wrong.
|
|
34
|
+
*
|
|
35
|
+
* ## The fix: divide in SQL, not in JavaScript
|
|
36
|
+
*
|
|
37
|
+
* `CAST(date AS REAL) / 1e9` never materialises the integer on the JS side, so
|
|
38
|
+
* there is nothing to throw. Precision is fine: 7.9e8 seconds with a fractional
|
|
39
|
+
* part is comfortably inside a double, and this surface has no use for
|
|
40
|
+
* nanosecond resolution anyway.
|
|
41
|
+
*
|
|
42
|
+
* Every query in `store.ts` uses `APPLE_SECONDS_SQL`. Reading one of these
|
|
43
|
+
* columns any other way is the bug.
|
|
44
|
+
*/
|
|
45
|
+
/** Seconds between 1970-01-01 and 2001-01-01. Same constant as `packages/core`. */
|
|
46
|
+
/**
|
|
47
|
+
* Below this, a value is seconds rather than nanoseconds.
|
|
48
|
+
*
|
|
49
|
+
* Messages switched around macOS 10.13 and old rows were not rewritten, so a
|
|
50
|
+
* store with history from both eras carries both. 1e12 apple-seconds is the year
|
|
51
|
+
* 33,658 and 1e12 apple-nanoseconds is 2001 — no real timestamp is near it in
|
|
52
|
+
* either reading, which is what makes the split safe.
|
|
53
|
+
*/
|
|
54
|
+
const NANOSECOND_FLOOR = 0xe8d4a51000;
|
|
55
|
+
/**
|
|
56
|
+
* SQL that yields apple-SECONDS as a REAL, whichever unit the row holds.
|
|
57
|
+
*
|
|
58
|
+
* Done in SQL rather than in JS because the point is to never let the raw
|
|
59
|
+
* integer reach `node:sqlite`'s value conversion.
|
|
60
|
+
*/
|
|
61
|
+
const appleSecondsSql = (column) => `CASE WHEN ${column} IS NULL THEN NULL WHEN ABS(CAST(${column} AS REAL)) > ${NANOSECOND_FLOOR} THEN CAST(${column} AS REAL) / 1000000000.0 ELSE CAST(${column} AS REAL) END`;
|
|
62
|
+
/** Apple-seconds to a JS Date. Null in, null out. */
|
|
63
|
+
const fromAppleSeconds = (value) => {
|
|
64
|
+
if (value === null || !Number.isFinite(value) || value === 0) return null;
|
|
65
|
+
return /* @__PURE__ */ new Date((value + CORE_DATA_EPOCH_OFFSET) * 1e3);
|
|
66
|
+
};
|
|
67
|
+
/** A JS Date to apple-seconds, for range bounds. */
|
|
68
|
+
const toAppleSeconds = (date) => date.getTime() / 1e3 - CORE_DATA_EPOCH_OFFSET;
|
|
69
|
+
/** ISO-8601, or null. What every date field on a result carries. */
|
|
70
|
+
const renderInstant = (value) => fromAppleSeconds(value)?.toISOString() ?? null;
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/client/errors.ts
|
|
73
|
+
/**
|
|
74
|
+
* Messages' error surface. The taxonomy lives in `@mgcrea/mcp-apple-core`; what
|
|
75
|
+
* belongs here is the identity those messages are written against.
|
|
76
|
+
*/
|
|
77
|
+
const MESSAGES_SURFACE = {
|
|
78
|
+
appName: "Messages",
|
|
79
|
+
envPrefix: "APPLE_MESSAGES"
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Used by exactly one code path, and unused by every read.
|
|
83
|
+
*
|
|
84
|
+
* Messages is the one surface with NO Apple Events read lane at all — measured,
|
|
85
|
+
* not assumed. `docs/messages.md` records every attempt failing, and the reason
|
|
86
|
+
* is peculiar enough to write down: Messages answers "Application isn't running"
|
|
87
|
+
* while `NSRunningApplication` reports it running, because it lives as a
|
|
88
|
+
* windowless background process that declines to wake for a script. The liveness
|
|
89
|
+
* check and the app's own answer disagree and neither is lying.
|
|
90
|
+
*
|
|
91
|
+
* The id is the liveness check for `send`, which is the only thing Apple Events
|
|
92
|
+
* can do on this surface — see `client/jxa/core.ts`.
|
|
93
|
+
*/
|
|
94
|
+
const MESSAGES_BUNDLE_ID = "com.apple.MobileSMS";
|
|
95
|
+
/** A message ref no longer resolves — deleted, or the chat was cleared. */
|
|
96
|
+
var MessageNotFoundError = class extends AppleAutomationError {
|
|
97
|
+
name = "MessageNotFoundError";
|
|
98
|
+
constructor(ref) {
|
|
99
|
+
super(`No message for ref "${ref}". It was probably deleted since the search ran. Re-run the search to get a current ref.`, { ref });
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
/** A chat ref no longer resolves. */
|
|
103
|
+
var ChatNotFoundError = class extends AppleAutomationError {
|
|
104
|
+
name = "ChatNotFoundError";
|
|
105
|
+
constructor(ref) {
|
|
106
|
+
super(`No chat for ref "${ref}". Use apple_messages_list_chats to get a current ref.`, { ref });
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* The store could not be read.
|
|
111
|
+
*
|
|
112
|
+
* Its own error because this surface fails harder than any other: there is no
|
|
113
|
+
* Apple Events fallback, so without Full Disk Access there is no server at all.
|
|
114
|
+
* `docs/distribution.md`'s "try before you grant" was retired partly because of
|
|
115
|
+
* this surface, and the message says so rather than implying a degraded mode
|
|
116
|
+
* that does not exist.
|
|
117
|
+
*/
|
|
118
|
+
var MessagesUnavailableError = class extends AppleAutomationError {
|
|
119
|
+
name = "MessagesUnavailableError";
|
|
120
|
+
constructor(reason) {
|
|
121
|
+
super(reason, {});
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Messages would not accept any form of recipient for a send.
|
|
126
|
+
*
|
|
127
|
+
* Its own error because the cause is almost never the recipient. Every rung of
|
|
128
|
+
* the ladder in `client/jxa/core.ts` except the first one enumerates something,
|
|
129
|
+
* and enumeration is exactly what this app refuses — so the usual cause of this
|
|
130
|
+
* error is that the chat is new (no guid in the store to address it by) rather
|
|
131
|
+
* than that the person does not exist. The message says so, because "not found"
|
|
132
|
+
* would send a caller looking for a typo that is not there.
|
|
133
|
+
*/
|
|
134
|
+
var SendTargetNotFoundError = class extends AppleAutomationError {
|
|
135
|
+
name = "SendTargetNotFoundError";
|
|
136
|
+
constructor(recipient, attempts) {
|
|
137
|
+
super(`Messages would not resolve "${recipient}" to a chat or participant, so nothing was sent. This usually means there is no existing conversation with them on this Mac: Messages refuses to enumerate participants for a script, so an existing chat is the only handle this server can address reliably. Open the conversation once in Messages.app and retry.`, {
|
|
138
|
+
recipient,
|
|
139
|
+
attempts: [...attempts]
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
/** Messages accepted the target and then refused the send itself. */
|
|
144
|
+
var SendFailedError = class extends AppleAutomationError {
|
|
145
|
+
name = "SendFailedError";
|
|
146
|
+
constructor(message, attempts) {
|
|
147
|
+
super(`Messages refused the send: ${message}`, { attempts: [...attempts] });
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/client/jxa/core.ts
|
|
152
|
+
/**
|
|
153
|
+
* The Apple Events lane for Messages — which exists for exactly one verb.
|
|
154
|
+
*
|
|
155
|
+
* Every script here is a static constant. None may contain a template
|
|
156
|
+
* interpolation: `assertStaticScript` rejects any script containing a dollar
|
|
157
|
+
* sign followed by a brace, including one written inside the JXA source. Use
|
|
158
|
+
* string concatenation in JXA code.
|
|
159
|
+
*
|
|
160
|
+
* Contract, shared with every other surface:
|
|
161
|
+
* - parameters arrive as `JSON.parse(argv[0])`
|
|
162
|
+
* - success returns `JSON.stringify({ok: true, data})`
|
|
163
|
+
* - an application-level failure returns `{ok: false, error: {code, message}}`
|
|
164
|
+
* and still exits 0, so a non-zero exit always means infrastructure.
|
|
165
|
+
*
|
|
166
|
+
* ## There is no read.ts here, and there never can be
|
|
167
|
+
*
|
|
168
|
+
* Adding a send did not add a read lane, and on this surface that is not a
|
|
169
|
+
* policy choice — it is the measurement in `docs/messages.md`:
|
|
170
|
+
*
|
|
171
|
+
* | attempt | result |
|
|
172
|
+
* | ---------------- | ----------------------------------------- |
|
|
173
|
+
* | `chats()` | `Error: Application isn't running.` |
|
|
174
|
+
* | `chats.id()` | `TypeError: M.chats.id is not a function` |
|
|
175
|
+
* | `participants()` | `Error: Application isn't running.` |
|
|
176
|
+
* | `buddies()` | `Error: Application isn't running.` |
|
|
177
|
+
* | messages of chat | `Error: Application isn't running.` |
|
|
178
|
+
*
|
|
179
|
+
* Messages answers "Application isn't running" while `NSRunningApplication`
|
|
180
|
+
* reports it running, because it lives as a windowless background process that
|
|
181
|
+
* declines to wake for a script. `test/jxa.test.ts` asserts `read.ts` does not
|
|
182
|
+
* exist, so this cannot be re-added out of helpfulness.
|
|
183
|
+
*
|
|
184
|
+
* The consequence for the code below is concrete: **the target resolution steps
|
|
185
|
+
* that enumerate anything are expected to fail**, which is why they are a ladder
|
|
186
|
+
* and why every rung reports itself. The one rung that does not enumerate —
|
|
187
|
+
* `chats.byId(guid)`, with the guid handed over by the file lane — is the one
|
|
188
|
+
* this design is built around.
|
|
189
|
+
*
|
|
190
|
+
* ## What the dictionary actually offers
|
|
191
|
+
*
|
|
192
|
+
* MEASURED from `sdef /System/Applications/Messages.app` on macOS 26.6. Three
|
|
193
|
+
* commands, and only one of them is a write:
|
|
194
|
+
*
|
|
195
|
+
* send text (or a file) to a participant or a chat
|
|
196
|
+
* login log in to all accounts
|
|
197
|
+
* logout log out of all accounts
|
|
198
|
+
*
|
|
199
|
+
* `login`/`logout` are not exposed as tools: logging a user out of iMessage on
|
|
200
|
+
* every device is not something to do behind a tool call, and there is no read
|
|
201
|
+
* to justify logging in.
|
|
202
|
+
*
|
|
203
|
+
* `send`'s direct parameter is typed `file` OR `text`. **This ships text only.**
|
|
204
|
+
* The file form is one branch away and deliberately not taken: a tool that
|
|
205
|
+
* transfers an arbitrary local path to a remote person is an exfiltration
|
|
206
|
+
* primitive, and unlike the text form its blast radius is not bounded by what
|
|
207
|
+
* the model can say. Recorded here so the omission reads as a decision.
|
|
208
|
+
*
|
|
209
|
+
* ## The `to` parameter, and why the file lane picks the target
|
|
210
|
+
*
|
|
211
|
+
* `send` takes a `participant` or a `chat`. Getting one of those normally means
|
|
212
|
+
* enumerating, which is the thing that does not work here. But the `chat` class
|
|
213
|
+
* carries `id` — "A guid identifier for this chat" — and the file lane already
|
|
214
|
+
* holds `chat.guid` for all 1,027 chats on the measured store. So the read lane
|
|
215
|
+
* chooses the target and the write lane addresses it by id, which is the only
|
|
216
|
+
* arrangement where neither lane has to do the thing it cannot.
|
|
217
|
+
*
|
|
218
|
+
* That the two guids are the same string is NOT assumed. It is the exact class
|
|
219
|
+
* of thing this project has been wrong about before — `docs/messages.md` calls
|
|
220
|
+
* the id bridge "unanswerable by construction" because Messages returned no
|
|
221
|
+
* identifier to bridge FROM. So `chats.byId` is rung one of a ladder, every rung
|
|
222
|
+
* records why it failed, and the strategy that worked is reported back to the
|
|
223
|
+
* caller in the tool result.
|
|
224
|
+
*/
|
|
225
|
+
/** The bundle id, which does not match the display name: Messages.app is still MobileSMS. */
|
|
226
|
+
const PRELUDE = `
|
|
227
|
+
ObjC.import("AppKit");
|
|
228
|
+
|
|
229
|
+
function ok(data) { return JSON.stringify({ ok: true, data: data }); }
|
|
230
|
+
function err(code, message, extra) {
|
|
231
|
+
var e = { code: code, message: String(message) };
|
|
232
|
+
if (extra) e.detail = extra;
|
|
233
|
+
return JSON.stringify({ ok: false, error: e });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Read one property defensively — every read on this surface is allowed to fail. */
|
|
237
|
+
function prop(fn, fallback) {
|
|
238
|
+
try {
|
|
239
|
+
var v = fn();
|
|
240
|
+
return v === undefined ? fallback : v;
|
|
241
|
+
} catch (e) {
|
|
242
|
+
return fallback;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isMessagesRunning() {
|
|
247
|
+
var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier("com.apple.MobileSMS");
|
|
248
|
+
return apps.count > 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The dictionary's service type enumeration: SMS, iMessage, RCS. */
|
|
252
|
+
function serviceEnum(name) {
|
|
253
|
+
var s = String(name || "").toLowerCase();
|
|
254
|
+
if (s === "sms") return "SMS";
|
|
255
|
+
if (s === "rcs") return "RCS";
|
|
256
|
+
return "iMessage";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Find something \`send\` will accept as its \`to\`.
|
|
261
|
+
*
|
|
262
|
+
* Ordered cheapest and most-likely-to-work first. Every rung is wrapped, every
|
|
263
|
+
* failure is recorded, and the caller is told which one answered — on a surface
|
|
264
|
+
* whose whole read half is known broken, "it worked" without "how" is not a
|
|
265
|
+
* result anyone can act on later.
|
|
266
|
+
*/
|
|
267
|
+
function resolveTarget(M, p, tried) {
|
|
268
|
+
var i;
|
|
269
|
+
|
|
270
|
+
// 1. The chat guid the file lane read out of chat.db. No enumeration.
|
|
271
|
+
if (p.chatGuid) {
|
|
272
|
+
try {
|
|
273
|
+
var chat = M.chats.byId(p.chatGuid);
|
|
274
|
+
chat.id();
|
|
275
|
+
return { target: chat, strategy: "chat-guid", kind: "chat" };
|
|
276
|
+
} catch (e) {
|
|
277
|
+
tried.push("chat-guid: " + (e.message || e));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!p.handle) return null;
|
|
282
|
+
|
|
283
|
+
// 2. The guid Messages composes for a one-to-one chat, spelled the way the
|
|
284
|
+
// store spells it: "iMessage;-;+15551234567". Constructed rather than read,
|
|
285
|
+
// so it is below the real one and above everything that enumerates.
|
|
286
|
+
var services = p.service ? [serviceEnum(p.service)] : ["iMessage", "SMS", "RCS"];
|
|
287
|
+
for (i = 0; i < services.length; i++) {
|
|
288
|
+
var guess = services[i] + ";-;" + p.handle;
|
|
289
|
+
try {
|
|
290
|
+
var guessed = M.chats.byId(guess);
|
|
291
|
+
guessed.id();
|
|
292
|
+
return { target: guessed, strategy: "chat-guid-guess", kind: "chat", guid: guess };
|
|
293
|
+
} catch (e2) {
|
|
294
|
+
tried.push("chat-guid-guess(" + guess + "): " + (e2.message || e2));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 3. A participant reached through its account. This enumerates, so it is
|
|
299
|
+
// expected to fail with "Application isn't running" — kept because it is
|
|
300
|
+
// the form every AppleScript example on the internet uses, and because if
|
|
301
|
+
// launching the app does wake the scripting interface, this is what works.
|
|
302
|
+
for (i = 0; i < services.length; i++) {
|
|
303
|
+
try {
|
|
304
|
+
var accounts = M.accounts.whose({ serviceType: services[i] })();
|
|
305
|
+
for (var j = 0; j < accounts.length; j++) {
|
|
306
|
+
try {
|
|
307
|
+
var buddy = accounts[j].participants.whose({ handle: p.handle })()[0];
|
|
308
|
+
if (buddy) {
|
|
309
|
+
buddy.id();
|
|
310
|
+
return { target: buddy, strategy: "account-participant", kind: "participant" };
|
|
311
|
+
}
|
|
312
|
+
} catch (e4) {
|
|
313
|
+
tried.push("account-participant(" + services[i] + "): " + (e4.message || e4));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch (e3) {
|
|
317
|
+
tried.push("accounts(" + services[i] + "): " + (e3.message || e3));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 4. The flat participant list, last because it is the widest enumeration.
|
|
322
|
+
try {
|
|
323
|
+
var flat = M.participants.whose({ handle: p.handle })()[0];
|
|
324
|
+
if (flat) {
|
|
325
|
+
flat.id();
|
|
326
|
+
return { target: flat, strategy: "participant", kind: "participant" };
|
|
327
|
+
}
|
|
328
|
+
tried.push("participant: no participant with that handle");
|
|
329
|
+
} catch (e5) {
|
|
330
|
+
tried.push("participant: " + (e5.message || e5));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
`;
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region src/client/jxa/write.ts
|
|
338
|
+
/**
|
|
339
|
+
* One script, one verb.
|
|
340
|
+
*
|
|
341
|
+
* `send` is the only mutating command in the Messages dictionary that this
|
|
342
|
+
* server exposes — see `core.ts` for the full list and for why `login`/`logout`
|
|
343
|
+
* and the file form of `send` are left out.
|
|
344
|
+
*
|
|
345
|
+
* ## What this script deliberately does NOT do
|
|
346
|
+
*
|
|
347
|
+
* It does not report success from its own read-back, because there is nothing to
|
|
348
|
+
* read back: `send` returns no value, and every read this app offers fails. A
|
|
349
|
+
* script that answered `{ok: true}` and stopped would be claiming delivery on
|
|
350
|
+
* the strength of a command that did not throw — the exact shape of "plausible,
|
|
351
|
+
* wrong and silent" this repo keeps designing against.
|
|
352
|
+
*
|
|
353
|
+
* So the script's answer is deliberately narrow: **the send command was accepted
|
|
354
|
+
* by Messages, and here is how the target was addressed.** Whether a row landed
|
|
355
|
+
* is a question for the file lane, and `client/messages.ts` asks it immediately
|
|
356
|
+
* afterwards by polling chat.db for the outgoing row. That split is the answer
|
|
357
|
+
* to the open question `docs/messages.md` left — "whether a send should
|
|
358
|
+
* re-resolve by scanning the store for a recent row on the target chat" — and it
|
|
359
|
+
* is what makes a send reportable at all on a surface with no id bridge.
|
|
360
|
+
*/
|
|
361
|
+
const SEND_MESSAGE = `${PRELUDE}
|
|
362
|
+
function run(argv) {
|
|
363
|
+
var p = JSON.parse(argv[0]);
|
|
364
|
+
var M = Application("Messages");
|
|
365
|
+
|
|
366
|
+
var wasRunning = isMessagesRunning();
|
|
367
|
+
if (!wasRunning && !p.allowLaunch) {
|
|
368
|
+
return err("APP_NOT_RUNNING", "Messages is not running.");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
var tried = [];
|
|
372
|
+
var resolved = resolveTarget(M, p, tried);
|
|
373
|
+
if (!resolved) {
|
|
374
|
+
return err(
|
|
375
|
+
"SEND_TARGET_NOT_FOUND",
|
|
376
|
+
"Messages would not resolve a chat or participant for that recipient.",
|
|
377
|
+
tried
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
M.send(p.text, { to: resolved.target });
|
|
383
|
+
} catch (e) {
|
|
384
|
+
return err("SEND_FAILED", e.message || e, tried);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return ok({
|
|
388
|
+
strategy: resolved.strategy,
|
|
389
|
+
targetKind: resolved.kind,
|
|
390
|
+
// Best effort, and allowed to be null: reading the id back is itself a read.
|
|
391
|
+
targetId: prop(function () { return String(resolved.target.id()); }, null),
|
|
392
|
+
launched: !wasRunning,
|
|
393
|
+
attempts: tried
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
`;
|
|
397
|
+
//#endregion
|
|
398
|
+
//#region src/client/locate.ts
|
|
399
|
+
/**
|
|
400
|
+
* Find Messages' store.
|
|
401
|
+
*
|
|
402
|
+
* The easiest locator in the repo: `~/Library/Messages/chat.db` is a constant,
|
|
403
|
+
* with no generated directory name to list (Reminders) and no per-account fan-out
|
|
404
|
+
* (Contacts). `statSync` succeeds on a TCC-protected file while `open` is denied,
|
|
405
|
+
* so this distinguishes "not there" from "not allowed" with no permission at all
|
|
406
|
+
* — which is most of what diagnostics is for on a surface where the grant is
|
|
407
|
+
* mandatory.
|
|
408
|
+
*/
|
|
409
|
+
const STORE_RELATIVE = join("Library", "Messages", "chat.db");
|
|
410
|
+
/** Attachment bytes live here, referenced by `attachment.filename`. */
|
|
411
|
+
const ATTACHMENTS_RELATIVE = join("Library", "Messages", "Attachments");
|
|
412
|
+
/**
|
|
413
|
+
* The directory an attachment path is required to sit inside.
|
|
414
|
+
*
|
|
415
|
+
* Deliberately the Messages root rather than `Attachments` itself. Measured
|
|
416
|
+
* shapes of `attachment.filename` include stickers, which Messages keeps in a
|
|
417
|
+
* sibling directory, so confining to `Attachments` would refuse real files —
|
|
418
|
+
* and confining to nothing at all would let a row in a database this server
|
|
419
|
+
* does not write choose which file gets copied out.
|
|
420
|
+
*/
|
|
421
|
+
const MESSAGES_ROOT_RELATIVE = join("Library", "Messages");
|
|
422
|
+
const defaultStorePath = (home = homedir()) => join(home, STORE_RELATIVE);
|
|
423
|
+
const FDA_HINT = "Grant Full Disk Access to the app running this server (System Settings > Privacy & Security > Full Disk Access) and restart it. Granting it to Messages.app does nothing — the reader needs the permission. Unlike every other surface here, there is no Apple Events fallback: Messages has no read path in its scripting dictionary at all, so without this grant there is no server.";
|
|
424
|
+
const locateStore = (opts = {}) => {
|
|
425
|
+
const home = opts.home ?? homedir();
|
|
426
|
+
const storePath = opts.storePath ?? defaultStorePath(home);
|
|
427
|
+
const facts = describeStore(storePath);
|
|
428
|
+
return {
|
|
429
|
+
...facts,
|
|
430
|
+
storePath,
|
|
431
|
+
attachmentsPath: join(home, ATTACHMENTS_RELATIVE),
|
|
432
|
+
messagesRoot: join(home, MESSAGES_ROOT_RELATIVE),
|
|
433
|
+
reason: facts.readable ? null : facts.exists ? `Found the Messages store at ${storePath} but cannot read it. ${FDA_HINT}` : opts.storePath ? `No file at ${storePath}. APPLE_MESSAGES_STORE points at nothing.` : `No Messages store at ${storePath}. Has Messages ever been set up on this account?`
|
|
434
|
+
};
|
|
435
|
+
};
|
|
436
|
+
//#endregion
|
|
437
|
+
//#region src/client/ref.ts
|
|
438
|
+
/**
|
|
439
|
+
* Refs for messages and chats.
|
|
440
|
+
*
|
|
441
|
+
* ## Why a GUID rather than a rowid
|
|
442
|
+
*
|
|
443
|
+
* Both exist, and the rowid is faster to look up. The GUID wins anyway because
|
|
444
|
+
* **rowids are reused.** SQLite hands a deleted row's id to the next insert
|
|
445
|
+
* unless the table is `AUTOINCREMENT`, and Messages deletes constantly — every
|
|
446
|
+
* "delete this conversation" frees a block of them. A ref handed to a model in
|
|
447
|
+
* one turn and used two turns later would then resolve to a DIFFERENT message,
|
|
448
|
+
* with no error anywhere. That is the failure this project keeps designing
|
|
449
|
+
* against: plausible, wrong, and silent.
|
|
450
|
+
*
|
|
451
|
+
* The GUID is also what Apple itself joins on — `associated_message_guid` for
|
|
452
|
+
* reactions, `thread_originator_guid` for replies — so it is the identifier the
|
|
453
|
+
* schema already treats as stable.
|
|
454
|
+
*
|
|
455
|
+
* ## Why `m1:` and `mc1:`
|
|
456
|
+
*
|
|
457
|
+
* `c1:` is Calendar's, `r1:` is Reminders', `k1:` is Contacts'. A ref that
|
|
458
|
+
* decodes under two surfaces would be worse than one that decodes under none,
|
|
459
|
+
* so each prefix is claimed once and the version digit keeps a future scheme
|
|
460
|
+
* change additive.
|
|
461
|
+
*/
|
|
462
|
+
const MESSAGE_REF_VERSION = "m1";
|
|
463
|
+
const CHAT_REF_VERSION = "mc1";
|
|
464
|
+
/**
|
|
465
|
+
* A GUID is opaque and not always a UUID — measured shapes include
|
|
466
|
+
* `iMessage;-;+15551234567` on chats, which carries semicolons and a phone
|
|
467
|
+
* number. So the tail is greedy and nothing inside it is parsed.
|
|
468
|
+
*/
|
|
469
|
+
const MESSAGE_PATTERN = /^m1:(.+)$/;
|
|
470
|
+
const CHAT_PATTERN = /^mc1:(.+)$/;
|
|
471
|
+
const otherSurface = (raw) => {
|
|
472
|
+
if (raw.startsWith("c1:")) return " That one is a Calendar event ref.";
|
|
473
|
+
if (raw.startsWith("r1:")) return " That one is a Reminders ref.";
|
|
474
|
+
if (raw.startsWith("k1:")) return " That one is a Contacts ref.";
|
|
475
|
+
if (raw.startsWith("mc1:")) return " That one is a CHAT ref — this wants a message ref.";
|
|
476
|
+
if (raw.startsWith("m1:")) return " That one is a MESSAGE ref — this wants a chat ref.";
|
|
477
|
+
return "";
|
|
478
|
+
};
|
|
479
|
+
var InvalidMessageRefError = class extends AppleAutomationError {
|
|
480
|
+
name = "InvalidMessageRefError";
|
|
481
|
+
constructor(raw, want) {
|
|
482
|
+
super(`"${raw}" is not a ${want} ref. Refs come from apple_messages_* results and look like ${want === "message" ? "\"m1:<guid>\"" : "\"mc1:<guid>\""} — they are opaque and must not be constructed by hand.${otherSurface(raw)}`, { ref: raw });
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
const encodeMessageRef = (guid) => `m1:${guid}`;
|
|
486
|
+
const encodeChatRef = (guid) => `mc1:${guid}`;
|
|
487
|
+
const decodeMessageRef = (raw) => {
|
|
488
|
+
const m = MESSAGE_PATTERN.exec(raw.trim());
|
|
489
|
+
if (!m?.[1]) throw new InvalidMessageRefError(raw, "message");
|
|
490
|
+
return m[1];
|
|
491
|
+
};
|
|
492
|
+
const decodeChatRef = (raw) => {
|
|
493
|
+
const m = CHAT_PATTERN.exec(raw.trim());
|
|
494
|
+
if (!m?.[1]) throw new InvalidMessageRefError(raw, "chat");
|
|
495
|
+
return m[1];
|
|
496
|
+
};
|
|
497
|
+
//#endregion
|
|
498
|
+
//#region src/client/typedstream.ts
|
|
499
|
+
/**
|
|
500
|
+
* NSArchiver `typedstream` reader — enough of it to pull the text out of a
|
|
501
|
+
* Messages `attributedBody` blob.
|
|
502
|
+
*
|
|
503
|
+
* ## Why this has to exist
|
|
504
|
+
*
|
|
505
|
+
* `docs/messages.md` measured it: 97,092 of 97,414 messages carry a blob and
|
|
506
|
+
* only 94,049 carry `text`. **The blob is the norm and `text` is the redundant
|
|
507
|
+
* copy**, not the other way round. 3,043 messages — one in thirty-two — have an
|
|
508
|
+
* empty `text` and content only in here, so a server that reads the column
|
|
509
|
+
* returns nothing for them, silently and with no error to notice.
|
|
510
|
+
*
|
|
511
|
+
* The archive header is `04 0B streamtyped 81 E8 03`. Not `bplist00`, not gzip,
|
|
512
|
+
* and not the protobuf that Notes turned out to hold, so none of the existing
|
|
513
|
+
* decoders apply.
|
|
514
|
+
*
|
|
515
|
+
* ## The format, as far as this reader needs it
|
|
516
|
+
*
|
|
517
|
+
* A stream of tagged values. Integers are a single signed byte unless prefixed:
|
|
518
|
+
*
|
|
519
|
+
* 0x81 int16 follows (little-endian)
|
|
520
|
+
* 0x82 int32 follows
|
|
521
|
+
* 0x83 float or double follows
|
|
522
|
+
* 0x84 START — a new class or object definition
|
|
523
|
+
* 0x85 nil / empty
|
|
524
|
+
* 0x86 END of the current object
|
|
525
|
+
* >=0x92 a back-reference; index = byte - 0x92
|
|
526
|
+
*
|
|
527
|
+
* Strings arrive length-prefixed. Class names and object contents both use that
|
|
528
|
+
* shape, which is why this walks structurally instead of pattern-matching: the
|
|
529
|
+
* same bytes mean different things depending on where you are.
|
|
530
|
+
*
|
|
531
|
+
* ## Honest failure, never a guess
|
|
532
|
+
*
|
|
533
|
+
* The Notes decoder was documented backwards because a `LIMIT 1` sample landed
|
|
534
|
+
* on an outlier, and this project has now been bitten three times by a heuristic
|
|
535
|
+
* that produced a plausible wrong answer. So there is no "longest printable run"
|
|
536
|
+
* fallback here. If the structure does not parse, `ok` is false and the caller
|
|
537
|
+
* counts it — and `text` remains available as the answer for the 96.5% of rows
|
|
538
|
+
* that have one.
|
|
539
|
+
*
|
|
540
|
+
* Pure and I/O-free.
|
|
541
|
+
*
|
|
542
|
+
* ## Two copies, deliberately
|
|
543
|
+
*
|
|
544
|
+
* `scripts/lib/typedstream.mjs` is the same reader, and the probe uses it to
|
|
545
|
+
* measure this one against a real store. They are kept in step by hand, as
|
|
546
|
+
* `packages/notes/src/client/protobuf.ts` and `scripts/lib/note-protobuf.mjs`
|
|
547
|
+
* already are: a probe that imported from a package would stop being runnable
|
|
548
|
+
* before that package exists, which is the wrong way round.
|
|
549
|
+
*/
|
|
550
|
+
const SIGNATURE = "streamtyped";
|
|
551
|
+
const TAG_I16 = 129;
|
|
552
|
+
const TAG_I32 = 130;
|
|
553
|
+
const TAG_DECIMAL = 131;
|
|
554
|
+
const TAG_START = 132;
|
|
555
|
+
const TAG_EMPTY = 133;
|
|
556
|
+
const TAG_END = 134;
|
|
557
|
+
/** Anything at or above this is an index into the table of things already seen. */
|
|
558
|
+
const TAG_REFERENCE = 146;
|
|
559
|
+
/** A blob larger than this is not a message; refuse rather than chew through it. */
|
|
560
|
+
const MAX_BLOB_BYTES = 4194304;
|
|
561
|
+
/**
|
|
562
|
+
* Structural guard for the walk BEFORE the text is reached — class chains and
|
|
563
|
+
* type encodings, which are a few dozen tokens on every blob measured. Reaching
|
|
564
|
+
* this means the stream is not shaped like an archived attributed string.
|
|
565
|
+
*/
|
|
566
|
+
const MAX_TOKENS = 2e4;
|
|
567
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
568
|
+
const latin1 = new TextDecoder("latin1");
|
|
569
|
+
var Cursor = class {
|
|
570
|
+
buf;
|
|
571
|
+
pos;
|
|
572
|
+
constructor(buf) {
|
|
573
|
+
this.buf = buf;
|
|
574
|
+
this.pos = 0;
|
|
575
|
+
}
|
|
576
|
+
get done() {
|
|
577
|
+
return this.pos >= this.buf.length;
|
|
578
|
+
}
|
|
579
|
+
get remaining() {
|
|
580
|
+
return this.buf.length - this.pos;
|
|
581
|
+
}
|
|
582
|
+
byte() {
|
|
583
|
+
return this.buf[this.pos++];
|
|
584
|
+
}
|
|
585
|
+
peek() {
|
|
586
|
+
return this.buf[this.pos];
|
|
587
|
+
}
|
|
588
|
+
take(n) {
|
|
589
|
+
const out = this.buf.subarray(this.pos, this.pos + n);
|
|
590
|
+
this.pos += n;
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* A byte at an offset from the cursor. Every caller checks `remaining` first,
|
|
595
|
+
* so the index is in range — this exists to say that once rather than to
|
|
596
|
+
* scatter non-null assertions through the integer readers.
|
|
597
|
+
*/
|
|
598
|
+
at(offset) {
|
|
599
|
+
return this.buf[this.pos + offset];
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
/**
|
|
603
|
+
* A signed integer, which is also how every length in the stream is written.
|
|
604
|
+
*
|
|
605
|
+
* Returns null rather than throwing on a truncated read — a truncated blob is a
|
|
606
|
+
* finding, not a crash.
|
|
607
|
+
*/
|
|
608
|
+
const readInt = (c) => {
|
|
609
|
+
if (c.done) return null;
|
|
610
|
+
const b = c.byte();
|
|
611
|
+
if (b === TAG_I16) {
|
|
612
|
+
if (c.remaining < 2) return null;
|
|
613
|
+
const v = c.at(0) | c.at(1) << 8;
|
|
614
|
+
c.pos += 2;
|
|
615
|
+
return v > 32767 ? v - 65536 : v;
|
|
616
|
+
}
|
|
617
|
+
if (b === TAG_I32) {
|
|
618
|
+
if (c.remaining < 4) return null;
|
|
619
|
+
const v = (c.at(0) | c.at(1) << 8 | c.at(2) << 16 | c.at(3) << 24) >>> 0;
|
|
620
|
+
c.pos += 4;
|
|
621
|
+
return v > 2147483647 ? v - 4294967296 : v;
|
|
622
|
+
}
|
|
623
|
+
if (b === TAG_DECIMAL) return null;
|
|
624
|
+
return b > 127 ? b - 256 : b;
|
|
625
|
+
};
|
|
626
|
+
/** A length-prefixed string. `null` when the bytes are not valid UTF-8. */
|
|
627
|
+
const readString = (c) => {
|
|
628
|
+
const len = readInt(c);
|
|
629
|
+
if (len === null || len < 0 || len > c.remaining) return null;
|
|
630
|
+
const bytes = c.take(len);
|
|
631
|
+
try {
|
|
632
|
+
return utf8.decode(bytes);
|
|
633
|
+
} catch {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
/**
|
|
638
|
+
* A length-prefixed UTF-8 payload — the `+` type encoding's value.
|
|
639
|
+
*
|
|
640
|
+
* The length is in BYTES, which is the trap: a 22-character string of accents
|
|
641
|
+
* and kana declares 36. Slicing by character count would desynchronise the walk
|
|
642
|
+
* and corrupt everything after it.
|
|
643
|
+
*/
|
|
644
|
+
const readByteArray = (c) => {
|
|
645
|
+
const len = readInt(c);
|
|
646
|
+
if (len === null || len < 0 || len > c.remaining) return null;
|
|
647
|
+
const bytes = c.take(len);
|
|
648
|
+
try {
|
|
649
|
+
return utf8.decode(bytes);
|
|
650
|
+
} catch {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
/**
|
|
655
|
+
* Validate the archive header and position the cursor after it.
|
|
656
|
+
*
|
|
657
|
+
* The system-version integer that follows the signature is read and discarded —
|
|
658
|
+
* it is 1000 on every blob measured, and pinning it would reject a future macOS
|
|
659
|
+
* for no reason.
|
|
660
|
+
*/
|
|
661
|
+
const readHeader = (c) => {
|
|
662
|
+
if (c.remaining < 16) return "too short to be a typedstream";
|
|
663
|
+
const version = c.byte();
|
|
664
|
+
const sigLen = c.byte();
|
|
665
|
+
if (sigLen !== 11) return `bad signature length ${sigLen}`;
|
|
666
|
+
const sig = latin1.decode(c.take(sigLen));
|
|
667
|
+
if (sig !== SIGNATURE) return `not a typedstream (signature ${JSON.stringify(sig)})`;
|
|
668
|
+
readInt(c);
|
|
669
|
+
return version === 4 ? null : `unexpected streamer version ${version}`;
|
|
670
|
+
};
|
|
671
|
+
const walk = (c) => {
|
|
672
|
+
const strings = [];
|
|
673
|
+
const classes = [];
|
|
674
|
+
let firstPayloadEnd = null;
|
|
675
|
+
let tokens = 0;
|
|
676
|
+
while (!c.done) {
|
|
677
|
+
if (++tokens > MAX_TOKENS) return {
|
|
678
|
+
strings,
|
|
679
|
+
classes,
|
|
680
|
+
firstPayloadEnd,
|
|
681
|
+
error: "token limit exceeded"
|
|
682
|
+
};
|
|
683
|
+
if (c.peek() !== TAG_START) {
|
|
684
|
+
c.pos += 1;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
c.pos += 1;
|
|
688
|
+
if (c.peek() === TAG_START) continue;
|
|
689
|
+
const before = c.pos;
|
|
690
|
+
const encoding = readString(c);
|
|
691
|
+
if (encoding === null) {
|
|
692
|
+
c.pos = before;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
if (encoding === "+") {
|
|
696
|
+
const value = readByteArray(c);
|
|
697
|
+
if (value === null) return {
|
|
698
|
+
strings,
|
|
699
|
+
classes,
|
|
700
|
+
firstPayloadEnd,
|
|
701
|
+
error: "truncated byte array"
|
|
702
|
+
};
|
|
703
|
+
strings.push(value);
|
|
704
|
+
firstPayloadEnd = c.pos;
|
|
705
|
+
return {
|
|
706
|
+
strings,
|
|
707
|
+
classes,
|
|
708
|
+
firstPayloadEnd,
|
|
709
|
+
error: null
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
if (/^[A-Za-z_][A-Za-z0-9_.]*$/.test(encoding) && encoding.length > 2) classes.push(encoding);
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
strings,
|
|
716
|
+
classes,
|
|
717
|
+
firstPayloadEnd,
|
|
718
|
+
error: null
|
|
719
|
+
};
|
|
720
|
+
};
|
|
721
|
+
const decodeAttributedBody = (buffer) => {
|
|
722
|
+
if (!buffer || buffer.length === 0) return {
|
|
723
|
+
ok: false,
|
|
724
|
+
error: "empty blob"
|
|
725
|
+
};
|
|
726
|
+
if (buffer.length > MAX_BLOB_BYTES) return {
|
|
727
|
+
ok: false,
|
|
728
|
+
error: "blob too large"
|
|
729
|
+
};
|
|
730
|
+
const c = new Cursor(buffer);
|
|
731
|
+
const headerError = readHeader(c);
|
|
732
|
+
if (headerError) return {
|
|
733
|
+
ok: false,
|
|
734
|
+
error: headerError
|
|
735
|
+
};
|
|
736
|
+
const { strings, classes, error, firstPayloadEnd } = walk(c);
|
|
737
|
+
if (error) return {
|
|
738
|
+
ok: false,
|
|
739
|
+
error,
|
|
740
|
+
classes
|
|
741
|
+
};
|
|
742
|
+
if (strings.length === 0) return {
|
|
743
|
+
ok: false,
|
|
744
|
+
error: "no content string found",
|
|
745
|
+
classes
|
|
746
|
+
};
|
|
747
|
+
return {
|
|
748
|
+
ok: true,
|
|
749
|
+
text: strings[0],
|
|
750
|
+
classes,
|
|
751
|
+
/** Bytes remained after the backing store — attribute runs this does not decode. */
|
|
752
|
+
hasAttributes: firstPayloadEnd !== null && firstPayloadEnd < buffer.length - 2,
|
|
753
|
+
error: null
|
|
754
|
+
};
|
|
755
|
+
};
|
|
756
|
+
/**
|
|
757
|
+
* A redacted structural outline of one blob.
|
|
758
|
+
*
|
|
759
|
+
* For measuring the format on a machine that has the data, without the report
|
|
760
|
+
* ever carrying a message. Every string is reduced to its length and its
|
|
761
|
+
* character class; only Apple's own constants survive as themselves.
|
|
762
|
+
*/
|
|
763
|
+
const outline = (buffer, maxTokens = 60) => {
|
|
764
|
+
const c = new Cursor(buffer);
|
|
765
|
+
const headerError = readHeader(c);
|
|
766
|
+
if (headerError) return [`ERROR ${headerError}`];
|
|
767
|
+
const out = [];
|
|
768
|
+
let tokens = 0;
|
|
769
|
+
while (!c.done && tokens < maxTokens) {
|
|
770
|
+
tokens += 1;
|
|
771
|
+
const b = c.peek();
|
|
772
|
+
if (b === TAG_START) {
|
|
773
|
+
c.pos += 1;
|
|
774
|
+
out.push("START");
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (b === TAG_EMPTY) {
|
|
778
|
+
c.pos += 1;
|
|
779
|
+
out.push("nil");
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
if (b === TAG_END) {
|
|
783
|
+
c.pos += 1;
|
|
784
|
+
out.push("END");
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
if (b >= TAG_REFERENCE) {
|
|
788
|
+
c.pos += 1;
|
|
789
|
+
out.push(`ref#${b - TAG_REFERENCE}`);
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
const before = c.pos;
|
|
793
|
+
const s = readString(c);
|
|
794
|
+
if (s === null) {
|
|
795
|
+
c.pos = before + 1;
|
|
796
|
+
out.push(`byte 0x${b.toString(16).padStart(2, "0")}`);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
if (/^(NS|IM|__kIM|CF)[A-Za-z0-9_]*$/.test(s) || s.length <= 2) out.push(`"${s}"`);
|
|
800
|
+
else out.push(`<str len=${s.length} ${/^[\x20-\x7e]*$/.test(s) ? "ascii" : "unicode"}>`);
|
|
801
|
+
}
|
|
802
|
+
if (!c.done) out.push("…");
|
|
803
|
+
return out;
|
|
804
|
+
};
|
|
805
|
+
//#endregion
|
|
806
|
+
//#region src/client/store.ts
|
|
807
|
+
/**
|
|
808
|
+
* Messages' file lane — the only lane there is.
|
|
809
|
+
*
|
|
810
|
+
* `docs/messages.md` measured every Apple Events read attempt failing, so unlike
|
|
811
|
+
* every other surface here there is nothing to fall back to. That shapes two
|
|
812
|
+
* things: `SchemaDriftError` is the only fatal condition, and a missing store is
|
|
813
|
+
* reported as a reason rather than as an empty list.
|
|
814
|
+
*
|
|
815
|
+
* ## Three measurements this file is built on
|
|
816
|
+
*
|
|
817
|
+
* **The blob is the norm, and increasingly the only thing there.** 97,094 rows
|
|
818
|
+
* carry `attributedBody` and 94,043 carry `text`; 3,051 have only the blob. That
|
|
819
|
+
* 3.1% is an average over a decade, and it hides the real shape: measured
|
|
820
|
+
* through this server, 2016-2025 are ~99% plain `text` and everything from
|
|
821
|
+
* MARCH 2026 ONWARD is blob-only. A reader that selects `text` would today
|
|
822
|
+
* report that the conversation stopped in February. Every read here goes through
|
|
823
|
+
* `#body()`, which prefers the column and falls back to the decoder — validated
|
|
824
|
+
* at **100.000% agreement across all 94,043 rows** where both exist, with zero failures
|
|
825
|
+
* across all 97,094.
|
|
826
|
+
*
|
|
827
|
+
* **Dates do not fit in a JavaScript number.** See `dates.ts`. Every date column
|
|
828
|
+
* is projected through `appleSecondsSql`, never selected raw.
|
|
829
|
+
*
|
|
830
|
+
* **Reactions are messages.** 2,788 rows carry `associated_message_type != 0`,
|
|
831
|
+
* and a reader that does not filter them renders `Liked "see you at 8"` as if
|
|
832
|
+
* somebody had typed it. Filtering is table stakes, not an enhancement.
|
|
833
|
+
*/
|
|
834
|
+
/** Tables the lane cannot work without. */
|
|
835
|
+
const REQUIRED = [
|
|
836
|
+
"message",
|
|
837
|
+
"chat",
|
|
838
|
+
"handle"
|
|
839
|
+
];
|
|
840
|
+
const PROBED_FINGERPRINT = "87b01c58a631";
|
|
841
|
+
const PROBED_MACOS = "26.6";
|
|
842
|
+
/**
|
|
843
|
+
* A tapback rather than something somebody typed.
|
|
844
|
+
*
|
|
845
|
+
* 2000–2005 add a reaction, 3000–3005 remove one; 0 is an ordinary message.
|
|
846
|
+
* The ranges are Apple's and are not documented anywhere public, so the code
|
|
847
|
+
* treats "not zero" as the test and only uses the ranges to LABEL — an
|
|
848
|
+
* unrecognised value is still excluded from the conversation, which is the safe
|
|
849
|
+
* direction.
|
|
850
|
+
*/
|
|
851
|
+
const REACTION_LABELS = {
|
|
852
|
+
2e3: "loved",
|
|
853
|
+
2001: "liked",
|
|
854
|
+
2002: "disliked",
|
|
855
|
+
2003: "laughed",
|
|
856
|
+
2004: "emphasized",
|
|
857
|
+
2005: "questioned"
|
|
858
|
+
};
|
|
859
|
+
const reactionLabel = (type) => {
|
|
860
|
+
if (REACTION_LABELS[type]) return REACTION_LABELS[type];
|
|
861
|
+
if (type >= 3e3 && type <= 3005) return `removed ${REACTION_LABELS[type - 1e3] ?? "reaction"}`;
|
|
862
|
+
return `reaction ${type}`;
|
|
863
|
+
};
|
|
864
|
+
const num = (v) => typeof v === "number" ? v : null;
|
|
865
|
+
const text = (v) => typeof v === "string" && v.length > 0 ? v : null;
|
|
866
|
+
const bool = (v) => v === 1 || v === true;
|
|
867
|
+
var MessagesStore = class {
|
|
868
|
+
db;
|
|
869
|
+
mode;
|
|
870
|
+
caps;
|
|
871
|
+
constructor(db, mode, caps) {
|
|
872
|
+
this.db = db;
|
|
873
|
+
this.mode = mode;
|
|
874
|
+
this.caps = caps;
|
|
875
|
+
}
|
|
876
|
+
/** Project a column, or a typed NULL when this store does not have it. */
|
|
877
|
+
#col(present, table, name, alias = name) {
|
|
878
|
+
return present.has(name) ? `${table}."${name}" AS ${alias}` : `NULL AS ${alias}`;
|
|
879
|
+
}
|
|
880
|
+
#date(present, table, name, alias) {
|
|
881
|
+
return present.has(name) ? `${appleSecondsSql(`${table}."${name}"`)} AS ${alias}` : `NULL AS ${alias}`;
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* The text of a message, from whichever source has it.
|
|
885
|
+
*
|
|
886
|
+
* The column first because it is free, then the decoder. `textSource` is on
|
|
887
|
+
* every result so a caller can see which answered — and so the 3.1% that only
|
|
888
|
+
* the decoder can reach are visible rather than indistinguishable from empty.
|
|
889
|
+
*/
|
|
890
|
+
#body(row) {
|
|
891
|
+
const column = text(row.text);
|
|
892
|
+
if (column !== null) return {
|
|
893
|
+
text: column,
|
|
894
|
+
source: "column"
|
|
895
|
+
};
|
|
896
|
+
const blob = row.attributedBody;
|
|
897
|
+
if (blob instanceof Uint8Array && blob.length > 0) {
|
|
898
|
+
const decoded = decodeAttributedBody(blob);
|
|
899
|
+
if (decoded.ok && decoded.text.length > 0) return {
|
|
900
|
+
text: decoded.text,
|
|
901
|
+
source: "decoded"
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
return {
|
|
905
|
+
text: null,
|
|
906
|
+
source: "none"
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
#messageColumns() {
|
|
910
|
+
const m = this.caps.messageColumns;
|
|
911
|
+
return [
|
|
912
|
+
`m."guid" AS guid`,
|
|
913
|
+
`m."text" AS text`,
|
|
914
|
+
m.has("attributedBody") ? `m."attributedBody" AS attributedBody` : `NULL AS attributedBody`,
|
|
915
|
+
this.#col(m, "m", "subject"),
|
|
916
|
+
this.#col(m, "m", "service"),
|
|
917
|
+
this.#col(m, "m", "is_from_me", "isFromMe"),
|
|
918
|
+
this.#col(m, "m", "is_read", "isRead"),
|
|
919
|
+
this.#col(m, "m", "is_sent", "isSent"),
|
|
920
|
+
this.#col(m, "m", "is_delivered", "isDelivered"),
|
|
921
|
+
this.#col(m, "m", "cache_has_attachments", "hasAttachments"),
|
|
922
|
+
this.#col(m, "m", "associated_message_type", "reactionType"),
|
|
923
|
+
this.#col(m, "m", "associated_message_guid", "reactionTarget"),
|
|
924
|
+
this.#col(m, "m", "thread_originator_guid", "threadOriginator"),
|
|
925
|
+
this.#col(m, "m", "item_type", "itemType"),
|
|
926
|
+
this.#date(m, "m", "date", "sentAt"),
|
|
927
|
+
this.#date(m, "m", "date_read", "readAt"),
|
|
928
|
+
this.#date(m, "m", "date_delivered", "deliveredAt"),
|
|
929
|
+
this.#date(m, "m", "date_edited", "editedAt"),
|
|
930
|
+
`h."id" AS handle`,
|
|
931
|
+
`c."guid" AS chatGuid`,
|
|
932
|
+
this.#col(this.caps.chatColumns, "c", "display_name", "chatName")
|
|
933
|
+
].join(",\n ");
|
|
934
|
+
}
|
|
935
|
+
#joins() {
|
|
936
|
+
return `LEFT JOIN "handle" h ON h."ROWID" = m."handle_id"
|
|
937
|
+
LEFT JOIN "chat_message_join" cmj ON cmj."message_id" = m."ROWID"
|
|
938
|
+
LEFT JOIN "chat" c ON c."ROWID" = cmj."chat_id"`;
|
|
939
|
+
}
|
|
940
|
+
#toRow(r) {
|
|
941
|
+
const body = this.#body(r);
|
|
942
|
+
return {
|
|
943
|
+
guid: String(r.guid),
|
|
944
|
+
chatGuid: text(r.chatGuid),
|
|
945
|
+
chatName: text(r.chatName),
|
|
946
|
+
handle: text(r.handle),
|
|
947
|
+
service: text(r.service),
|
|
948
|
+
isFromMe: bool(r.isFromMe),
|
|
949
|
+
sentAt: num(r.sentAt),
|
|
950
|
+
readAt: num(r.readAt),
|
|
951
|
+
deliveredAt: num(r.deliveredAt),
|
|
952
|
+
editedAt: num(r.editedAt),
|
|
953
|
+
text: body.text,
|
|
954
|
+
textSource: body.source,
|
|
955
|
+
subject: text(r.subject),
|
|
956
|
+
isRead: bool(r.isRead),
|
|
957
|
+
isSent: bool(r.isSent),
|
|
958
|
+
isDelivered: bool(r.isDelivered),
|
|
959
|
+
hasAttachments: bool(r.hasAttachments),
|
|
960
|
+
reactionType: num(r.reactionType) || null,
|
|
961
|
+
reactionTarget: text(r.reactionTarget),
|
|
962
|
+
threadOriginator: text(r.threadOriginator),
|
|
963
|
+
itemType: num(r.itemType)
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* A window of messages, newest first.
|
|
968
|
+
*
|
|
969
|
+
* Reactions are excluded by default. They are rows in this table like any
|
|
970
|
+
* other, and 2,788 of them would otherwise appear as messages reading
|
|
971
|
+
* `Liked "see you at 8"` — which is not something anybody typed.
|
|
972
|
+
*/
|
|
973
|
+
range(q) {
|
|
974
|
+
const m = this.caps.messageColumns;
|
|
975
|
+
const where = [];
|
|
976
|
+
const params = [];
|
|
977
|
+
if (q.chatGuid) {
|
|
978
|
+
where.push(`c."guid" = ?`);
|
|
979
|
+
params.push(q.chatGuid);
|
|
980
|
+
}
|
|
981
|
+
if (q.fromApple !== void 0 && m.has("date")) {
|
|
982
|
+
where.push(`${appleSecondsSql("m.\"date\"")} >= ?`);
|
|
983
|
+
params.push(q.fromApple);
|
|
984
|
+
}
|
|
985
|
+
if (q.toApple !== void 0 && m.has("date")) {
|
|
986
|
+
where.push(`${appleSecondsSql("m.\"date\"")} < ?`);
|
|
987
|
+
params.push(q.toApple);
|
|
988
|
+
}
|
|
989
|
+
if (!q.includeReactions && m.has("associated_message_type")) where.push(`(m."associated_message_type" IS NULL OR m."associated_message_type" = 0)`);
|
|
990
|
+
const sql = `
|
|
991
|
+
SELECT ${this.#messageColumns()}
|
|
992
|
+
FROM "message" m
|
|
993
|
+
${this.#joins()}
|
|
994
|
+
${where.length ? `WHERE ${where.join("\n AND ")}` : ""}
|
|
995
|
+
ORDER BY m."date" DESC
|
|
996
|
+
LIMIT ${Math.max(1, Math.trunc(q.limit))}`;
|
|
997
|
+
return this.db.prepare(sql).all(...params).map((r) => this.#toRow(r));
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Text search, in two passes — and the second one is the point.
|
|
1001
|
+
*
|
|
1002
|
+
* Pass 1 is `LIKE` on the column: measured at **16 ms over 97,416 rows** with
|
|
1003
|
+
* 27 existing indexes, so there is no index-vs-scan tradeoff to litigate and
|
|
1004
|
+
* no FTS table to build.
|
|
1005
|
+
*
|
|
1006
|
+
* Pass 2 covers what pass 1 structurally cannot. 3,051 messages have an empty
|
|
1007
|
+
* `text` and content only in `attributedBody`, and no amount of SQL reaches
|
|
1008
|
+
* inside a blob. Decoding them costs about **6 ms** — the decoder runs at
|
|
1009
|
+
* 2 ms per thousand — so completeness here is nearly free, and a search that
|
|
1010
|
+
* silently omitted one message in thirty-two would be the worst kind of wrong.
|
|
1011
|
+
*/
|
|
1012
|
+
search(query, limit, includeReactions = false) {
|
|
1013
|
+
const m = this.caps.messageColumns;
|
|
1014
|
+
const needle = `%${escapeLike(query)}%`;
|
|
1015
|
+
const reactionFilter = !includeReactions && m.has("associated_message_type") ? `AND (m."associated_message_type" IS NULL OR m."associated_message_type" = 0)` : "";
|
|
1016
|
+
const cap = Math.max(1, Math.trunc(limit));
|
|
1017
|
+
const results = this.db.prepare(`SELECT ${this.#messageColumns()}
|
|
1018
|
+
FROM "message" m
|
|
1019
|
+
${this.#joins()}
|
|
1020
|
+
WHERE m."text" LIKE ? ESCAPE '\\' ${reactionFilter}
|
|
1021
|
+
ORDER BY m."date" DESC
|
|
1022
|
+
LIMIT ${cap}`).all(needle).map((r) => this.#toRow(r));
|
|
1023
|
+
if (results.length >= cap || !m.has("attributedBody")) return results;
|
|
1024
|
+
const blobOnly = this.db.prepare(`SELECT ${this.#messageColumns()}
|
|
1025
|
+
FROM "message" m
|
|
1026
|
+
${this.#joins()}
|
|
1027
|
+
WHERE (m."text" IS NULL OR m."text" = '')
|
|
1028
|
+
AND m."attributedBody" IS NOT NULL ${reactionFilter}
|
|
1029
|
+
ORDER BY m."date" DESC`).all();
|
|
1030
|
+
const lowered = query.toLowerCase();
|
|
1031
|
+
for (const raw of blobOnly) {
|
|
1032
|
+
if (results.length >= cap) break;
|
|
1033
|
+
const row = this.#toRow(raw);
|
|
1034
|
+
if (row.text && row.text.toLowerCase().includes(lowered)) results.push(row);
|
|
1035
|
+
}
|
|
1036
|
+
return results.slice(0, cap);
|
|
1037
|
+
}
|
|
1038
|
+
byGuid(guid) {
|
|
1039
|
+
const rows = this.db.prepare(`SELECT ${this.#messageColumns()}
|
|
1040
|
+
FROM "message" m
|
|
1041
|
+
${this.#joins()}
|
|
1042
|
+
WHERE m."guid" = ?
|
|
1043
|
+
LIMIT 1`).all(guid);
|
|
1044
|
+
return rows[0] ? this.#toRow(rows[0]) : null;
|
|
1045
|
+
}
|
|
1046
|
+
/** Tapbacks aimed at one message. */
|
|
1047
|
+
reactionsFor(guid) {
|
|
1048
|
+
if (!this.caps.hasReactions) return [];
|
|
1049
|
+
return this.db.prepare(`SELECT m."associated_message_type" AS type, h."id" AS handle
|
|
1050
|
+
FROM "message" m
|
|
1051
|
+
LEFT JOIN "handle" h ON h."ROWID" = m."handle_id"
|
|
1052
|
+
WHERE m."associated_message_guid" LIKE ? ESCAPE '\\'
|
|
1053
|
+
AND m."associated_message_type" > 0
|
|
1054
|
+
ORDER BY m."date" ASC`).all(`%${escapeLike(guid)}`).flatMap((r) => {
|
|
1055
|
+
const type = num(r.type);
|
|
1056
|
+
if (type === null) return [];
|
|
1057
|
+
return [{
|
|
1058
|
+
type,
|
|
1059
|
+
label: reactionLabel(type),
|
|
1060
|
+
handle: text(r.handle)
|
|
1061
|
+
}];
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* The attachments on one message.
|
|
1066
|
+
*
|
|
1067
|
+
* `id` is `attachment.guid`, for the reason `ref.ts` gives at length about
|
|
1068
|
+
* messages: the ROWID is faster and gets REUSED. Every "delete this
|
|
1069
|
+
* conversation" frees a block of attachment ids for the next insert, so a
|
|
1070
|
+
* caller that listed attachments in one turn and saved one two turns later
|
|
1071
|
+
* would write out a different file, silently. The guid is `UNIQUE NOT NULL`
|
|
1072
|
+
* in the shipped schema and is what Apple's own sync joins on.
|
|
1073
|
+
*
|
|
1074
|
+
* `path` is the raw `filename` column, reported so a caller can see WHERE the
|
|
1075
|
+
* bytes are before asking for them — it is frequently `~`-prefixed, and it is
|
|
1076
|
+
* empty for an attachment iCloud has offloaded.
|
|
1077
|
+
*/
|
|
1078
|
+
attachmentsFor(guid) {
|
|
1079
|
+
if (!this.caps.hasAttachments) return [];
|
|
1080
|
+
const a = this.caps.attachmentColumns;
|
|
1081
|
+
return this.db.prepare(`SELECT ${this.#col(a, "a", "guid", "id")},
|
|
1082
|
+
${this.#col(a, "a", "filename", "path")},
|
|
1083
|
+
${this.#col(a, "a", "mime_type", "mimeType")},
|
|
1084
|
+
${this.#col(a, "a", "transfer_name", "transferName")},
|
|
1085
|
+
${this.#col(a, "a", "total_bytes", "bytes")},
|
|
1086
|
+
${this.#col(a, "a", "is_sticker", "isSticker")}
|
|
1087
|
+
FROM "attachment" a
|
|
1088
|
+
JOIN "message_attachment_join" maj ON maj."attachment_id" = a."ROWID"
|
|
1089
|
+
JOIN "message" m ON m."ROWID" = maj."message_id"
|
|
1090
|
+
WHERE m."guid" = ?`).all(guid).map((r) => ({
|
|
1091
|
+
id: text(r.id),
|
|
1092
|
+
path: text(r.path),
|
|
1093
|
+
mimeType: text(r.mimeType),
|
|
1094
|
+
transferName: text(r.transferName),
|
|
1095
|
+
bytes: num(r.bytes),
|
|
1096
|
+
isSticker: num(r.isSticker) === 1
|
|
1097
|
+
}));
|
|
1098
|
+
}
|
|
1099
|
+
/** One attachment by its guid, wherever it hangs. Null when it is gone. */
|
|
1100
|
+
attachmentById(id) {
|
|
1101
|
+
if (!this.caps.hasAttachments) return null;
|
|
1102
|
+
const a = this.caps.attachmentColumns;
|
|
1103
|
+
if (!a.has("guid")) return null;
|
|
1104
|
+
const row = this.db.prepare(`SELECT ${this.#col(a, "a", "guid", "id")},
|
|
1105
|
+
${this.#col(a, "a", "filename", "path")},
|
|
1106
|
+
${this.#col(a, "a", "mime_type", "mimeType")},
|
|
1107
|
+
${this.#col(a, "a", "transfer_name", "transferName")},
|
|
1108
|
+
${this.#col(a, "a", "total_bytes", "bytes")}
|
|
1109
|
+
FROM "attachment" a WHERE a."guid" = ? LIMIT 1`).get(id);
|
|
1110
|
+
if (!row) return null;
|
|
1111
|
+
return {
|
|
1112
|
+
id: text(row.id),
|
|
1113
|
+
path: text(row.path),
|
|
1114
|
+
mimeType: text(row.mimeType),
|
|
1115
|
+
transferName: text(row.transferName),
|
|
1116
|
+
bytes: num(row.bytes)
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
chats(limit) {
|
|
1120
|
+
return this.#chats("", [], limit);
|
|
1121
|
+
}
|
|
1122
|
+
/** One chat, by the guid a ref carries. Null when it has been deleted. */
|
|
1123
|
+
chatByGuid(guid) {
|
|
1124
|
+
return this.#chats(`WHERE c."guid" = ?`, [guid], 1)[0] ?? null;
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* The chats that already exist with a set of handles, newest first.
|
|
1128
|
+
*
|
|
1129
|
+
* This is what makes a send addressable at all. Messages will not enumerate
|
|
1130
|
+
* participants for a script, so the write lane cannot look a person up — but
|
|
1131
|
+
* it can address a chat by guid, and the guid lives here. The read lane
|
|
1132
|
+
* choosing the target for the write lane is the whole arrangement; see
|
|
1133
|
+
* `client/jxa/core.ts`.
|
|
1134
|
+
*
|
|
1135
|
+
* Handles are matched as given. Suffix matching happens a layer up in
|
|
1136
|
+
* `client/messages.ts`, where `packages/contacts`' measured `suffixKey` is
|
|
1137
|
+
* available and the candidate list is the store's own 1,075 handles.
|
|
1138
|
+
*/
|
|
1139
|
+
chatsForHandles(handles, limit = 10) {
|
|
1140
|
+
if (!handles.length) return [];
|
|
1141
|
+
const marks = handles.map(() => "?").join(", ");
|
|
1142
|
+
return this.#chats(`WHERE c."ROWID" IN (
|
|
1143
|
+
SELECT chj."chat_id"
|
|
1144
|
+
FROM "chat_handle_join" chj
|
|
1145
|
+
JOIN "handle" h2 ON h2."ROWID" = chj."handle_id"
|
|
1146
|
+
WHERE h2."id" IN (${marks}))`, [...handles], limit);
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Outgoing messages in a set of chats since an instant — the send's receipt.
|
|
1150
|
+
*
|
|
1151
|
+
* `docs/messages.md` recorded that Apple Events returns no chat identifier, so
|
|
1152
|
+
* a send "cannot report what it wrote by id". That is true of the write lane
|
|
1153
|
+
* alone and false of the pair: the row Messages writes for an outgoing message
|
|
1154
|
+
* is an ordinary row in this table, and a narrow window plus the target chat
|
|
1155
|
+
* identifies it. Matching on text as well would be wrong — two identical
|
|
1156
|
+
* messages a minute apart are a normal thing to send — so the caller passes a
|
|
1157
|
+
* `sinceApple` taken immediately BEFORE the send and takes the oldest match.
|
|
1158
|
+
*/
|
|
1159
|
+
sentSince(chatGuids, sinceApple, limit = 10) {
|
|
1160
|
+
const m = this.caps.messageColumns;
|
|
1161
|
+
if (!chatGuids.length || !m.has("date")) return [];
|
|
1162
|
+
const marks = chatGuids.map(() => "?").join(", ");
|
|
1163
|
+
const fromMe = m.has("is_from_me") ? `AND m."is_from_me" = 1` : "";
|
|
1164
|
+
return this.db.prepare(`SELECT ${this.#messageColumns()}
|
|
1165
|
+
FROM "message" m
|
|
1166
|
+
${this.#joins()}
|
|
1167
|
+
WHERE c."guid" IN (${marks})
|
|
1168
|
+
AND ${appleSecondsSql("m.\"date\"")} >= ?
|
|
1169
|
+
${fromMe}
|
|
1170
|
+
ORDER BY m."date" ASC
|
|
1171
|
+
LIMIT ${Math.max(1, Math.trunc(limit))}`).all(...[...chatGuids, sinceApple]).map((r) => this.#toRow(r));
|
|
1172
|
+
}
|
|
1173
|
+
#chats(where, params, limit) {
|
|
1174
|
+
const c = this.caps.chatColumns;
|
|
1175
|
+
const rows = this.db.prepare(`SELECT c."ROWID" AS rowid,
|
|
1176
|
+
c."guid" AS guid,
|
|
1177
|
+
${this.#col(c, "c", "chat_identifier", "identifier")},
|
|
1178
|
+
${this.#col(c, "c", "display_name", "displayName")},
|
|
1179
|
+
${this.#col(c, "c", "style")},
|
|
1180
|
+
${this.#col(c, "c", "service_name", "service")},
|
|
1181
|
+
COUNT(cmj."message_id") AS messages,
|
|
1182
|
+
${appleSecondsSql("MAX(m.\"date\")")} AS lastMessageAt
|
|
1183
|
+
FROM "chat" c
|
|
1184
|
+
LEFT JOIN "chat_message_join" cmj ON cmj."chat_id" = c."ROWID"
|
|
1185
|
+
LEFT JOIN "message" m ON m."ROWID" = cmj."message_id"
|
|
1186
|
+
${where}
|
|
1187
|
+
GROUP BY c."ROWID"
|
|
1188
|
+
ORDER BY MAX(m."date") DESC
|
|
1189
|
+
LIMIT ${Math.max(1, Math.trunc(limit))}`).all(...params);
|
|
1190
|
+
const participants = this.#participants(rows.map((r) => Number(r.rowid)));
|
|
1191
|
+
return rows.map((r) => {
|
|
1192
|
+
const style = num(r.style);
|
|
1193
|
+
return {
|
|
1194
|
+
guid: String(r.guid),
|
|
1195
|
+
identifier: text(r.identifier),
|
|
1196
|
+
displayName: text(r.displayName),
|
|
1197
|
+
style,
|
|
1198
|
+
isGroup: style === 43 || (participants.get(Number(r.rowid))?.length ?? 0) > 1,
|
|
1199
|
+
service: text(r.service),
|
|
1200
|
+
participants: participants.get(Number(r.rowid)) ?? [],
|
|
1201
|
+
messages: Number(r.messages ?? 0),
|
|
1202
|
+
lastMessageAt: num(r.lastMessageAt)
|
|
1203
|
+
};
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
#participants(chatRowIds) {
|
|
1207
|
+
const out = /* @__PURE__ */ new Map();
|
|
1208
|
+
if (!chatRowIds.length) return out;
|
|
1209
|
+
const marks = chatRowIds.map(() => "?").join(", ");
|
|
1210
|
+
const rows = this.db.prepare(`SELECT chj."chat_id" AS chatId, h."id" AS handle
|
|
1211
|
+
FROM "chat_handle_join" chj
|
|
1212
|
+
JOIN "handle" h ON h."ROWID" = chj."handle_id"
|
|
1213
|
+
WHERE chj."chat_id" IN (${marks})`).all(...chatRowIds);
|
|
1214
|
+
for (const r of rows) {
|
|
1215
|
+
const id = Number(r.chatId);
|
|
1216
|
+
const handle = text(r.handle);
|
|
1217
|
+
if (!handle) continue;
|
|
1218
|
+
const bucket = out.get(id);
|
|
1219
|
+
if (bucket) bucket.push(handle);
|
|
1220
|
+
else out.set(id, [handle]);
|
|
1221
|
+
}
|
|
1222
|
+
return out;
|
|
1223
|
+
}
|
|
1224
|
+
/** Every distinct handle in the store, for a bulk resolve. */
|
|
1225
|
+
handles() {
|
|
1226
|
+
return this.db.prepare(`SELECT DISTINCT "id" AS id FROM "handle" WHERE "id" IS NOT NULL AND "id" <> ''`).all().flatMap((r) => {
|
|
1227
|
+
const h = text(r.id);
|
|
1228
|
+
return h ? [h] : [];
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
counts() {
|
|
1232
|
+
const one = (sql) => {
|
|
1233
|
+
try {
|
|
1234
|
+
return Number(this.db.prepare(sql).get().c ?? 0);
|
|
1235
|
+
} catch {
|
|
1236
|
+
return 0;
|
|
1237
|
+
}
|
|
1238
|
+
};
|
|
1239
|
+
return {
|
|
1240
|
+
messages: one(`SELECT COUNT(*) AS c FROM "message"`),
|
|
1241
|
+
chats: one(`SELECT COUNT(*) AS c FROM "chat"`),
|
|
1242
|
+
handles: one(`SELECT COUNT(*) AS c FROM "handle"`),
|
|
1243
|
+
attachments: this.caps.hasAttachments ? one(`SELECT COUNT(*) AS c FROM "attachment"`) : 0
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
close() {
|
|
1247
|
+
try {
|
|
1248
|
+
this.db.close();
|
|
1249
|
+
} catch {}
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
const introspect = (db) => {
|
|
1253
|
+
const messageColumns = new Set(columnsOf(db, "message"));
|
|
1254
|
+
const chatColumns = new Set(columnsOf(db, "chat"));
|
|
1255
|
+
const handleColumns = new Set(columnsOf(db, "handle"));
|
|
1256
|
+
for (const t of REQUIRED) if ((t === "message" ? messageColumns : t === "chat" ? chatColumns : handleColumns).size === 0) throw new SchemaDriftError(`This Messages store has no ${t} table. It was probed on macOS ${PROBED_MACOS} with schema fingerprint ${PROBED_FINGERPRINT} (a PROBE fingerprint — compare it against another probe run, not against the one diagnostics reports); re-run \`pnpm probe:messages\` to see what changed.`);
|
|
1257
|
+
const attachmentColumns = new Set(columnsOf(db, "attachment"));
|
|
1258
|
+
return {
|
|
1259
|
+
fingerprint: fingerprintSchema(db),
|
|
1260
|
+
messageColumns,
|
|
1261
|
+
chatColumns,
|
|
1262
|
+
handleColumns,
|
|
1263
|
+
attachmentColumns,
|
|
1264
|
+
hasAttachments: attachmentColumns.size > 0 && columnsOf(db, "message_attachment_join").length > 0,
|
|
1265
|
+
hasReactions: messageColumns.has("associated_message_type"),
|
|
1266
|
+
hasThreads: messageColumns.has("thread_originator_guid"),
|
|
1267
|
+
hasEdits: messageColumns.has("date_edited")
|
|
1268
|
+
};
|
|
1269
|
+
};
|
|
1270
|
+
const openStore = (path, mode, logger) => {
|
|
1271
|
+
if (!path) return null;
|
|
1272
|
+
const { db, mode: used, validated } = openReadOnly(path, mode, {
|
|
1273
|
+
label: "Messages store",
|
|
1274
|
+
envVar: "APPLE_MESSAGES_INDEX_MODE",
|
|
1275
|
+
validate: introspect,
|
|
1276
|
+
fatal: (err) => err instanceof SchemaDriftError,
|
|
1277
|
+
onFallback: () => logger?.debug?.("opened the Messages store with immutable=1, which skips the write-ahead log — recent messages may be missing until Messages checkpoints. The measured WAL was ~0.5 MB.")
|
|
1278
|
+
});
|
|
1279
|
+
return new MessagesStore(db, used, validated);
|
|
1280
|
+
};
|
|
1281
|
+
//#endregion
|
|
1282
|
+
//#region src/client/messages.ts
|
|
1283
|
+
var AppleMessagesClient = class {
|
|
1284
|
+
#config;
|
|
1285
|
+
#logger;
|
|
1286
|
+
#home;
|
|
1287
|
+
#located = null;
|
|
1288
|
+
#store = null;
|
|
1289
|
+
#storeTried = false;
|
|
1290
|
+
#contacts;
|
|
1291
|
+
#contactsTried;
|
|
1292
|
+
#resolved = /* @__PURE__ */ new Map();
|
|
1293
|
+
/**
|
|
1294
|
+
* Built even when writes are off, and that costs nothing.
|
|
1295
|
+
*
|
|
1296
|
+
* `createOsascriptRunner` spawns no process until something calls it, and with
|
|
1297
|
+
* `allowWrites` off nothing does — the send tool is never registered. So this
|
|
1298
|
+
* server still sends no Apple Event and still asks for no Automation grant in
|
|
1299
|
+
* its default configuration, which is the claim `surfaces.json` makes.
|
|
1300
|
+
*/
|
|
1301
|
+
#runner;
|
|
1302
|
+
constructor(opts) {
|
|
1303
|
+
this.#config = opts.config;
|
|
1304
|
+
this.#logger = opts.logger;
|
|
1305
|
+
this.#home = opts.home;
|
|
1306
|
+
this.#contacts = opts.contacts ?? null;
|
|
1307
|
+
this.#contactsTried = opts.contacts !== void 0;
|
|
1308
|
+
this.#runner = opts.osascript ?? createOsascriptRunner({
|
|
1309
|
+
surface: MESSAGES_SURFACE,
|
|
1310
|
+
osascriptPath: opts.config.osascriptPath,
|
|
1311
|
+
timeoutMs: opts.config.osascriptTimeoutMs,
|
|
1312
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
get config() {
|
|
1316
|
+
return this.#config;
|
|
1317
|
+
}
|
|
1318
|
+
located() {
|
|
1319
|
+
this.#located ??= locateStore({
|
|
1320
|
+
storePath: this.#config.storePath,
|
|
1321
|
+
...this.#home ? { home: this.#home } : {}
|
|
1322
|
+
});
|
|
1323
|
+
return this.#located;
|
|
1324
|
+
}
|
|
1325
|
+
store() {
|
|
1326
|
+
if (this.#storeTried) return this.#store;
|
|
1327
|
+
this.#storeTried = true;
|
|
1328
|
+
if (this.#config.indexMode === "off") return null;
|
|
1329
|
+
const located = this.located();
|
|
1330
|
+
if (!located.readable) return null;
|
|
1331
|
+
const mode = this.#config.indexMode === "auto" ? "ro" : this.#config.indexMode;
|
|
1332
|
+
this.#store = openStore(located.storePath, mode, this.#logger);
|
|
1333
|
+
return this.#store;
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Never `[]` when the store is missing.
|
|
1337
|
+
*
|
|
1338
|
+
* An empty list is a valid answer to "no messages match", and this surface has
|
|
1339
|
+
* no second lane to produce one from. Callers get a reason instead — and on
|
|
1340
|
+
* this surface the reason is load-bearing, because the fix is a permission.
|
|
1341
|
+
*/
|
|
1342
|
+
#require() {
|
|
1343
|
+
const store = this.store();
|
|
1344
|
+
if (store) return store;
|
|
1345
|
+
if (this.#config.indexMode === "off") throw new IndexUnavailableError("The Messages index is disabled (APPLE_MESSAGES_INDEX_MODE=off). This surface has no Apple Events read lane at all, so nothing can be read until it is re-enabled.");
|
|
1346
|
+
throw new MessagesUnavailableError(this.located().reason ?? "No readable Messages store was found.");
|
|
1347
|
+
}
|
|
1348
|
+
#contactsClient() {
|
|
1349
|
+
if (this.#contactsTried) return this.#contacts;
|
|
1350
|
+
this.#contactsTried = true;
|
|
1351
|
+
if (!this.#config.resolveContacts) return null;
|
|
1352
|
+
try {
|
|
1353
|
+
this.#contacts = new AppleContactsClient({
|
|
1354
|
+
config: loadConfig({}),
|
|
1355
|
+
...this.#logger ? { logger: this.#logger } : {},
|
|
1356
|
+
...this.#home ? { home: this.#home } : {}
|
|
1357
|
+
});
|
|
1358
|
+
} catch (err) {
|
|
1359
|
+
this.#logger?.debug?.(`contacts resolver unavailable: ${String(err)}`);
|
|
1360
|
+
this.#contacts = null;
|
|
1361
|
+
}
|
|
1362
|
+
return this.#contacts;
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Resolve a batch, memoised for the life of the process.
|
|
1366
|
+
*
|
|
1367
|
+
* Batched because building the lookup walks every contact once (3 ms on the
|
|
1368
|
+
* measured store) and every subsequent handle is a map hit. Memoised because a
|
|
1369
|
+
* conversation renders the same correspondent hundreds of times.
|
|
1370
|
+
*/
|
|
1371
|
+
#resolve(handles) {
|
|
1372
|
+
const wanted = [...new Set(handles.filter((h) => Boolean(h)))].filter((h) => !this.#resolved.has(h));
|
|
1373
|
+
if (!wanted.length) return;
|
|
1374
|
+
const contacts = this.#contactsClient();
|
|
1375
|
+
if (!contacts) return;
|
|
1376
|
+
try {
|
|
1377
|
+
for (const r of contacts.resolve(wanted).results) this.#resolved.set(r.handle, r);
|
|
1378
|
+
} catch (err) {
|
|
1379
|
+
this.#logger?.debug?.(`resolve failed: ${String(err)}`);
|
|
1380
|
+
this.#contacts = null;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
#correspondent(handle) {
|
|
1384
|
+
if (!handle) return {
|
|
1385
|
+
handle: null,
|
|
1386
|
+
name: null,
|
|
1387
|
+
resolution: "unknown"
|
|
1388
|
+
};
|
|
1389
|
+
const hit = this.#resolved.get(handle);
|
|
1390
|
+
if (!hit) return {
|
|
1391
|
+
handle,
|
|
1392
|
+
name: null,
|
|
1393
|
+
resolution: this.#contacts ? "unknown" : "unavailable"
|
|
1394
|
+
};
|
|
1395
|
+
return {
|
|
1396
|
+
handle,
|
|
1397
|
+
name: hit.name,
|
|
1398
|
+
resolution: hit.status
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
#render(rows) {
|
|
1402
|
+
this.#resolve(rows.map((r) => r.handle));
|
|
1403
|
+
return rows.map((r) => ({
|
|
1404
|
+
ref: encodeMessageRef(r.guid),
|
|
1405
|
+
chatRef: r.chatGuid ? encodeChatRef(r.chatGuid) : null,
|
|
1406
|
+
chat: r.chatName,
|
|
1407
|
+
from: r.isFromMe ? {
|
|
1408
|
+
handle: null,
|
|
1409
|
+
name: "me",
|
|
1410
|
+
resolution: "self"
|
|
1411
|
+
} : this.#correspondent(r.handle),
|
|
1412
|
+
fromMe: r.isFromMe,
|
|
1413
|
+
sentAt: renderInstant(r.sentAt),
|
|
1414
|
+
editedAt: renderInstant(r.editedAt),
|
|
1415
|
+
text: r.text,
|
|
1416
|
+
textSource: r.textSource,
|
|
1417
|
+
subject: r.subject,
|
|
1418
|
+
service: r.service,
|
|
1419
|
+
isRead: r.isRead,
|
|
1420
|
+
hasAttachments: r.hasAttachments,
|
|
1421
|
+
...r.threadOriginator ? { replyToRef: encodeMessageRef(r.threadOriginator) } : {},
|
|
1422
|
+
...r.itemType ? { itemType: r.itemType } : {}
|
|
1423
|
+
}));
|
|
1424
|
+
}
|
|
1425
|
+
listMessages(opts) {
|
|
1426
|
+
return this.#render(this.#require().range({
|
|
1427
|
+
...opts.chatRef ? { chatGuid: opts.chatRef } : {},
|
|
1428
|
+
...opts.fromApple === void 0 ? {} : { fromApple: opts.fromApple },
|
|
1429
|
+
...opts.toApple === void 0 ? {} : { toApple: opts.toApple },
|
|
1430
|
+
...opts.includeReactions === void 0 ? {} : { includeReactions: opts.includeReactions },
|
|
1431
|
+
limit: opts.limit ?? this.#config.maxResults
|
|
1432
|
+
}));
|
|
1433
|
+
}
|
|
1434
|
+
searchMessages(query, limit) {
|
|
1435
|
+
return this.#render(this.#require().search(query, limit ?? this.#config.maxResults));
|
|
1436
|
+
}
|
|
1437
|
+
getMessage(guid) {
|
|
1438
|
+
const store = this.#require();
|
|
1439
|
+
const row = store.byGuid(guid);
|
|
1440
|
+
if (!row) return null;
|
|
1441
|
+
const reactions = store.reactionsFor(guid);
|
|
1442
|
+
this.#resolve(reactions.map((r) => r.handle));
|
|
1443
|
+
const [rendered] = this.#render([row]);
|
|
1444
|
+
return {
|
|
1445
|
+
...rendered,
|
|
1446
|
+
reactions: reactions.map((r) => ({
|
|
1447
|
+
label: r.label,
|
|
1448
|
+
from: this.#correspondent(r.handle)
|
|
1449
|
+
})),
|
|
1450
|
+
attachments: store.attachmentsFor(guid)
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Copy one attachment out of the Messages store onto disk.
|
|
1455
|
+
*
|
|
1456
|
+
* ## Why this is a copy and not an extraction
|
|
1457
|
+
*
|
|
1458
|
+
* Mail's equivalent has to parse MIME out of an `.emlx` because the bytes are
|
|
1459
|
+
* inside the message file. Messages does not work that way: `attachment` rows
|
|
1460
|
+
* point at real files under `~/Library/Messages`, so the work here is finding
|
|
1461
|
+
* the row, deciding the path is one we are willing to read, and copying.
|
|
1462
|
+
*
|
|
1463
|
+
* ## Two boundaries, not one
|
|
1464
|
+
*
|
|
1465
|
+
* The DESTINATION boundary is the same one Mail and Notes enforce:
|
|
1466
|
+
* `attachmentDir` is a confinement, `directory` may only select inside it, and
|
|
1467
|
+
* the leaf name is `basename`d because it comes from whoever sent the message.
|
|
1468
|
+
*
|
|
1469
|
+
* The SOURCE boundary is this surface's own. `filename` comes out of a
|
|
1470
|
+
* database this server never writes, and it is a fully-qualified path: taken
|
|
1471
|
+
* at face value it names any file the process can read. So it is required to
|
|
1472
|
+
* resolve inside the Messages root before a single byte is read. That check
|
|
1473
|
+
* has never fired on a real store and is not expected to — it is here so that
|
|
1474
|
+
* the day the schema surprises us, the surprise is a refusal.
|
|
1475
|
+
*/
|
|
1476
|
+
async saveAttachment(attachmentId, opts = {}) {
|
|
1477
|
+
const meta = this.#require().attachmentById(attachmentId);
|
|
1478
|
+
if (!meta) throw new PreconditionError(`No attachment with id "${attachmentId}". Ids come from apple_messages_get_message and are the attachment's guid; the message may have been deleted since it was listed.`);
|
|
1479
|
+
if (!meta.path) throw new PreconditionError("That attachment has no file path in the store, which normally means iCloud has offloaded it. Open the conversation in Messages to download it, then try again.");
|
|
1480
|
+
const home = this.#home ?? homedir();
|
|
1481
|
+
const expanded = meta.path.startsWith("~/") ? join(home, meta.path.slice(2)) : meta.path;
|
|
1482
|
+
const source = resolve(expanded);
|
|
1483
|
+
const root = resolve(this.located().messagesRoot);
|
|
1484
|
+
if (source !== root && !source.startsWith(root + sep)) throw new PreconditionError(`Refusing to read ${source}: it is outside ${root}, and this tool only copies files Messages itself stores.`);
|
|
1485
|
+
if (!existsSync(source) || !statSync(source).isFile()) throw new PreconditionError(`The store names ${source} but there is no file there. Messages prunes attachment bytes while keeping the row, so this is a normal state for an old conversation.`);
|
|
1486
|
+
const dest = resolve(this.#config.attachmentDir);
|
|
1487
|
+
const dir = opts.directory ? resolve(dest, opts.directory) : dest;
|
|
1488
|
+
if (dir !== dest && !dir.startsWith(dest + sep)) throw new PreconditionError(`Refusing to write outside ${dest}. Set APPLE_MESSAGES_ATTACHMENT_DIR to change the destination.`);
|
|
1489
|
+
const name = basename(meta.transferName ?? basename(source));
|
|
1490
|
+
const target = resolve(join(dir, name));
|
|
1491
|
+
if (target !== join(dir, name) || !target.startsWith(dir + sep)) throw new PreconditionError(`Refusing to write outside ${dir}.`);
|
|
1492
|
+
if (existsSync(target) && !opts.overwrite) throw new PreconditionError(`${target} already exists; refusing to overwrite it.`);
|
|
1493
|
+
const bytes = readFileSync(source);
|
|
1494
|
+
mkdirSync(dir, { recursive: true });
|
|
1495
|
+
writeFileSync(target, bytes, { mode: 384 });
|
|
1496
|
+
return {
|
|
1497
|
+
path: target,
|
|
1498
|
+
bytes: bytes.length,
|
|
1499
|
+
source,
|
|
1500
|
+
mimeType: meta.mimeType
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
listChats(limit) {
|
|
1504
|
+
const rows = this.#require().chats(limit ?? this.#config.maxResults);
|
|
1505
|
+
this.#resolve(rows.flatMap((c) => c.participants));
|
|
1506
|
+
return rows.map((c) => ({
|
|
1507
|
+
ref: encodeChatRef(c.guid),
|
|
1508
|
+
name: c.displayName ?? (c.participants.length === 1 && c.participants[0] ? this.#correspondent(c.participants[0]).name ?? c.participants[0] : null),
|
|
1509
|
+
isGroup: c.isGroup,
|
|
1510
|
+
service: c.service,
|
|
1511
|
+
participants: c.participants.map((h) => this.#correspondent(h)),
|
|
1512
|
+
messages: c.messages,
|
|
1513
|
+
lastMessageAt: renderInstant(c.lastMessageAt)
|
|
1514
|
+
}));
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Pick the chat to address, using the store.
|
|
1518
|
+
*
|
|
1519
|
+
* Handles are matched the way `packages/contacts` measured them rather than by
|
|
1520
|
+
* string equality: a caller who types `06 12 34 56 78` and a store that holds
|
|
1521
|
+
* `+33612345678` are the same person, and `suffixKey`'s last-9 rule is what
|
|
1522
|
+
* says so. The candidate set is the store's own handles — 1,075 on the
|
|
1523
|
+
* measured machine — so this is a scan over a list that is already in memory.
|
|
1524
|
+
*/
|
|
1525
|
+
#chatFor(input) {
|
|
1526
|
+
const store = this.store();
|
|
1527
|
+
if (input.chatRef) {
|
|
1528
|
+
const guid = decodeChatRef(input.chatRef);
|
|
1529
|
+
const chat = store?.chatByGuid(guid) ?? null;
|
|
1530
|
+
if (store && !chat) throw new ChatNotFoundError(input.chatRef);
|
|
1531
|
+
return {
|
|
1532
|
+
guid,
|
|
1533
|
+
chat,
|
|
1534
|
+
handle: chat?.participants[0] ?? null
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
const to = input.to?.trim();
|
|
1538
|
+
if (!to) return {
|
|
1539
|
+
guid: null,
|
|
1540
|
+
chat: null,
|
|
1541
|
+
handle: null
|
|
1542
|
+
};
|
|
1543
|
+
if (!store) return {
|
|
1544
|
+
guid: null,
|
|
1545
|
+
chat: null,
|
|
1546
|
+
handle: to
|
|
1547
|
+
};
|
|
1548
|
+
const kind = handleKind(to);
|
|
1549
|
+
const wantedSuffix = kind === "phone" ? suffixKey(to) : null;
|
|
1550
|
+
const wantedEmail = kind === "email" ? emailKey(to) : null;
|
|
1551
|
+
const candidates = store.handles().filter((h) => {
|
|
1552
|
+
if (h === to) return true;
|
|
1553
|
+
if (wantedEmail) return emailKey(h) === wantedEmail;
|
|
1554
|
+
if (wantedSuffix) return suffixKey(h) === wantedSuffix;
|
|
1555
|
+
return false;
|
|
1556
|
+
});
|
|
1557
|
+
if (!candidates.length) return {
|
|
1558
|
+
guid: null,
|
|
1559
|
+
chat: null,
|
|
1560
|
+
handle: to
|
|
1561
|
+
};
|
|
1562
|
+
const direct = store.chatsForHandles(candidates, 25).find((c) => !c.isGroup) ?? null;
|
|
1563
|
+
return {
|
|
1564
|
+
guid: direct?.guid ?? null,
|
|
1565
|
+
chat: direct,
|
|
1566
|
+
handle: candidates[0] ?? to
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Find the row Messages wrote, or say plainly that it was not found.
|
|
1571
|
+
*
|
|
1572
|
+
* `docs/messages.md` left this as an open question — "whether a send should
|
|
1573
|
+
* re-resolve by scanning the store for a recent row on the target chat" — and
|
|
1574
|
+
* this is the answer, because the alternative is a send that can report
|
|
1575
|
+
* nothing at all. Apple Events hands back no identifier of any kind.
|
|
1576
|
+
*
|
|
1577
|
+
* Three things make the match safe rather than merely plausible:
|
|
1578
|
+
*
|
|
1579
|
+
* 1. `since` is taken BEFORE the send, so nothing earlier can match.
|
|
1580
|
+
* 2. The window is scoped to the target chat.
|
|
1581
|
+
* 3. Text is compared when it is available, which separates our row from one
|
|
1582
|
+
* the user sent from their phone in the same second. Only when it is not
|
|
1583
|
+
* available does the oldest row in the window win.
|
|
1584
|
+
*
|
|
1585
|
+
* A miss is `pending`, never an error: the send already happened, and iMessage
|
|
1586
|
+
* writes its row asynchronously. Reporting a failure there would be the worst
|
|
1587
|
+
* possible lie — it would invite a retry that sends the message twice.
|
|
1588
|
+
*/
|
|
1589
|
+
async #reconcile(guid, since, text) {
|
|
1590
|
+
const deadline = Date.now() + this.#config.sendReconcileMs;
|
|
1591
|
+
for (;;) {
|
|
1592
|
+
this.#invalidate();
|
|
1593
|
+
const store = this.store();
|
|
1594
|
+
if (!store) return null;
|
|
1595
|
+
const rows = store.sentSince([guid], since, 10);
|
|
1596
|
+
const hit = rows.find((r) => r.text !== null && r.text === text) ?? rows[0];
|
|
1597
|
+
if (hit) return this.#render([hit])[0] ?? null;
|
|
1598
|
+
if (Date.now() >= deadline) return null;
|
|
1599
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
#invalidate() {
|
|
1603
|
+
this.#store?.close();
|
|
1604
|
+
this.#store = null;
|
|
1605
|
+
this.#storeTried = false;
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Send one message. A real one, to a real person, immediately.
|
|
1609
|
+
*
|
|
1610
|
+
* Everything difficult about this is in `client/jxa/core.ts`; what is left
|
|
1611
|
+
* here is choosing the target from the file lane and reconciling afterwards.
|
|
1612
|
+
*/
|
|
1613
|
+
async sendMessage(input) {
|
|
1614
|
+
const { guid, chat, handle } = this.#chatFor(input);
|
|
1615
|
+
const since = toAppleSeconds(/* @__PURE__ */ new Date(Date.now() - 2e3));
|
|
1616
|
+
let data;
|
|
1617
|
+
try {
|
|
1618
|
+
data = await withBusyRetry(() => this.#runner.run(SEND_MESSAGE, {
|
|
1619
|
+
...guid ? { chatGuid: guid } : {},
|
|
1620
|
+
...handle ? { handle } : {},
|
|
1621
|
+
...input.service ? { service: input.service } : {},
|
|
1622
|
+
text: input.text,
|
|
1623
|
+
allowLaunch: true
|
|
1624
|
+
}));
|
|
1625
|
+
} catch (err) {
|
|
1626
|
+
const details = err?.details;
|
|
1627
|
+
const attempts = Array.isArray(details?.detail) ? details.detail : [];
|
|
1628
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1629
|
+
if (details?.code === "SEND_TARGET_NOT_FOUND") throw new SendTargetNotFoundError(input.chatRef ?? input.to ?? "", attempts);
|
|
1630
|
+
if (details?.code === "SEND_FAILED") throw new SendFailedError(message, attempts);
|
|
1631
|
+
throw err;
|
|
1632
|
+
}
|
|
1633
|
+
const message = guid ? await this.#reconcile(guid, since, input.text) : null;
|
|
1634
|
+
if (handle) this.#resolve([handle]);
|
|
1635
|
+
return {
|
|
1636
|
+
sent: true,
|
|
1637
|
+
strategy: typeof data.strategy === "string" ? data.strategy : "unknown",
|
|
1638
|
+
targetKind: typeof data.targetKind === "string" ? data.targetKind : "unknown",
|
|
1639
|
+
chatRef: guid ? encodeChatRef(guid) : null,
|
|
1640
|
+
chat: chat?.displayName ?? null,
|
|
1641
|
+
to: handle ? this.#correspondent(handle) : null,
|
|
1642
|
+
launched: data.launched === true,
|
|
1643
|
+
reconciliation: message ? "matched" : guid ? "pending" : "unavailable",
|
|
1644
|
+
message,
|
|
1645
|
+
...message ? {} : { note: guid ? "Messages accepted the send, but no matching row had appeared in the store yet. That is normal for a slow network — re-run apple_messages_list_messages on this chat rather than sending again." : "Messages accepted the send, but there was no existing chat to reconcile it against, so no ref can be reported for it. Read the chat back once it exists." }
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
/** Bounds for a range query, as apple-seconds. */
|
|
1649
|
+
window(from, to) {
|
|
1650
|
+
return {
|
|
1651
|
+
...from ? { fromApple: toAppleSeconds(from) } : {},
|
|
1652
|
+
...to ? { toApple: toAppleSeconds(to) } : {}
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
status() {
|
|
1656
|
+
const store = this.store();
|
|
1657
|
+
return {
|
|
1658
|
+
located: this.located(),
|
|
1659
|
+
store: {
|
|
1660
|
+
opened: Boolean(store),
|
|
1661
|
+
mode: store?.mode ?? null,
|
|
1662
|
+
fingerprint: store?.caps.fingerprint ?? null
|
|
1663
|
+
},
|
|
1664
|
+
counts: store?.counts() ?? null,
|
|
1665
|
+
contacts: {
|
|
1666
|
+
enabled: this.#config.resolveContacts,
|
|
1667
|
+
available: Boolean(this.#contactsClient()),
|
|
1668
|
+
resolved: this.#resolved.size
|
|
1669
|
+
}
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
close() {
|
|
1673
|
+
this.#store?.close();
|
|
1674
|
+
this.#store = null;
|
|
1675
|
+
this.#storeTried = false;
|
|
1676
|
+
this.#contacts?.close();
|
|
1677
|
+
this.#resolved.clear();
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
//#endregion
|
|
1681
|
+
//#region src/config.ts
|
|
1682
|
+
/**
|
|
1683
|
+
* Configuration is environment-only — this server holds no secret at all, its
|
|
1684
|
+
* access is the macOS permission the user granted.
|
|
1685
|
+
*
|
|
1686
|
+
* `allowWrites` is inherited from `BaseConfigSchema` and, since 1.2.0, means
|
|
1687
|
+
* something here: it gates the one mutating tool, `send_message`. With it off
|
|
1688
|
+
* this server registers no write tool and therefore sends no Apple Event at all,
|
|
1689
|
+
* which is what keeps its "no Automation grant" claim true by default.
|
|
1690
|
+
*
|
|
1691
|
+
* `docs/messages.md` recorded why sending went unprobed for so long — "probing
|
|
1692
|
+
* it would mean sending a real message to a real person" — and left the id
|
|
1693
|
+
* bridge open, because Apple Events returns no chat identifier to reconcile
|
|
1694
|
+
* against. `sendReconcileMs` is that decision made: the file lane finds the row
|
|
1695
|
+
* instead.
|
|
1696
|
+
*/
|
|
1697
|
+
const ConfigSchema = BaseConfigSchema.extend({
|
|
1698
|
+
/** Explicit store path. Bypasses discovery — for tests and forensic copies. */
|
|
1699
|
+
storePath: z.string().optional(),
|
|
1700
|
+
indexMode: z.enum([
|
|
1701
|
+
"auto",
|
|
1702
|
+
"ro",
|
|
1703
|
+
"immutable",
|
|
1704
|
+
"off"
|
|
1705
|
+
]).default("auto"),
|
|
1706
|
+
/**
|
|
1707
|
+
* Look names up in Contacts.
|
|
1708
|
+
*
|
|
1709
|
+
* On by default: without it this server answers "+15551234567 said …", which
|
|
1710
|
+
* is why `packages/contacts` was built. Off is for a machine where the
|
|
1711
|
+
* Contacts permission has not been granted and the prompt is unwelcome — the
|
|
1712
|
+
* resolver degrades to raw handles either way, so this only decides whether it
|
|
1713
|
+
* is attempted.
|
|
1714
|
+
*/
|
|
1715
|
+
resolveContacts: z.boolean().default(true),
|
|
1716
|
+
/**
|
|
1717
|
+
* The only directory `save_attachment` may write into.
|
|
1718
|
+
*
|
|
1719
|
+
* A boundary rather than a default: the tool's `directory` argument selects a
|
|
1720
|
+
* subdirectory of this and cannot escape it. Saving is write-gated for the
|
|
1721
|
+
* same reason it is in Mail and Notes — it puts a file on the user's disk,
|
|
1722
|
+
* even though it changes nothing in Messages.
|
|
1723
|
+
*/
|
|
1724
|
+
attachmentDir: z.string().default(join(homedir(), "Downloads")),
|
|
1725
|
+
/** Window for a range query that names only a start. */
|
|
1726
|
+
defaultRangeDays: z.number().int().min(1).max(3660).default(30),
|
|
1727
|
+
/**
|
|
1728
|
+
* How long to wait for a sent message to appear in the store.
|
|
1729
|
+
*
|
|
1730
|
+
* Messages writes the outgoing row asynchronously, so a send that returns
|
|
1731
|
+
* instantly has usually not been written yet. Five seconds is generous for a
|
|
1732
|
+
* local write and short enough that a tool call does not hang on a bad
|
|
1733
|
+
* network; a miss is reported as `pending`, never as a failure. Zero means one
|
|
1734
|
+
* immediate check and no polling, which is what the test suite uses.
|
|
1735
|
+
*/
|
|
1736
|
+
sendReconcileMs: z.number().int().min(0).max(6e4).default(5e3)
|
|
1737
|
+
}).strict();
|
|
1738
|
+
const loadConfig$1 = (env = process.env) => parseConfig(ConfigSchema, {
|
|
1739
|
+
allowWrites: parseBool(env.APPLE_MESSAGES_ALLOW_WRITES),
|
|
1740
|
+
exposePrompts: parseBool(env.APPLE_MESSAGES_EXPOSE_PROMPTS),
|
|
1741
|
+
debug: parseBool(env.APPLE_MESSAGES_DEBUG),
|
|
1742
|
+
storePath: trimmed(env.APPLE_MESSAGES_STORE),
|
|
1743
|
+
indexMode: trimmed(env.APPLE_MESSAGES_INDEX_MODE),
|
|
1744
|
+
resolveContacts: parseBool(env.APPLE_MESSAGES_RESOLVE_CONTACTS),
|
|
1745
|
+
attachmentDir: trimmed(env.APPLE_MESSAGES_ATTACHMENT_DIR),
|
|
1746
|
+
defaultRangeDays: parseIntOpt(env.APPLE_MESSAGES_DEFAULT_RANGE_DAYS),
|
|
1747
|
+
sendReconcileMs: parseIntOpt(env.APPLE_MESSAGES_SEND_RECONCILE_MS),
|
|
1748
|
+
osascriptPath: trimmed(env.APPLE_MESSAGES_OSASCRIPT_PATH),
|
|
1749
|
+
osascriptTimeoutMs: parseIntOpt(env.APPLE_MESSAGES_OSASCRIPT_TIMEOUT_MS),
|
|
1750
|
+
maxResults: parseIntOpt(env.APPLE_MESSAGES_MAX_RESULTS)
|
|
1751
|
+
});
|
|
1752
|
+
//#endregion
|
|
1753
|
+
//#region src/guide.ts
|
|
1754
|
+
/**
|
|
1755
|
+
* The Messages operating manual, served as `cupertino://messages/guide` and
|
|
1756
|
+
* embedded ahead of every Messages prompt. Static by design — see the note in
|
|
1757
|
+
* the Mail guide.
|
|
1758
|
+
*/
|
|
1759
|
+
const MESSAGES_GUIDE = `# Apple Messages — how to drive this server
|
|
1760
|
+
|
|
1761
|
+
## Refs are opaque
|
|
1762
|
+
|
|
1763
|
+
Chats come back as \`mc1:<guid>\`, messages as \`m1:<guid>\`. Pass them back
|
|
1764
|
+
verbatim; never construct one.
|
|
1765
|
+
|
|
1766
|
+
## Full Disk Access is mandatory here, unlike every other surface
|
|
1767
|
+
|
|
1768
|
+
There is no Apple Events read lane to fall back on. Measured: Messages answers
|
|
1769
|
+
"Application isn't running" even while it is running, because it is a windowless
|
|
1770
|
+
background process that declines to wake for a script. Without the grant this
|
|
1771
|
+
server can do **nothing** on the read side — that is a permission problem, not
|
|
1772
|
+
an empty message history.
|
|
1773
|
+
|
|
1774
|
+
## Which tool, under which constraint
|
|
1775
|
+
|
|
1776
|
+
- **\`apple_messages_list_chats\`** first, when you need to know which
|
|
1777
|
+
conversation you are looking at.
|
|
1778
|
+
- **\`apple_messages_list_messages\`** reads one conversation in order.
|
|
1779
|
+
- **\`apple_messages_search_messages\`** searches across them.
|
|
1780
|
+
- **\`apple_messages_get_message\`** returns one message with its tapbacks and
|
|
1781
|
+
attachments.
|
|
1782
|
+
|
|
1783
|
+
## Handles are not names
|
|
1784
|
+
|
|
1785
|
+
A chat identifies people by phone number or email address. Names come from
|
|
1786
|
+
Contacts, which has its own separate permission, so \`unknown\` is a normal
|
|
1787
|
+
outcome — about one in six of even the busiest correspondents has no card. When
|
|
1788
|
+
diagnostics reports \`contacts.available: false\`, **nobody looked at all** and
|
|
1789
|
+
every handle is raw. Do not present a raw handle as though the person is
|
|
1790
|
+
unidentified when the truth is that lookup was never possible.
|
|
1791
|
+
|
|
1792
|
+
## Two things that look like missing messages and are not
|
|
1793
|
+
|
|
1794
|
+
**Recent messages are stored as blobs.** Messages stopped writing the plain
|
|
1795
|
+
\`text\` column between late February and late March 2026; everything since lives
|
|
1796
|
+
only as an archived blob, which this server decodes. \`textSource\` on each result
|
|
1797
|
+
says which lane answered. A reader without the decoder would report that the
|
|
1798
|
+
conversation stopped in February — if you ever see history that appears to end
|
|
1799
|
+
there, that is this, not silence.
|
|
1800
|
+
|
|
1801
|
+
**Tapbacks are rows in the message table.** They are filtered out of
|
|
1802
|
+
conversations and reported on the message they target. A "liked" is not a reply
|
|
1803
|
+
and should not be summarised as one.
|
|
1804
|
+
|
|
1805
|
+
## Sending
|
|
1806
|
+
|
|
1807
|
+
\`apple_messages_send_message\` exists only when writes are enabled, and it is the
|
|
1808
|
+
**only** thing this server can change — the dictionary has no edit, delete,
|
|
1809
|
+
mark-as-read or reaction command.
|
|
1810
|
+
|
|
1811
|
+
Sending is real and immediate. There is no draft state, no undo, and no
|
|
1812
|
+
confirmation step after the call. Confirm the recipient handle with the user
|
|
1813
|
+
before sending, exactly as it will be used.
|
|
1814
|
+
|
|
1815
|
+
Messages hands back no identifier for what it sent, so the sent row is found by
|
|
1816
|
+
re-reading the store. \`reconciliation: "pending"\` means it has not appeared yet.
|
|
1817
|
+
That is **not a failure and must not be retried** — retrying sends the message
|
|
1818
|
+
twice.
|
|
1819
|
+
`;
|
|
1820
|
+
//#endregion
|
|
1821
|
+
//#region src/prompts.ts
|
|
1822
|
+
const CTX = {
|
|
1823
|
+
surface: "messages",
|
|
1824
|
+
guide: MESSAGES_GUIDE
|
|
1825
|
+
};
|
|
1826
|
+
/**
|
|
1827
|
+
* Messages' workflow prompts.
|
|
1828
|
+
*
|
|
1829
|
+
* The send prompt carries the only genuinely irreversible action in this
|
|
1830
|
+
* bundle. Everywhere else a mistake leaves a draft, an extra reminder or a
|
|
1831
|
+
* wrong-looking event; here it puts words in someone's pocket under the user's
|
|
1832
|
+
* name, with no undo and no draft state to catch it.
|
|
1833
|
+
*/
|
|
1834
|
+
const registerPrompts = (server, allowWrites) => {
|
|
1835
|
+
registerWorkflowPrompt(server, CTX, {
|
|
1836
|
+
name: "apple_messages_catch_up",
|
|
1837
|
+
title: "Catch up on a conversation",
|
|
1838
|
+
description: "Read back what has been said in a chat and what is waiting on a reply, with handles resolved to names where that is actually possible. Read-only.",
|
|
1839
|
+
argsSchema: {
|
|
1840
|
+
chat: promptArg("Who or which chat — a name, a handle, or a chat ref. Omit for recent chats."),
|
|
1841
|
+
since: promptArg("How far back, e.g. \"yesterday\", \"last week\". Defaults to recent history.")
|
|
1842
|
+
},
|
|
1843
|
+
build: ({ chat, since }) => `Catch me up on ${chat ? `the conversation with ${chat}` : "my recent messages"}${since ? `, covering ${since}` : ""}.
|
|
1844
|
+
|
|
1845
|
+
1. \`apple_messages_list_chats\` to find ${chat ? "the right conversation. If several match, ask which rather than picking." : "what has been active."}
|
|
1846
|
+
2. \`apple_messages_list_messages\` over that chat${since ? " with a date bound" : ""}.
|
|
1847
|
+
3. Summarise what was actually said and, most importantly, **what is waiting on
|
|
1848
|
+
me** — a question asked and not answered is the thing worth surfacing.
|
|
1849
|
+
4. Where a handle has no name, say so and show the handle raw. Do not guess who
|
|
1850
|
+
it is from context. If \`contacts.available\` is false in diagnostics, say that
|
|
1851
|
+
names could not be looked up at all — that is different from these people
|
|
1852
|
+
being unknown.
|
|
1853
|
+
|
|
1854
|
+
Ignore tapbacks as messages; they are reactions on the message they target and
|
|
1855
|
+
a "liked" is not a reply. If the history appears to stop in early 2026, that is
|
|
1856
|
+
the blob-storage change described in the guide, not the end of the conversation
|
|
1857
|
+
— check \`textSource\` before reporting a gap.`
|
|
1858
|
+
});
|
|
1859
|
+
if (!allowWrites) return;
|
|
1860
|
+
registerWorkflowPrompt(server, CTX, {
|
|
1861
|
+
name: "apple_messages_send",
|
|
1862
|
+
title: "Send a message",
|
|
1863
|
+
description: "Compose and send an iMessage or SMS. Requires writes. Sending is immediate and cannot be undone — this prompt confirms the recipient before anything goes out.",
|
|
1864
|
+
argsSchema: {
|
|
1865
|
+
to: requiredPromptArg("Who to send to — a name, phone number or email address."),
|
|
1866
|
+
message: promptArg("What to say, or the gist of it. Omit to be asked.")
|
|
1867
|
+
},
|
|
1868
|
+
build: ({ to, message }) => `Send a message to ${to}.${message ? `\n\nWhat it should say: ${message}` : ""}
|
|
1869
|
+
|
|
1870
|
+
1. Work out the exact handle. \`apple_messages_list_chats\` to find an existing
|
|
1871
|
+
conversation with this person — an established chat is better evidence of the
|
|
1872
|
+
right handle than an address book match, because it is the one they actually
|
|
1873
|
+
reply on.
|
|
1874
|
+
2. Read the last few messages in that chat. It tells you which language they
|
|
1875
|
+
write in, how formal they are, and whether something is already pending.
|
|
1876
|
+
3. **Show the user the exact handle and the exact text, and wait for them to
|
|
1877
|
+
confirm.** There is no draft state here and no undo: the call sends. A
|
|
1878
|
+
message to the wrong handle cannot be recalled, and it is sent under the
|
|
1879
|
+
user's name.
|
|
1880
|
+
4. On confirmation, \`apple_messages_send_message\`.
|
|
1881
|
+
5. If the result says \`reconciliation: "pending"\`, **the message was sent.**
|
|
1882
|
+
Messages returns no identifier, so the sent row is found by re-reading the
|
|
1883
|
+
store and has not appeared yet. Do not retry — retrying sends it twice. Say
|
|
1884
|
+
it went out and that confirmation is lagging.`
|
|
1885
|
+
});
|
|
1886
|
+
};
|
|
1887
|
+
//#endregion
|
|
1888
|
+
//#region src/tools/util.ts
|
|
1889
|
+
const chatRefArg = z.string().optional().describe("An opaque chat ref from apple_messages_list_chats (looks like \"mc1:<guid>\"). Do not construct one by hand.");
|
|
1890
|
+
const messageRefArg = z.string().min(1).describe("An opaque message ref from a list or search result (looks like \"m1:<guid>\"). Do not construct one by hand.");
|
|
1891
|
+
const fromArg = z.string().optional().describe("Start of the window, ISO-8601 — \"2026-08-01\" or \"2026-08-01T09:00\".");
|
|
1892
|
+
const toArg = z.string().optional().describe("End of the window, ISO-8601. Defaults to now.");
|
|
1893
|
+
const includeReactionsArg = z.boolean().optional().describe("Include tapbacks as if they were messages. Off by default, and you almost never want it on: a tapback renders as `Liked \"see you at 8\"`, which nobody typed. Use apple_messages_get_message to see the reactions on a specific message instead.");
|
|
1894
|
+
//#endregion
|
|
1895
|
+
//#region src/tools/diagnostics.ts
|
|
1896
|
+
/**
|
|
1897
|
+
* Build the report.
|
|
1898
|
+
*
|
|
1899
|
+
* Split out of the tool registration so the `cupertino://messages/diagnostics`
|
|
1900
|
+
* resource can serve the same bytes. Two renderings of one probe: duplicated,
|
|
1901
|
+
* the resource and the tool would drift, and the disagreement would surface as
|
|
1902
|
+
* "the diagnostics lied" — the one thing this file must never do.
|
|
1903
|
+
*/
|
|
1904
|
+
const buildDiagnostics = async (client) => {
|
|
1905
|
+
const status = client.status();
|
|
1906
|
+
const writes = client.config.allowWrites;
|
|
1907
|
+
return {
|
|
1908
|
+
server: {
|
|
1909
|
+
name: BUILD_INFO.name,
|
|
1910
|
+
version: BUILD_INFO.version
|
|
1911
|
+
},
|
|
1912
|
+
settings: { exposePrompts: client.config.exposePrompts },
|
|
1913
|
+
lane: {
|
|
1914
|
+
reads: "file lane (read-only SQLite)",
|
|
1915
|
+
writes: writes ? "apple_messages_send_message, over Apple Events. The only mutating command the Messages dictionary offers." : "off — APPLE_MESSAGES_ALLOW_WRITES is not set, so the send tool is not registered and this server sends no Apple Event at all.",
|
|
1916
|
+
appleEvents: "no read path exists, and never will. Measured: every read attempt fails, and Messages answers \"Application isn't running\" even while it is running, because it is a windowless background process that declines to wake for a script. Sending is the one thing that works."
|
|
1917
|
+
},
|
|
1918
|
+
store: {
|
|
1919
|
+
path: status.located.storePath,
|
|
1920
|
+
exists: status.located.exists,
|
|
1921
|
+
readable: status.located.readable,
|
|
1922
|
+
opened: status.store.opened,
|
|
1923
|
+
mode: status.store.mode,
|
|
1924
|
+
fingerprint: status.store.fingerprint,
|
|
1925
|
+
counts: status.counts,
|
|
1926
|
+
reason: status.located.reason
|
|
1927
|
+
},
|
|
1928
|
+
contacts: status.contacts,
|
|
1929
|
+
caveats: [
|
|
1930
|
+
"Full Disk Access is MANDATORY here, unlike every other surface in this bundle. There is no Apple Events read lane to fall back to, so without the grant this server can do nothing at all.",
|
|
1931
|
+
"Messages stopped writing the plain `text` column between late February and late March 2026: every message since is stored only as an archived blob, which is decoded here. `textSource` on each result says which lane answered. Across all history the blob-only share is about 3%, but for anything recent it is ~100%, so a reader without the decoder would report that the conversation stopped in February.",
|
|
1932
|
+
"Names come from Contacts, which has its own separate permission. `unknown` is a normal outcome and not an error — about one in six of even the busiest correspondents has no contact card. When `contacts.available` is false, nobody looked at all and every handle is raw.",
|
|
1933
|
+
"Tapbacks are rows in the message table. They are filtered out of conversations by default and reported on the message they target instead.",
|
|
1934
|
+
writes ? "Sending is real and immediate, and it is the ONLY thing this server can change — the dictionary has no edit, delete, mark-as-read or reaction command. Messages hands back no identifier for what it sent, so the sent row is found by re-reading the store; `reconciliation: \"pending\"` means it has not appeared yet, which is not a failure and must not be retried." : "This server cannot send: APPLE_MESSAGES_ALLOW_WRITES is off. With it on, one tool appears — apple_messages_send_message — and it is the only mutating command the Messages dictionary offers."
|
|
1935
|
+
]
|
|
1936
|
+
};
|
|
1937
|
+
};
|
|
1938
|
+
const registerDiagnosticsTools = (server, client) => {
|
|
1939
|
+
server.registerTool("apple_messages_diagnostics", {
|
|
1940
|
+
description: "Report whether the Messages store could be opened, how much is in it, whether names are being resolved, and what this server cannot do. Start here when a read returns nothing.",
|
|
1941
|
+
inputSchema: {},
|
|
1942
|
+
annotations: { readOnlyHint: true }
|
|
1943
|
+
}, async () => wrap(() => buildDiagnostics(client)));
|
|
1944
|
+
};
|
|
1945
|
+
//#endregion
|
|
1946
|
+
//#region src/tools/actions.ts
|
|
1947
|
+
const registerActionTools = (server, client) => {
|
|
1948
|
+
server.registerTool("apple_messages_send_message", {
|
|
1949
|
+
description: "Send a message to an existing conversation or to a phone number / email address. This sends a REAL message from the user's own iMessage/SMS account, immediately and irreversibly. There is no unsend, no draft and no preview: the recipient's phone buzzes as soon as this returns. Messages may be launched to do it, which the user will see. Confirm the exact wording and the exact recipient with the user before calling this. Prefer chatRef from apple_messages_list_chats over a raw handle: Messages refuses to enumerate participants for a script, so an existing conversation is the only target this server can address reliably — a handle with no conversation on this Mac will usually fail rather than start a new thread. The result reports which targeting strategy worked, and reconciles against the message store to hand back a real message ref: reconciliation `matched` means the sent row was found and `message` is it, `pending` means Messages accepted the send but has not written the row yet (normal on a slow network — read the chat back, do NOT send again), and `unavailable` means there was no existing chat to look in.",
|
|
1950
|
+
inputSchema: {
|
|
1951
|
+
chatRef: z.string().optional().describe("An opaque chat ref from apple_messages_list_chats (\"mc1:<guid>\"). The reliable way to target a send. Do not construct one by hand."),
|
|
1952
|
+
to: z.string().optional().describe("A phone number or email address, when there is no chat ref. Matched against the store's own handles by last-9-digits, so local and international spellings of the same number both work."),
|
|
1953
|
+
text: z.string().min(1).describe("Exactly what to send. Sent verbatim."),
|
|
1954
|
+
service: z.enum([
|
|
1955
|
+
"imessage",
|
|
1956
|
+
"sms",
|
|
1957
|
+
"rcs"
|
|
1958
|
+
]).optional().describe("Which service to prefer when addressing a handle with no existing chat. Ignored when chatRef is given, since the chat already knows its service."),
|
|
1959
|
+
confirm: confirmArg
|
|
1960
|
+
},
|
|
1961
|
+
annotations: {
|
|
1962
|
+
readOnlyHint: false,
|
|
1963
|
+
destructiveHint: true,
|
|
1964
|
+
idempotentHint: false
|
|
1965
|
+
}
|
|
1966
|
+
}, async ({ chatRef, to, text, service }) => wrap(async () => {
|
|
1967
|
+
if (Boolean(chatRef) === Boolean(to)) throw new PreconditionError("Give exactly one of chatRef or to. chatRef targets an existing conversation and is the reliable form; to addresses a phone number or email directly.");
|
|
1968
|
+
return client.sendMessage({
|
|
1969
|
+
...chatRef ? { chatRef } : {},
|
|
1970
|
+
...to ? { to } : {},
|
|
1971
|
+
...service ? { service } : {},
|
|
1972
|
+
text
|
|
1973
|
+
});
|
|
1974
|
+
}));
|
|
1975
|
+
};
|
|
1976
|
+
//#endregion
|
|
1977
|
+
//#region src/tools/attachments.ts
|
|
1978
|
+
/**
|
|
1979
|
+
* Saving an attachment out of a conversation.
|
|
1980
|
+
*
|
|
1981
|
+
* ## Why this is not in `actions.ts`
|
|
1982
|
+
*
|
|
1983
|
+
* That file's argument is that Messages' scripting dictionary has exactly one
|
|
1984
|
+
* usable verb, so this server has exactly one mutating tool — and that with
|
|
1985
|
+
* writes off it sends no Apple Event at all. Both remain true with this tool
|
|
1986
|
+
* registered: it opens a file and writes a file, and never speaks to
|
|
1987
|
+
* Messages.app. Filing it next to `send` would quietly weaken a claim
|
|
1988
|
+
* `diagnostics` makes about what the write gate buys.
|
|
1989
|
+
*
|
|
1990
|
+
* ## Why it is behind the write gate anyway
|
|
1991
|
+
*
|
|
1992
|
+
* It puts a file on the user's disk. Mail and Notes made the same call for the
|
|
1993
|
+
* same reason, and the three should not disagree about it: a user who has not
|
|
1994
|
+
* turned writes on has not agreed to this server creating files.
|
|
1995
|
+
*/
|
|
1996
|
+
const registerAttachmentTools = (server, client) => {
|
|
1997
|
+
server.registerTool("apple_messages_save_attachment", {
|
|
1998
|
+
description: "Save one attachment from a conversation to disk — a photo, a video, a PDF, a voice memo. Take the `id` from an attachment on apple_messages_get_message. Needs Full Disk Access, like every read here. It can only write into APPLE_MESSAGES_ATTACHMENT_DIR (default ~/Downloads) and will not overwrite an existing file unless you ask it to. Write-gated because it puts a file on the user's disk, even though it changes nothing in Messages. An attachment iCloud has offloaded has no bytes on this Mac and is refused with that reason — open the conversation in Messages to pull it down first.",
|
|
1999
|
+
inputSchema: {
|
|
2000
|
+
attachmentId: z.string().min(1).describe("The `id` of an attachment from apple_messages_get_message. Opaque; do not construct one, and do not pass the file path shown next to it."),
|
|
2001
|
+
directory: z.string().optional().describe("Override the target directory. Must still resolve inside the configured one."),
|
|
2002
|
+
overwrite: z.boolean().optional().describe("Replace an existing file. Default false.")
|
|
2003
|
+
},
|
|
2004
|
+
annotations: {
|
|
2005
|
+
readOnlyHint: false,
|
|
2006
|
+
destructiveHint: false,
|
|
2007
|
+
idempotentHint: false
|
|
2008
|
+
}
|
|
2009
|
+
}, async ({ attachmentId, directory, overwrite }) => wrap(async () => client.saveAttachment(attachmentId, {
|
|
2010
|
+
...directory ? { directory } : {},
|
|
2011
|
+
...overwrite ? { overwrite } : {}
|
|
2012
|
+
})));
|
|
2013
|
+
};
|
|
2014
|
+
//#endregion
|
|
2015
|
+
//#region src/tools/chats.ts
|
|
2016
|
+
/**
|
|
2017
|
+
* NOTE ON `async` BELOW: core's `wrap` is typed `() => Promise<T>` because most
|
|
2018
|
+
* surfaces reach Apple Events. Messages reads synchronous SQLite and never
|
|
2019
|
+
* leaves the process, so the thunks are marked async here rather than widening a
|
|
2020
|
+
* shared signature for every surface to accommodate one.
|
|
2021
|
+
*/
|
|
2022
|
+
const registerChatTools = (server, client) => {
|
|
2023
|
+
server.registerTool("apple_messages_list_chats", {
|
|
2024
|
+
description: "List conversations, most recently active first, with their participants and message counts. Names come from Contacts where they resolve; where they do not, the raw phone number or email is shown and `resolution` says why. Use the returned ref to read one conversation with apple_messages_list_messages.",
|
|
2025
|
+
inputSchema: { limit: limitArg },
|
|
2026
|
+
annotations: { readOnlyHint: true }
|
|
2027
|
+
}, async ({ limit }) => wrap(async () => client.listChats(limit)));
|
|
2028
|
+
};
|
|
2029
|
+
//#endregion
|
|
2030
|
+
//#region src/tools/messages.ts
|
|
2031
|
+
/** ISO-8601 in, or a clear refusal. No relative grammar on this surface yet. */
|
|
2032
|
+
const parseBound = (raw, field) => {
|
|
2033
|
+
if (raw === void 0) return void 0;
|
|
2034
|
+
const d = new Date(raw);
|
|
2035
|
+
if (Number.isNaN(d.getTime())) throw new Error(`Could not read ${field} from ${JSON.stringify(raw)}. Use ISO-8601: "2026-08-01" or "2026-08-01T09:00".`);
|
|
2036
|
+
return d;
|
|
2037
|
+
};
|
|
2038
|
+
const registerMessageTools = (server, client) => {
|
|
2039
|
+
server.registerTool("apple_messages_list_messages", {
|
|
2040
|
+
description: "Read messages, newest first — the whole store, or one conversation, or a date window. Tapbacks are excluded by default because they are rows in the same table and would otherwise read as messages nobody typed.\n\nEvery message carries `from.name` where Contacts resolves it and `from.handle` always. `from.resolution` is worth reading: `unknown` is COMMON AND NOT AN ERROR — measured on a real store, about one in six of even the busiest correspondents has no contact card.",
|
|
2041
|
+
inputSchema: {
|
|
2042
|
+
chatRef: chatRefArg,
|
|
2043
|
+
from: fromArg,
|
|
2044
|
+
to: toArg,
|
|
2045
|
+
includeReactions: includeReactionsArg,
|
|
2046
|
+
limit: limitArg
|
|
2047
|
+
},
|
|
2048
|
+
annotations: { readOnlyHint: true }
|
|
2049
|
+
}, async ({ chatRef, from, to, includeReactions, limit }) => wrap(async () => {
|
|
2050
|
+
const window = client.window(parseBound(from, "from"), parseBound(to, "to"));
|
|
2051
|
+
return client.listMessages({
|
|
2052
|
+
...chatRef ? { chatRef: decodeChatRef(chatRef) } : {},
|
|
2053
|
+
...window,
|
|
2054
|
+
...includeReactions === void 0 ? {} : { includeReactions },
|
|
2055
|
+
...limit === void 0 ? {} : { limit }
|
|
2056
|
+
});
|
|
2057
|
+
}));
|
|
2058
|
+
server.registerTool("apple_messages_search_messages", {
|
|
2059
|
+
description: "Search message text across every conversation. Matches are substring and case-insensitive.\n\nThis searches ALL messages, including the roughly 3% whose text lives only in an archived blob rather than in a plain column — those are invisible to any SQL query and are decoded here. `textSource` on each result says which produced it.",
|
|
2060
|
+
inputSchema: {
|
|
2061
|
+
query: z.string().min(1).describe("Text to look for."),
|
|
2062
|
+
limit: limitArg
|
|
2063
|
+
},
|
|
2064
|
+
annotations: { readOnlyHint: true }
|
|
2065
|
+
}, async ({ query, limit }) => wrap(async () => client.searchMessages(query, limit)));
|
|
2066
|
+
server.registerTool("apple_messages_get_message", {
|
|
2067
|
+
description: "One message in full, with its tapbacks and attachments. Reactions are reported here rather than mixed into the conversation, which is the whole reason list_messages filters them out.",
|
|
2068
|
+
inputSchema: { ref: messageRefArg },
|
|
2069
|
+
annotations: { readOnlyHint: true }
|
|
2070
|
+
}, async ({ ref }) => wrapResult(async () => {
|
|
2071
|
+
const message = client.getMessage(decodeMessageRef(ref));
|
|
2072
|
+
if (!message) return fail(`No message for ref "${ref}". It was probably deleted since the search ran. Re-run the search to get a current ref.`);
|
|
2073
|
+
return ok(message);
|
|
2074
|
+
}));
|
|
2075
|
+
};
|
|
2076
|
+
//#endregion
|
|
2077
|
+
//#region src/tools/index.ts
|
|
2078
|
+
/**
|
|
2079
|
+
* Register the Apple Messages tools.
|
|
2080
|
+
*
|
|
2081
|
+
* Five reads, always. Two writes, only when `allowWrites` is on — and on this
|
|
2082
|
+
* surface the flag carries a permission claim as well as a safety one: with it
|
|
2083
|
+
* off no Apple Event is ever sent, so no Automation grant is ever requested.
|
|
2084
|
+
* What is needed either way is Full Disk Access, absolutely — see `diagnostics`.
|
|
2085
|
+
*
|
|
2086
|
+
* The registered set does NOT vary with whether the store is readable. That is a
|
|
2087
|
+
* runtime condition, and MCP clients cache the tool list.
|
|
2088
|
+
*/
|
|
2089
|
+
const registerTools = (server, client, ctx) => {
|
|
2090
|
+
registerDiagnosticsTools(server, client);
|
|
2091
|
+
registerChatTools(server, client);
|
|
2092
|
+
registerMessageTools(server, client);
|
|
2093
|
+
if (!ctx.allowWrites) return;
|
|
2094
|
+
registerActionTools(server, client);
|
|
2095
|
+
registerAttachmentTools(server, client);
|
|
2096
|
+
};
|
|
2097
|
+
//#endregion
|
|
2098
|
+
//#region src/server.ts
|
|
2099
|
+
const SERVER_NAME = BUILD_INFO.name;
|
|
2100
|
+
const SERVER_VERSION = BUILD_INFO.version;
|
|
2101
|
+
/**
|
|
2102
|
+
* Build the server. Side-effect free: it opens no database and reads no file,
|
|
2103
|
+
* so a test can construct it freely and every external dependency arrives
|
|
2104
|
+
* through an option.
|
|
2105
|
+
*/
|
|
2106
|
+
const createServer = (opts) => {
|
|
2107
|
+
const { config } = opts;
|
|
2108
|
+
const server = new McpServer({
|
|
2109
|
+
name: SERVER_NAME,
|
|
2110
|
+
version: SERVER_VERSION
|
|
2111
|
+
});
|
|
2112
|
+
const client = new AppleMessagesClient({
|
|
2113
|
+
config,
|
|
2114
|
+
...opts.logger ? { logger: opts.logger } : {},
|
|
2115
|
+
...opts.osascript ? { osascript: opts.osascript } : {},
|
|
2116
|
+
...opts.home ? { home: opts.home } : {},
|
|
2117
|
+
...opts.contacts === void 0 ? {} : { contacts: opts.contacts }
|
|
2118
|
+
});
|
|
2119
|
+
registerTools(server, client, { allowWrites: config.allowWrites });
|
|
2120
|
+
if (config.exposePrompts) {
|
|
2121
|
+
registerPrompts(server, config.allowWrites);
|
|
2122
|
+
registerSurfaceResources(server, {
|
|
2123
|
+
surface: "messages",
|
|
2124
|
+
displayName: "Messages",
|
|
2125
|
+
guide: MESSAGES_GUIDE,
|
|
2126
|
+
diagnostics: () => buildDiagnostics(client)
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
return {
|
|
2130
|
+
server,
|
|
2131
|
+
client
|
|
2132
|
+
};
|
|
2133
|
+
};
|
|
2134
|
+
//#endregion
|
|
2135
|
+
export { MESSAGES_SURFACE as A, toAppleSeconds as B, locateStore as C, ChatNotFoundError as D, AppleMessagesError as E, SendTargetNotFoundError as F, CORE_DATA_EPOCH_OFFSET as I, appleSecondsSql as L, MessagesUnavailableError as M, SchemaDriftError$1 as N, IndexUnavailableError$1 as O, SendFailedError as P, fromAppleSeconds as R, defaultStorePath as S, PRELUDE as T, BUILD_INFO as V, decodeMessageRef as _, loadConfig$1 as a, ATTACHMENTS_RELATIVE as b, introspect as c, decodeAttributedBody as d, outline as f, decodeChatRef as g, MESSAGE_REF_VERSION as h, registerTools as i, MessageNotFoundError as j, MESSAGES_BUNDLE_ID as k, openStore as l, InvalidMessageRefError as m, SERVER_VERSION as n, AppleMessagesClient as o, CHAT_REF_VERSION as p, createServer as r, MessagesStore as s, SERVER_NAME as t, reactionLabel as u, encodeChatRef as v, SEND_MESSAGE as w, STORE_RELATIVE as x, encodeMessageRef as y, renderInstant as z };
|
|
2136
|
+
|
|
2137
|
+
//# sourceMappingURL=server-DH4U3LBX.js.map
|