@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
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
import { AppleAutomationError, AppleAutomationError as AppleMessagesError, BuildInfo, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, Logger, OsascriptRunner, ReadOnlyMode, SchemaDriftError, StoreFacts, SurfaceContext } from "@mgcrea/mcp-apple-core";
|
|
2
|
+
import { AppleContactsClient } from "@mgcrea/mcp-apple-contacts";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
//#region src/build-info.d.ts
|
|
7
|
+
declare const BUILD_INFO: BuildInfo;
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/config.d.ts
|
|
10
|
+
/**
|
|
11
|
+
* Configuration is environment-only — this server holds no secret at all, its
|
|
12
|
+
* access is the macOS permission the user granted.
|
|
13
|
+
*
|
|
14
|
+
* `allowWrites` is inherited from `BaseConfigSchema` and, since 1.2.0, means
|
|
15
|
+
* something here: it gates the one mutating tool, `send_message`. With it off
|
|
16
|
+
* this server registers no write tool and therefore sends no Apple Event at all,
|
|
17
|
+
* which is what keeps its "no Automation grant" claim true by default.
|
|
18
|
+
*
|
|
19
|
+
* `docs/messages.md` recorded why sending went unprobed for so long — "probing
|
|
20
|
+
* it would mean sending a real message to a real person" — and left the id
|
|
21
|
+
* bridge open, because Apple Events returns no chat identifier to reconcile
|
|
22
|
+
* against. `sendReconcileMs` is that decision made: the file lane finds the row
|
|
23
|
+
* instead.
|
|
24
|
+
*/
|
|
25
|
+
declare const ConfigSchema: z.ZodObject<{
|
|
26
|
+
allowWrites: z.ZodDefault<z.ZodBoolean>;
|
|
27
|
+
exposePrompts: z.ZodDefault<z.ZodBoolean>;
|
|
28
|
+
debug: z.ZodDefault<z.ZodBoolean>;
|
|
29
|
+
osascriptPath: z.ZodDefault<z.ZodString>;
|
|
30
|
+
osascriptTimeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
31
|
+
maxResults: z.ZodDefault<z.ZodNumber>;
|
|
32
|
+
storePath: z.ZodOptional<z.ZodString>;
|
|
33
|
+
indexMode: z.ZodDefault<z.ZodEnum<{
|
|
34
|
+
auto: "auto";
|
|
35
|
+
immutable: "immutable";
|
|
36
|
+
off: "off";
|
|
37
|
+
ro: "ro";
|
|
38
|
+
}>>;
|
|
39
|
+
resolveContacts: z.ZodDefault<z.ZodBoolean>;
|
|
40
|
+
attachmentDir: z.ZodDefault<z.ZodString>;
|
|
41
|
+
defaultRangeDays: z.ZodDefault<z.ZodNumber>;
|
|
42
|
+
sendReconcileMs: z.ZodDefault<z.ZodNumber>;
|
|
43
|
+
}, z.core.$strict>;
|
|
44
|
+
type Config = z.infer<typeof ConfigSchema>;
|
|
45
|
+
declare const loadConfig: (env?: NodeJS.ProcessEnv) => Config;
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/client/locate.d.ts
|
|
48
|
+
/**
|
|
49
|
+
* Find Messages' store.
|
|
50
|
+
*
|
|
51
|
+
* The easiest locator in the repo: `~/Library/Messages/chat.db` is a constant,
|
|
52
|
+
* with no generated directory name to list (Reminders) and no per-account fan-out
|
|
53
|
+
* (Contacts). `statSync` succeeds on a TCC-protected file while `open` is denied,
|
|
54
|
+
* so this distinguishes "not there" from "not allowed" with no permission at all
|
|
55
|
+
* — which is most of what diagnostics is for on a surface where the grant is
|
|
56
|
+
* mandatory.
|
|
57
|
+
*/
|
|
58
|
+
declare const STORE_RELATIVE: string;
|
|
59
|
+
/** Attachment bytes live here, referenced by `attachment.filename`. */
|
|
60
|
+
declare const ATTACHMENTS_RELATIVE: string;
|
|
61
|
+
type LocateResult = StoreFacts & {
|
|
62
|
+
storePath: string;
|
|
63
|
+
attachmentsPath: string;
|
|
64
|
+
/** Nothing outside this is copied out, whatever the store says. */
|
|
65
|
+
messagesRoot: string;
|
|
66
|
+
reason: string | null;
|
|
67
|
+
};
|
|
68
|
+
declare const defaultStorePath: (home?: string) => string;
|
|
69
|
+
declare const locateStore: (opts?: {
|
|
70
|
+
storePath?: string | undefined;
|
|
71
|
+
home?: string;
|
|
72
|
+
}) => LocateResult;
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/client/store.d.ts
|
|
75
|
+
declare const reactionLabel: (type: number) => string;
|
|
76
|
+
type StoreCapabilities = {
|
|
77
|
+
fingerprint: string;
|
|
78
|
+
messageColumns: Set<string>;
|
|
79
|
+
chatColumns: Set<string>;
|
|
80
|
+
handleColumns: Set<string>;
|
|
81
|
+
attachmentColumns: Set<string>;
|
|
82
|
+
hasAttachments: boolean;
|
|
83
|
+
hasReactions: boolean;
|
|
84
|
+
hasThreads: boolean;
|
|
85
|
+
hasEdits: boolean;
|
|
86
|
+
};
|
|
87
|
+
type MessageRow = {
|
|
88
|
+
guid: string;
|
|
89
|
+
chatGuid: string | null;
|
|
90
|
+
chatName: string | null;
|
|
91
|
+
handle: string | null;
|
|
92
|
+
service: string | null;
|
|
93
|
+
isFromMe: boolean;
|
|
94
|
+
/** Apple-seconds. Render with `renderInstant`. */
|
|
95
|
+
sentAt: number | null;
|
|
96
|
+
readAt: number | null;
|
|
97
|
+
deliveredAt: number | null;
|
|
98
|
+
editedAt: number | null;
|
|
99
|
+
text: string | null;
|
|
100
|
+
/** Which lane answered — the column, or the decoder. */
|
|
101
|
+
textSource: "column" | "decoded" | "none";
|
|
102
|
+
subject: string | null;
|
|
103
|
+
isRead: boolean;
|
|
104
|
+
isSent: boolean;
|
|
105
|
+
isDelivered: boolean;
|
|
106
|
+
hasAttachments: boolean;
|
|
107
|
+
/** Set when this row is a tapback on another message rather than a message. */
|
|
108
|
+
reactionType: number | null;
|
|
109
|
+
reactionTarget: string | null;
|
|
110
|
+
/** Set when this message is a reply in a thread. */
|
|
111
|
+
threadOriginator: string | null;
|
|
112
|
+
/** Non-zero for a group event — someone joined, left, or renamed the chat. */
|
|
113
|
+
itemType: number | null;
|
|
114
|
+
};
|
|
115
|
+
type ChatRow = {
|
|
116
|
+
guid: string;
|
|
117
|
+
identifier: string | null;
|
|
118
|
+
displayName: string | null;
|
|
119
|
+
/** 43 is a group chat, 45 is one-to-one. Reported raw beside the boolean. */
|
|
120
|
+
style: number | null;
|
|
121
|
+
isGroup: boolean;
|
|
122
|
+
service: string | null;
|
|
123
|
+
participants: string[];
|
|
124
|
+
messages: number;
|
|
125
|
+
lastMessageAt: number | null;
|
|
126
|
+
};
|
|
127
|
+
type RangeQuery = {
|
|
128
|
+
chatGuid?: string | undefined;
|
|
129
|
+
fromApple?: number | undefined;
|
|
130
|
+
toApple?: number | undefined;
|
|
131
|
+
includeReactions?: boolean | undefined;
|
|
132
|
+
limit: number;
|
|
133
|
+
};
|
|
134
|
+
declare class MessagesStore {
|
|
135
|
+
#private;
|
|
136
|
+
readonly db: DatabaseSync;
|
|
137
|
+
readonly mode: string;
|
|
138
|
+
readonly caps: StoreCapabilities;
|
|
139
|
+
constructor(db: DatabaseSync, mode: string, caps: StoreCapabilities);
|
|
140
|
+
/**
|
|
141
|
+
* A window of messages, newest first.
|
|
142
|
+
*
|
|
143
|
+
* Reactions are excluded by default. They are rows in this table like any
|
|
144
|
+
* other, and 2,788 of them would otherwise appear as messages reading
|
|
145
|
+
* `Liked "see you at 8"` — which is not something anybody typed.
|
|
146
|
+
*/
|
|
147
|
+
range(q: RangeQuery): MessageRow[];
|
|
148
|
+
/**
|
|
149
|
+
* Text search, in two passes — and the second one is the point.
|
|
150
|
+
*
|
|
151
|
+
* Pass 1 is `LIKE` on the column: measured at **16 ms over 97,416 rows** with
|
|
152
|
+
* 27 existing indexes, so there is no index-vs-scan tradeoff to litigate and
|
|
153
|
+
* no FTS table to build.
|
|
154
|
+
*
|
|
155
|
+
* Pass 2 covers what pass 1 structurally cannot. 3,051 messages have an empty
|
|
156
|
+
* `text` and content only in `attributedBody`, and no amount of SQL reaches
|
|
157
|
+
* inside a blob. Decoding them costs about **6 ms** — the decoder runs at
|
|
158
|
+
* 2 ms per thousand — so completeness here is nearly free, and a search that
|
|
159
|
+
* silently omitted one message in thirty-two would be the worst kind of wrong.
|
|
160
|
+
*/
|
|
161
|
+
search(query: string, limit: number, includeReactions?: boolean): MessageRow[];
|
|
162
|
+
byGuid(guid: string): MessageRow | null;
|
|
163
|
+
/** Tapbacks aimed at one message. */
|
|
164
|
+
reactionsFor(guid: string): {
|
|
165
|
+
type: number;
|
|
166
|
+
label: string;
|
|
167
|
+
handle: string | null;
|
|
168
|
+
}[];
|
|
169
|
+
/**
|
|
170
|
+
* The attachments on one message.
|
|
171
|
+
*
|
|
172
|
+
* `id` is `attachment.guid`, for the reason `ref.ts` gives at length about
|
|
173
|
+
* messages: the ROWID is faster and gets REUSED. Every "delete this
|
|
174
|
+
* conversation" frees a block of attachment ids for the next insert, so a
|
|
175
|
+
* caller that listed attachments in one turn and saved one two turns later
|
|
176
|
+
* would write out a different file, silently. The guid is `UNIQUE NOT NULL`
|
|
177
|
+
* in the shipped schema and is what Apple's own sync joins on.
|
|
178
|
+
*
|
|
179
|
+
* `path` is the raw `filename` column, reported so a caller can see WHERE the
|
|
180
|
+
* bytes are before asking for them — it is frequently `~`-prefixed, and it is
|
|
181
|
+
* empty for an attachment iCloud has offloaded.
|
|
182
|
+
*/
|
|
183
|
+
attachmentsFor(guid: string): {
|
|
184
|
+
id: string | null;
|
|
185
|
+
path: string | null;
|
|
186
|
+
mimeType: string | null;
|
|
187
|
+
transferName: string | null;
|
|
188
|
+
bytes: number | null;
|
|
189
|
+
isSticker: boolean;
|
|
190
|
+
}[];
|
|
191
|
+
/** One attachment by its guid, wherever it hangs. Null when it is gone. */
|
|
192
|
+
attachmentById(id: string): {
|
|
193
|
+
id: string | null;
|
|
194
|
+
path: string | null;
|
|
195
|
+
mimeType: string | null;
|
|
196
|
+
transferName: string | null;
|
|
197
|
+
bytes: number | null;
|
|
198
|
+
} | null;
|
|
199
|
+
chats(limit: number): ChatRow[];
|
|
200
|
+
/** One chat, by the guid a ref carries. Null when it has been deleted. */
|
|
201
|
+
chatByGuid(guid: string): ChatRow | null;
|
|
202
|
+
/**
|
|
203
|
+
* The chats that already exist with a set of handles, newest first.
|
|
204
|
+
*
|
|
205
|
+
* This is what makes a send addressable at all. Messages will not enumerate
|
|
206
|
+
* participants for a script, so the write lane cannot look a person up — but
|
|
207
|
+
* it can address a chat by guid, and the guid lives here. The read lane
|
|
208
|
+
* choosing the target for the write lane is the whole arrangement; see
|
|
209
|
+
* `client/jxa/core.ts`.
|
|
210
|
+
*
|
|
211
|
+
* Handles are matched as given. Suffix matching happens a layer up in
|
|
212
|
+
* `client/messages.ts`, where `packages/contacts`' measured `suffixKey` is
|
|
213
|
+
* available and the candidate list is the store's own 1,075 handles.
|
|
214
|
+
*/
|
|
215
|
+
chatsForHandles(handles: readonly string[], limit?: number): ChatRow[];
|
|
216
|
+
/**
|
|
217
|
+
* Outgoing messages in a set of chats since an instant — the send's receipt.
|
|
218
|
+
*
|
|
219
|
+
* `docs/messages.md` recorded that Apple Events returns no chat identifier, so
|
|
220
|
+
* a send "cannot report what it wrote by id". That is true of the write lane
|
|
221
|
+
* alone and false of the pair: the row Messages writes for an outgoing message
|
|
222
|
+
* is an ordinary row in this table, and a narrow window plus the target chat
|
|
223
|
+
* identifies it. Matching on text as well would be wrong — two identical
|
|
224
|
+
* messages a minute apart are a normal thing to send — so the caller passes a
|
|
225
|
+
* `sinceApple` taken immediately BEFORE the send and takes the oldest match.
|
|
226
|
+
*/
|
|
227
|
+
sentSince(chatGuids: readonly string[], sinceApple: number, limit?: number): MessageRow[];
|
|
228
|
+
/** Every distinct handle in the store, for a bulk resolve. */
|
|
229
|
+
handles(): string[];
|
|
230
|
+
counts(): {
|
|
231
|
+
messages: number;
|
|
232
|
+
chats: number;
|
|
233
|
+
handles: number;
|
|
234
|
+
attachments: number;
|
|
235
|
+
};
|
|
236
|
+
close(): void;
|
|
237
|
+
}
|
|
238
|
+
declare const introspect: (db: DatabaseSync) => StoreCapabilities;
|
|
239
|
+
declare const openStore: (path: string | null, mode: ReadOnlyMode, logger?: Logger) => MessagesStore | null;
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/client/messages.d.ts
|
|
242
|
+
/**
|
|
243
|
+
* Messages' one lane, plus the resolver.
|
|
244
|
+
*
|
|
245
|
+
* ## Why this depends on another surface
|
|
246
|
+
*
|
|
247
|
+
* `chat.db` records a correspondent as `+15551234567` and nothing else, so a
|
|
248
|
+
* server built on it alone answers *"+15551234567 said …"* — complete, and
|
|
249
|
+
* useless. `packages/contacts` exists because of that, and this is the consumer
|
|
250
|
+
* it was built for. It is a workspace import rather than an MCP call: reaching a
|
|
251
|
+
* function in the same repo through a protocol would be absurd.
|
|
252
|
+
*
|
|
253
|
+
* `docs/contacts.md` measured what to expect, and the numbers set the contract:
|
|
254
|
+
*
|
|
255
|
+
* | denominator | resolved |
|
|
256
|
+
* | ----------------------------- | -------- |
|
|
257
|
+
* | every handle ever seen | 27.6% |
|
|
258
|
+
* | messages in the last year | 97.6% |
|
|
259
|
+
* | the 25 busiest correspondents | 84% |
|
|
260
|
+
*
|
|
261
|
+
* So **`unknown` is a normal outcome, not an error.** About one in six of even
|
|
262
|
+
* the busiest correspondents does not resolve, and a caller that treats that as
|
|
263
|
+
* a failure will be wrong several times on any real inbox. Every rendered
|
|
264
|
+
* correspondent therefore carries both the resolved name and the raw handle.
|
|
265
|
+
*
|
|
266
|
+
* ## Degrading
|
|
267
|
+
*
|
|
268
|
+
* If Contacts cannot be read — its own TCC grant, separate from Full Disk
|
|
269
|
+
* Access, and one the user may simply not have answered — resolution is skipped
|
|
270
|
+
* and handles are returned raw. That is a capability downgrade, reported through
|
|
271
|
+
* `diagnostics`, never a throw: a Messages server with no names is still a
|
|
272
|
+
* Messages server.
|
|
273
|
+
*/
|
|
274
|
+
type CreateClientOptions = {
|
|
275
|
+
config: Config;
|
|
276
|
+
logger?: Logger;
|
|
277
|
+
/** Injected by tests. */
|
|
278
|
+
home?: string;
|
|
279
|
+
/** Injected by tests, so no test reaches the developer's real address book. */
|
|
280
|
+
contacts?: AppleContactsClient | null;
|
|
281
|
+
/** Injected by tests, so no test sends a real message to a real person. */
|
|
282
|
+
osascript?: OsascriptRunner;
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* What a send actually knows, which is less than a caller might assume.
|
|
286
|
+
*
|
|
287
|
+
* `sent` means Messages accepted the command without throwing. `delivered` is
|
|
288
|
+
* not a field here at all, because nothing in either lane reports it at send
|
|
289
|
+
* time. What closes the gap is `message`: the row the file lane found afterwards
|
|
290
|
+
* — a real ref, usable with `apple_messages_get_message` like any other.
|
|
291
|
+
*/
|
|
292
|
+
type SendResult = {
|
|
293
|
+
sent: true;
|
|
294
|
+
/** Which rung of the ladder in `client/jxa/core.ts` answered. */
|
|
295
|
+
strategy: string;
|
|
296
|
+
targetKind: string;
|
|
297
|
+
/** The chat the file lane chose, when it could choose one. */
|
|
298
|
+
chatRef: string | null;
|
|
299
|
+
chat: string | null;
|
|
300
|
+
to: Correspondent | null;
|
|
301
|
+
/** Whether Messages had to be launched. A launch is visible to the user. */
|
|
302
|
+
launched: boolean;
|
|
303
|
+
/** `matched` | `pending` | `unavailable` — see `#reconcile`. */
|
|
304
|
+
reconciliation: string;
|
|
305
|
+
message: RenderedMessage | null;
|
|
306
|
+
note?: string;
|
|
307
|
+
};
|
|
308
|
+
type Correspondent = {
|
|
309
|
+
/** Always present. The raw `handle.id` from the store. */
|
|
310
|
+
handle: string | null;
|
|
311
|
+
/** Null when unresolved, which is normal — see above. */
|
|
312
|
+
name: string | null;
|
|
313
|
+
/** `resolved` | `unknown` | `ambiguous` | `shortcode` | `unavailable`. */
|
|
314
|
+
resolution: string;
|
|
315
|
+
};
|
|
316
|
+
type RenderedMessage = {
|
|
317
|
+
ref: string;
|
|
318
|
+
chatRef: string | null;
|
|
319
|
+
chat: string | null;
|
|
320
|
+
from: Correspondent;
|
|
321
|
+
fromMe: boolean;
|
|
322
|
+
sentAt: string | null;
|
|
323
|
+
editedAt: string | null;
|
|
324
|
+
text: string | null;
|
|
325
|
+
/** `column` | `decoded` | `none` — which lane produced the text. */
|
|
326
|
+
textSource: string;
|
|
327
|
+
subject: string | null;
|
|
328
|
+
service: string | null;
|
|
329
|
+
isRead: boolean;
|
|
330
|
+
hasAttachments: boolean;
|
|
331
|
+
/** Present only when this row is a reply. */
|
|
332
|
+
replyToRef?: string;
|
|
333
|
+
/** Present only when this row is a group event rather than a message. */
|
|
334
|
+
itemType?: number;
|
|
335
|
+
};
|
|
336
|
+
type RenderedChat = {
|
|
337
|
+
ref: string;
|
|
338
|
+
name: string | null;
|
|
339
|
+
isGroup: boolean;
|
|
340
|
+
service: string | null;
|
|
341
|
+
participants: Correspondent[];
|
|
342
|
+
messages: number;
|
|
343
|
+
lastMessageAt: string | null;
|
|
344
|
+
};
|
|
345
|
+
declare class AppleMessagesClient {
|
|
346
|
+
#private;
|
|
347
|
+
constructor(opts: CreateClientOptions);
|
|
348
|
+
get config(): Config;
|
|
349
|
+
located(): LocateResult;
|
|
350
|
+
store(): MessagesStore | null;
|
|
351
|
+
listMessages(opts: {
|
|
352
|
+
chatRef?: string | undefined;
|
|
353
|
+
fromApple?: number | undefined;
|
|
354
|
+
toApple?: number | undefined;
|
|
355
|
+
includeReactions?: boolean | undefined;
|
|
356
|
+
limit?: number | undefined;
|
|
357
|
+
}): RenderedMessage[];
|
|
358
|
+
searchMessages(query: string, limit?: number): RenderedMessage[];
|
|
359
|
+
getMessage(guid: string): (RenderedMessage & {
|
|
360
|
+
reactions: {
|
|
361
|
+
label: string;
|
|
362
|
+
from: Correspondent;
|
|
363
|
+
}[];
|
|
364
|
+
attachments: ReturnType<MessagesStore["attachmentsFor"]>;
|
|
365
|
+
}) | null;
|
|
366
|
+
/**
|
|
367
|
+
* Copy one attachment out of the Messages store onto disk.
|
|
368
|
+
*
|
|
369
|
+
* ## Why this is a copy and not an extraction
|
|
370
|
+
*
|
|
371
|
+
* Mail's equivalent has to parse MIME out of an `.emlx` because the bytes are
|
|
372
|
+
* inside the message file. Messages does not work that way: `attachment` rows
|
|
373
|
+
* point at real files under `~/Library/Messages`, so the work here is finding
|
|
374
|
+
* the row, deciding the path is one we are willing to read, and copying.
|
|
375
|
+
*
|
|
376
|
+
* ## Two boundaries, not one
|
|
377
|
+
*
|
|
378
|
+
* The DESTINATION boundary is the same one Mail and Notes enforce:
|
|
379
|
+
* `attachmentDir` is a confinement, `directory` may only select inside it, and
|
|
380
|
+
* the leaf name is `basename`d because it comes from whoever sent the message.
|
|
381
|
+
*
|
|
382
|
+
* The SOURCE boundary is this surface's own. `filename` comes out of a
|
|
383
|
+
* database this server never writes, and it is a fully-qualified path: taken
|
|
384
|
+
* at face value it names any file the process can read. So it is required to
|
|
385
|
+
* resolve inside the Messages root before a single byte is read. That check
|
|
386
|
+
* has never fired on a real store and is not expected to — it is here so that
|
|
387
|
+
* the day the schema surprises us, the surprise is a refusal.
|
|
388
|
+
*/
|
|
389
|
+
saveAttachment(attachmentId: string, opts?: {
|
|
390
|
+
directory?: string | undefined;
|
|
391
|
+
overwrite?: boolean;
|
|
392
|
+
}): Promise<{
|
|
393
|
+
path: string;
|
|
394
|
+
bytes: number;
|
|
395
|
+
source: string;
|
|
396
|
+
mimeType: string | null;
|
|
397
|
+
}>;
|
|
398
|
+
listChats(limit?: number): RenderedChat[];
|
|
399
|
+
/**
|
|
400
|
+
* Send one message. A real one, to a real person, immediately.
|
|
401
|
+
*
|
|
402
|
+
* Everything difficult about this is in `client/jxa/core.ts`; what is left
|
|
403
|
+
* here is choosing the target from the file lane and reconciling afterwards.
|
|
404
|
+
*/
|
|
405
|
+
sendMessage(input: {
|
|
406
|
+
chatRef?: string | undefined;
|
|
407
|
+
to?: string | undefined;
|
|
408
|
+
text: string;
|
|
409
|
+
service?: string | undefined;
|
|
410
|
+
}): Promise<SendResult>;
|
|
411
|
+
/** Bounds for a range query, as apple-seconds. */
|
|
412
|
+
window(from?: Date, to?: Date): {
|
|
413
|
+
fromApple?: number;
|
|
414
|
+
toApple?: number;
|
|
415
|
+
};
|
|
416
|
+
status(): {
|
|
417
|
+
located: LocateResult;
|
|
418
|
+
store: {
|
|
419
|
+
opened: boolean;
|
|
420
|
+
mode: string | null;
|
|
421
|
+
fingerprint: string | null;
|
|
422
|
+
};
|
|
423
|
+
counts: ReturnType<MessagesStore["counts"]> | null;
|
|
424
|
+
contacts: {
|
|
425
|
+
enabled: boolean;
|
|
426
|
+
available: boolean;
|
|
427
|
+
resolved: number;
|
|
428
|
+
};
|
|
429
|
+
};
|
|
430
|
+
close(): void;
|
|
431
|
+
}
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region src/client/dates.d.ts
|
|
434
|
+
/**
|
|
435
|
+
* SQL that yields apple-SECONDS as a REAL, whichever unit the row holds.
|
|
436
|
+
*
|
|
437
|
+
* Done in SQL rather than in JS because the point is to never let the raw
|
|
438
|
+
* integer reach `node:sqlite`'s value conversion.
|
|
439
|
+
*/
|
|
440
|
+
declare const appleSecondsSql: (column: string) => string;
|
|
441
|
+
/** Apple-seconds to a JS Date. Null in, null out. */
|
|
442
|
+
declare const fromAppleSeconds: (value: number | null) => Date | null;
|
|
443
|
+
/** A JS Date to apple-seconds, for range bounds. */
|
|
444
|
+
declare const toAppleSeconds: (date: Date) => number;
|
|
445
|
+
/** ISO-8601, or null. What every date field on a result carries. */
|
|
446
|
+
declare const renderInstant: (value: number | null) => string | null;
|
|
447
|
+
//#endregion
|
|
448
|
+
//#region src/client/errors.d.ts
|
|
449
|
+
declare const MESSAGES_SURFACE: SurfaceContext;
|
|
450
|
+
/**
|
|
451
|
+
* Used by exactly one code path, and unused by every read.
|
|
452
|
+
*
|
|
453
|
+
* Messages is the one surface with NO Apple Events read lane at all — measured,
|
|
454
|
+
* not assumed. `docs/messages.md` records every attempt failing, and the reason
|
|
455
|
+
* is peculiar enough to write down: Messages answers "Application isn't running"
|
|
456
|
+
* while `NSRunningApplication` reports it running, because it lives as a
|
|
457
|
+
* windowless background process that declines to wake for a script. The liveness
|
|
458
|
+
* check and the app's own answer disagree and neither is lying.
|
|
459
|
+
*
|
|
460
|
+
* The id is the liveness check for `send`, which is the only thing Apple Events
|
|
461
|
+
* can do on this surface — see `client/jxa/core.ts`.
|
|
462
|
+
*/
|
|
463
|
+
declare const MESSAGES_BUNDLE_ID = "com.apple.MobileSMS";
|
|
464
|
+
/** A message ref no longer resolves — deleted, or the chat was cleared. */
|
|
465
|
+
declare class MessageNotFoundError extends AppleAutomationError {
|
|
466
|
+
readonly name = "MessageNotFoundError";
|
|
467
|
+
constructor(ref: string);
|
|
468
|
+
}
|
|
469
|
+
/** A chat ref no longer resolves. */
|
|
470
|
+
declare class ChatNotFoundError extends AppleAutomationError {
|
|
471
|
+
readonly name = "ChatNotFoundError";
|
|
472
|
+
constructor(ref: string);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* The store could not be read.
|
|
476
|
+
*
|
|
477
|
+
* Its own error because this surface fails harder than any other: there is no
|
|
478
|
+
* Apple Events fallback, so without Full Disk Access there is no server at all.
|
|
479
|
+
* `docs/distribution.md`'s "try before you grant" was retired partly because of
|
|
480
|
+
* this surface, and the message says so rather than implying a degraded mode
|
|
481
|
+
* that does not exist.
|
|
482
|
+
*/
|
|
483
|
+
declare class MessagesUnavailableError extends AppleAutomationError {
|
|
484
|
+
readonly name = "MessagesUnavailableError";
|
|
485
|
+
constructor(reason: string);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Messages would not accept any form of recipient for a send.
|
|
489
|
+
*
|
|
490
|
+
* Its own error because the cause is almost never the recipient. Every rung of
|
|
491
|
+
* the ladder in `client/jxa/core.ts` except the first one enumerates something,
|
|
492
|
+
* and enumeration is exactly what this app refuses — so the usual cause of this
|
|
493
|
+
* error is that the chat is new (no guid in the store to address it by) rather
|
|
494
|
+
* than that the person does not exist. The message says so, because "not found"
|
|
495
|
+
* would send a caller looking for a typo that is not there.
|
|
496
|
+
*/
|
|
497
|
+
declare class SendTargetNotFoundError extends AppleAutomationError {
|
|
498
|
+
readonly name = "SendTargetNotFoundError";
|
|
499
|
+
constructor(recipient: string, attempts: readonly string[]);
|
|
500
|
+
}
|
|
501
|
+
/** Messages accepted the target and then refused the send itself. */
|
|
502
|
+
declare class SendFailedError extends AppleAutomationError {
|
|
503
|
+
readonly name = "SendFailedError";
|
|
504
|
+
constructor(message: string, attempts: readonly string[]);
|
|
505
|
+
}
|
|
506
|
+
//#endregion
|
|
507
|
+
//#region src/client/ref.d.ts
|
|
508
|
+
/**
|
|
509
|
+
* Refs for messages and chats.
|
|
510
|
+
*
|
|
511
|
+
* ## Why a GUID rather than a rowid
|
|
512
|
+
*
|
|
513
|
+
* Both exist, and the rowid is faster to look up. The GUID wins anyway because
|
|
514
|
+
* **rowids are reused.** SQLite hands a deleted row's id to the next insert
|
|
515
|
+
* unless the table is `AUTOINCREMENT`, and Messages deletes constantly — every
|
|
516
|
+
* "delete this conversation" frees a block of them. A ref handed to a model in
|
|
517
|
+
* one turn and used two turns later would then resolve to a DIFFERENT message,
|
|
518
|
+
* with no error anywhere. That is the failure this project keeps designing
|
|
519
|
+
* against: plausible, wrong, and silent.
|
|
520
|
+
*
|
|
521
|
+
* The GUID is also what Apple itself joins on — `associated_message_guid` for
|
|
522
|
+
* reactions, `thread_originator_guid` for replies — so it is the identifier the
|
|
523
|
+
* schema already treats as stable.
|
|
524
|
+
*
|
|
525
|
+
* ## Why `m1:` and `mc1:`
|
|
526
|
+
*
|
|
527
|
+
* `c1:` is Calendar's, `r1:` is Reminders', `k1:` is Contacts'. A ref that
|
|
528
|
+
* decodes under two surfaces would be worse than one that decodes under none,
|
|
529
|
+
* so each prefix is claimed once and the version digit keeps a future scheme
|
|
530
|
+
* change additive.
|
|
531
|
+
*/
|
|
532
|
+
declare const MESSAGE_REF_VERSION = "m1";
|
|
533
|
+
declare const CHAT_REF_VERSION = "mc1";
|
|
534
|
+
declare class InvalidMessageRefError extends AppleAutomationError {
|
|
535
|
+
readonly name = "InvalidMessageRefError";
|
|
536
|
+
constructor(raw: string, want: "message" | "chat");
|
|
537
|
+
}
|
|
538
|
+
declare const encodeMessageRef: (guid: string) => string;
|
|
539
|
+
declare const encodeChatRef: (guid: string) => string;
|
|
540
|
+
declare const decodeMessageRef: (raw: string) => string;
|
|
541
|
+
declare const decodeChatRef: (raw: string) => string;
|
|
542
|
+
//#endregion
|
|
543
|
+
//#region src/client/typedstream.d.ts
|
|
544
|
+
/**
|
|
545
|
+
* NSArchiver `typedstream` reader — enough of it to pull the text out of a
|
|
546
|
+
* Messages `attributedBody` blob.
|
|
547
|
+
*
|
|
548
|
+
* ## Why this has to exist
|
|
549
|
+
*
|
|
550
|
+
* `docs/messages.md` measured it: 97,092 of 97,414 messages carry a blob and
|
|
551
|
+
* only 94,049 carry `text`. **The blob is the norm and `text` is the redundant
|
|
552
|
+
* copy**, not the other way round. 3,043 messages — one in thirty-two — have an
|
|
553
|
+
* empty `text` and content only in here, so a server that reads the column
|
|
554
|
+
* returns nothing for them, silently and with no error to notice.
|
|
555
|
+
*
|
|
556
|
+
* The archive header is `04 0B streamtyped 81 E8 03`. Not `bplist00`, not gzip,
|
|
557
|
+
* and not the protobuf that Notes turned out to hold, so none of the existing
|
|
558
|
+
* decoders apply.
|
|
559
|
+
*
|
|
560
|
+
* ## The format, as far as this reader needs it
|
|
561
|
+
*
|
|
562
|
+
* A stream of tagged values. Integers are a single signed byte unless prefixed:
|
|
563
|
+
*
|
|
564
|
+
* 0x81 int16 follows (little-endian)
|
|
565
|
+
* 0x82 int32 follows
|
|
566
|
+
* 0x83 float or double follows
|
|
567
|
+
* 0x84 START — a new class or object definition
|
|
568
|
+
* 0x85 nil / empty
|
|
569
|
+
* 0x86 END of the current object
|
|
570
|
+
* >=0x92 a back-reference; index = byte - 0x92
|
|
571
|
+
*
|
|
572
|
+
* Strings arrive length-prefixed. Class names and object contents both use that
|
|
573
|
+
* shape, which is why this walks structurally instead of pattern-matching: the
|
|
574
|
+
* same bytes mean different things depending on where you are.
|
|
575
|
+
*
|
|
576
|
+
* ## Honest failure, never a guess
|
|
577
|
+
*
|
|
578
|
+
* The Notes decoder was documented backwards because a `LIMIT 1` sample landed
|
|
579
|
+
* on an outlier, and this project has now been bitten three times by a heuristic
|
|
580
|
+
* that produced a plausible wrong answer. So there is no "longest printable run"
|
|
581
|
+
* fallback here. If the structure does not parse, `ok` is false and the caller
|
|
582
|
+
* counts it — and `text` remains available as the answer for the 96.5% of rows
|
|
583
|
+
* that have one.
|
|
584
|
+
*
|
|
585
|
+
* Pure and I/O-free.
|
|
586
|
+
*
|
|
587
|
+
* ## Two copies, deliberately
|
|
588
|
+
*
|
|
589
|
+
* `scripts/lib/typedstream.mjs` is the same reader, and the probe uses it to
|
|
590
|
+
* measure this one against a real store. They are kept in step by hand, as
|
|
591
|
+
* `packages/notes/src/client/protobuf.ts` and `scripts/lib/note-protobuf.mjs`
|
|
592
|
+
* already are: a probe that imported from a package would stop being runnable
|
|
593
|
+
* before that package exists, which is the wrong way round.
|
|
594
|
+
*/
|
|
595
|
+
/**
|
|
596
|
+
* The message text.
|
|
597
|
+
*
|
|
598
|
+
* ## Text only, and that is now a measured decision rather than a shortcut
|
|
599
|
+
*
|
|
600
|
+
* `docs/messages.md` left this open: *"whether `attributedBody` carries
|
|
601
|
+
* formatting that matters, or is only ever a redundant copy of `text` plus
|
|
602
|
+
* attachment placeholders. This decides whether the decoder must preserve
|
|
603
|
+
* structure or merely extract a string."*
|
|
604
|
+
*
|
|
605
|
+
* It is the second. Two things settle it:
|
|
606
|
+
*
|
|
607
|
+
* 1. **Attribute values are back-REFERENCED, not inline.** Archiving an
|
|
608
|
+
* attachment-shaped string through the real `NSArchiver` produces
|
|
609
|
+
* `92 84 98 98 22 "__kIMFileTransferGUIDAttributeName" 86` — where `98` is an
|
|
610
|
+
* index into the object table rather than a type encoding. Resolving those
|
|
611
|
+
* means reconstructing the whole table, which is materially more code and
|
|
612
|
+
* more ways to be silently wrong.
|
|
613
|
+
* 2. **Nothing needs it.** The one attribute worth having is the file-transfer
|
|
614
|
+
* GUID, and attachments are already reachable relationally — 17,529 rows in
|
|
615
|
+
* their own table, joined through `message_attachment_join`. The blob's copy
|
|
616
|
+
* is redundant, and the placeholder character itself (U+FFFC) survives in the
|
|
617
|
+
* text, so the position is not lost either.
|
|
618
|
+
*
|
|
619
|
+
* So this returns the backing string and says plainly that it stopped there.
|
|
620
|
+
* `hasAttributes` reports whether anything followed it, which is what a future
|
|
621
|
+
* decision to go further would be based on — a count, not a guess.
|
|
622
|
+
*/
|
|
623
|
+
type DecodedBody = {
|
|
624
|
+
ok: true;
|
|
625
|
+
text: string;
|
|
626
|
+
classes: string[];
|
|
627
|
+
/** Bytes remained after the backing store — attribute runs this does not decode. */
|
|
628
|
+
hasAttributes: boolean;
|
|
629
|
+
error: null;
|
|
630
|
+
} | {
|
|
631
|
+
ok: false;
|
|
632
|
+
text?: undefined;
|
|
633
|
+
classes?: string[];
|
|
634
|
+
hasAttributes?: undefined;
|
|
635
|
+
error: string;
|
|
636
|
+
};
|
|
637
|
+
declare const decodeAttributedBody: (buffer: Uint8Array | null | undefined) => DecodedBody;
|
|
638
|
+
/**
|
|
639
|
+
* A redacted structural outline of one blob.
|
|
640
|
+
*
|
|
641
|
+
* For measuring the format on a machine that has the data, without the report
|
|
642
|
+
* ever carrying a message. Every string is reduced to its length and its
|
|
643
|
+
* character class; only Apple's own constants survive as themselves.
|
|
644
|
+
*/
|
|
645
|
+
declare const outline: (buffer: Uint8Array, maxTokens?: number) => string[];
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region src/client/jxa/core.d.ts
|
|
648
|
+
/**
|
|
649
|
+
* The Apple Events lane for Messages — which exists for exactly one verb.
|
|
650
|
+
*
|
|
651
|
+
* Every script here is a static constant. None may contain a template
|
|
652
|
+
* interpolation: `assertStaticScript` rejects any script containing a dollar
|
|
653
|
+
* sign followed by a brace, including one written inside the JXA source. Use
|
|
654
|
+
* string concatenation in JXA code.
|
|
655
|
+
*
|
|
656
|
+
* Contract, shared with every other surface:
|
|
657
|
+
* - parameters arrive as `JSON.parse(argv[0])`
|
|
658
|
+
* - success returns `JSON.stringify({ok: true, data})`
|
|
659
|
+
* - an application-level failure returns `{ok: false, error: {code, message}}`
|
|
660
|
+
* and still exits 0, so a non-zero exit always means infrastructure.
|
|
661
|
+
*
|
|
662
|
+
* ## There is no read.ts here, and there never can be
|
|
663
|
+
*
|
|
664
|
+
* Adding a send did not add a read lane, and on this surface that is not a
|
|
665
|
+
* policy choice — it is the measurement in `docs/messages.md`:
|
|
666
|
+
*
|
|
667
|
+
* | attempt | result |
|
|
668
|
+
* | ---------------- | ----------------------------------------- |
|
|
669
|
+
* | `chats()` | `Error: Application isn't running.` |
|
|
670
|
+
* | `chats.id()` | `TypeError: M.chats.id is not a function` |
|
|
671
|
+
* | `participants()` | `Error: Application isn't running.` |
|
|
672
|
+
* | `buddies()` | `Error: Application isn't running.` |
|
|
673
|
+
* | messages of chat | `Error: Application isn't running.` |
|
|
674
|
+
*
|
|
675
|
+
* Messages answers "Application isn't running" while `NSRunningApplication`
|
|
676
|
+
* reports it running, because it lives as a windowless background process that
|
|
677
|
+
* declines to wake for a script. `test/jxa.test.ts` asserts `read.ts` does not
|
|
678
|
+
* exist, so this cannot be re-added out of helpfulness.
|
|
679
|
+
*
|
|
680
|
+
* The consequence for the code below is concrete: **the target resolution steps
|
|
681
|
+
* that enumerate anything are expected to fail**, which is why they are a ladder
|
|
682
|
+
* and why every rung reports itself. The one rung that does not enumerate —
|
|
683
|
+
* `chats.byId(guid)`, with the guid handed over by the file lane — is the one
|
|
684
|
+
* this design is built around.
|
|
685
|
+
*
|
|
686
|
+
* ## What the dictionary actually offers
|
|
687
|
+
*
|
|
688
|
+
* MEASURED from `sdef /System/Applications/Messages.app` on macOS 26.6. Three
|
|
689
|
+
* commands, and only one of them is a write:
|
|
690
|
+
*
|
|
691
|
+
* send text (or a file) to a participant or a chat
|
|
692
|
+
* login log in to all accounts
|
|
693
|
+
* logout log out of all accounts
|
|
694
|
+
*
|
|
695
|
+
* `login`/`logout` are not exposed as tools: logging a user out of iMessage on
|
|
696
|
+
* every device is not something to do behind a tool call, and there is no read
|
|
697
|
+
* to justify logging in.
|
|
698
|
+
*
|
|
699
|
+
* `send`'s direct parameter is typed `file` OR `text`. **This ships text only.**
|
|
700
|
+
* The file form is one branch away and deliberately not taken: a tool that
|
|
701
|
+
* transfers an arbitrary local path to a remote person is an exfiltration
|
|
702
|
+
* primitive, and unlike the text form its blast radius is not bounded by what
|
|
703
|
+
* the model can say. Recorded here so the omission reads as a decision.
|
|
704
|
+
*
|
|
705
|
+
* ## The `to` parameter, and why the file lane picks the target
|
|
706
|
+
*
|
|
707
|
+
* `send` takes a `participant` or a `chat`. Getting one of those normally means
|
|
708
|
+
* enumerating, which is the thing that does not work here. But the `chat` class
|
|
709
|
+
* carries `id` — "A guid identifier for this chat" — and the file lane already
|
|
710
|
+
* holds `chat.guid` for all 1,027 chats on the measured store. So the read lane
|
|
711
|
+
* chooses the target and the write lane addresses it by id, which is the only
|
|
712
|
+
* arrangement where neither lane has to do the thing it cannot.
|
|
713
|
+
*
|
|
714
|
+
* That the two guids are the same string is NOT assumed. It is the exact class
|
|
715
|
+
* of thing this project has been wrong about before — `docs/messages.md` calls
|
|
716
|
+
* the id bridge "unanswerable by construction" because Messages returned no
|
|
717
|
+
* identifier to bridge FROM. So `chats.byId` is rung one of a ladder, every rung
|
|
718
|
+
* records why it failed, and the strategy that worked is reported back to the
|
|
719
|
+
* caller in the tool result.
|
|
720
|
+
*/
|
|
721
|
+
/** The bundle id, which does not match the display name: Messages.app is still MobileSMS. */
|
|
722
|
+
declare const PRELUDE = "\nObjC.import(\"AppKit\");\n\nfunction ok(data) { return JSON.stringify({ ok: true, data: data }); }\nfunction err(code, message, extra) {\n var e = { code: code, message: String(message) };\n if (extra) e.detail = extra;\n return JSON.stringify({ ok: false, error: e });\n}\n\n/** Read one property defensively — every read on this surface is allowed to fail. */\nfunction prop(fn, fallback) {\n try {\n var v = fn();\n return v === undefined ? fallback : v;\n } catch (e) {\n return fallback;\n }\n}\n\nfunction isMessagesRunning() {\n var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier(\"com.apple.MobileSMS\");\n return apps.count > 0;\n}\n\n/** The dictionary's service type enumeration: SMS, iMessage, RCS. */\nfunction serviceEnum(name) {\n var s = String(name || \"\").toLowerCase();\n if (s === \"sms\") return \"SMS\";\n if (s === \"rcs\") return \"RCS\";\n return \"iMessage\";\n}\n\n/**\n * Find something `send` will accept as its `to`.\n *\n * Ordered cheapest and most-likely-to-work first. Every rung is wrapped, every\n * failure is recorded, and the caller is told which one answered — on a surface\n * whose whole read half is known broken, \"it worked\" without \"how\" is not a\n * result anyone can act on later.\n */\nfunction resolveTarget(M, p, tried) {\n var i;\n\n // 1. The chat guid the file lane read out of chat.db. No enumeration.\n if (p.chatGuid) {\n try {\n var chat = M.chats.byId(p.chatGuid);\n chat.id();\n return { target: chat, strategy: \"chat-guid\", kind: \"chat\" };\n } catch (e) {\n tried.push(\"chat-guid: \" + (e.message || e));\n }\n }\n\n if (!p.handle) return null;\n\n // 2. The guid Messages composes for a one-to-one chat, spelled the way the\n // store spells it: \"iMessage;-;+15551234567\". Constructed rather than read,\n // so it is below the real one and above everything that enumerates.\n var services = p.service ? [serviceEnum(p.service)] : [\"iMessage\", \"SMS\", \"RCS\"];\n for (i = 0; i < services.length; i++) {\n var guess = services[i] + \";-;\" + p.handle;\n try {\n var guessed = M.chats.byId(guess);\n guessed.id();\n return { target: guessed, strategy: \"chat-guid-guess\", kind: \"chat\", guid: guess };\n } catch (e2) {\n tried.push(\"chat-guid-guess(\" + guess + \"): \" + (e2.message || e2));\n }\n }\n\n // 3. A participant reached through its account. This enumerates, so it is\n // expected to fail with \"Application isn't running\" — kept because it is\n // the form every AppleScript example on the internet uses, and because if\n // launching the app does wake the scripting interface, this is what works.\n for (i = 0; i < services.length; i++) {\n try {\n var accounts = M.accounts.whose({ serviceType: services[i] })();\n for (var j = 0; j < accounts.length; j++) {\n try {\n var buddy = accounts[j].participants.whose({ handle: p.handle })()[0];\n if (buddy) {\n buddy.id();\n return { target: buddy, strategy: \"account-participant\", kind: \"participant\" };\n }\n } catch (e4) {\n tried.push(\"account-participant(\" + services[i] + \"): \" + (e4.message || e4));\n }\n }\n } catch (e3) {\n tried.push(\"accounts(\" + services[i] + \"): \" + (e3.message || e3));\n }\n }\n\n // 4. The flat participant list, last because it is the widest enumeration.\n try {\n var flat = M.participants.whose({ handle: p.handle })()[0];\n if (flat) {\n flat.id();\n return { target: flat, strategy: \"participant\", kind: \"participant\" };\n }\n tried.push(\"participant: no participant with that handle\");\n } catch (e5) {\n tried.push(\"participant: \" + (e5.message || e5));\n }\n\n return null;\n}\n";
|
|
723
|
+
//#endregion
|
|
724
|
+
//#region src/client/jxa/write.d.ts
|
|
725
|
+
/**
|
|
726
|
+
* One script, one verb.
|
|
727
|
+
*
|
|
728
|
+
* `send` is the only mutating command in the Messages dictionary that this
|
|
729
|
+
* server exposes — see `core.ts` for the full list and for why `login`/`logout`
|
|
730
|
+
* and the file form of `send` are left out.
|
|
731
|
+
*
|
|
732
|
+
* ## What this script deliberately does NOT do
|
|
733
|
+
*
|
|
734
|
+
* It does not report success from its own read-back, because there is nothing to
|
|
735
|
+
* read back: `send` returns no value, and every read this app offers fails. A
|
|
736
|
+
* script that answered `{ok: true}` and stopped would be claiming delivery on
|
|
737
|
+
* the strength of a command that did not throw — the exact shape of "plausible,
|
|
738
|
+
* wrong and silent" this repo keeps designing against.
|
|
739
|
+
*
|
|
740
|
+
* So the script's answer is deliberately narrow: **the send command was accepted
|
|
741
|
+
* by Messages, and here is how the target was addressed.** Whether a row landed
|
|
742
|
+
* is a question for the file lane, and `client/messages.ts` asks it immediately
|
|
743
|
+
* afterwards by polling chat.db for the outgoing row. That split is the answer
|
|
744
|
+
* to the open question `docs/messages.md` left — "whether a send should
|
|
745
|
+
* re-resolve by scanning the store for a recent row on the target chat" — and it
|
|
746
|
+
* is what makes a send reportable at all on a surface with no id bridge.
|
|
747
|
+
*/
|
|
748
|
+
declare const SEND_MESSAGE = "\nObjC.import(\"AppKit\");\n\nfunction ok(data) { return JSON.stringify({ ok: true, data: data }); }\nfunction err(code, message, extra) {\n var e = { code: code, message: String(message) };\n if (extra) e.detail = extra;\n return JSON.stringify({ ok: false, error: e });\n}\n\n/** Read one property defensively — every read on this surface is allowed to fail. */\nfunction prop(fn, fallback) {\n try {\n var v = fn();\n return v === undefined ? fallback : v;\n } catch (e) {\n return fallback;\n }\n}\n\nfunction isMessagesRunning() {\n var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier(\"com.apple.MobileSMS\");\n return apps.count > 0;\n}\n\n/** The dictionary's service type enumeration: SMS, iMessage, RCS. */\nfunction serviceEnum(name) {\n var s = String(name || \"\").toLowerCase();\n if (s === \"sms\") return \"SMS\";\n if (s === \"rcs\") return \"RCS\";\n return \"iMessage\";\n}\n\n/**\n * Find something `send` will accept as its `to`.\n *\n * Ordered cheapest and most-likely-to-work first. Every rung is wrapped, every\n * failure is recorded, and the caller is told which one answered — on a surface\n * whose whole read half is known broken, \"it worked\" without \"how\" is not a\n * result anyone can act on later.\n */\nfunction resolveTarget(M, p, tried) {\n var i;\n\n // 1. The chat guid the file lane read out of chat.db. No enumeration.\n if (p.chatGuid) {\n try {\n var chat = M.chats.byId(p.chatGuid);\n chat.id();\n return { target: chat, strategy: \"chat-guid\", kind: \"chat\" };\n } catch (e) {\n tried.push(\"chat-guid: \" + (e.message || e));\n }\n }\n\n if (!p.handle) return null;\n\n // 2. The guid Messages composes for a one-to-one chat, spelled the way the\n // store spells it: \"iMessage;-;+15551234567\". Constructed rather than read,\n // so it is below the real one and above everything that enumerates.\n var services = p.service ? [serviceEnum(p.service)] : [\"iMessage\", \"SMS\", \"RCS\"];\n for (i = 0; i < services.length; i++) {\n var guess = services[i] + \";-;\" + p.handle;\n try {\n var guessed = M.chats.byId(guess);\n guessed.id();\n return { target: guessed, strategy: \"chat-guid-guess\", kind: \"chat\", guid: guess };\n } catch (e2) {\n tried.push(\"chat-guid-guess(\" + guess + \"): \" + (e2.message || e2));\n }\n }\n\n // 3. A participant reached through its account. This enumerates, so it is\n // expected to fail with \"Application isn't running\" — kept because it is\n // the form every AppleScript example on the internet uses, and because if\n // launching the app does wake the scripting interface, this is what works.\n for (i = 0; i < services.length; i++) {\n try {\n var accounts = M.accounts.whose({ serviceType: services[i] })();\n for (var j = 0; j < accounts.length; j++) {\n try {\n var buddy = accounts[j].participants.whose({ handle: p.handle })()[0];\n if (buddy) {\n buddy.id();\n return { target: buddy, strategy: \"account-participant\", kind: \"participant\" };\n }\n } catch (e4) {\n tried.push(\"account-participant(\" + services[i] + \"): \" + (e4.message || e4));\n }\n }\n } catch (e3) {\n tried.push(\"accounts(\" + services[i] + \"): \" + (e3.message || e3));\n }\n }\n\n // 4. The flat participant list, last because it is the widest enumeration.\n try {\n var flat = M.participants.whose({ handle: p.handle })()[0];\n if (flat) {\n flat.id();\n return { target: flat, strategy: \"participant\", kind: \"participant\" };\n }\n tried.push(\"participant: no participant with that handle\");\n } catch (e5) {\n tried.push(\"participant: \" + (e5.message || e5));\n }\n\n return null;\n}\n\nfunction run(argv) {\n var p = JSON.parse(argv[0]);\n var M = Application(\"Messages\");\n\n var wasRunning = isMessagesRunning();\n if (!wasRunning && !p.allowLaunch) {\n return err(\"APP_NOT_RUNNING\", \"Messages is not running.\");\n }\n\n var tried = [];\n var resolved = resolveTarget(M, p, tried);\n if (!resolved) {\n return err(\n \"SEND_TARGET_NOT_FOUND\",\n \"Messages would not resolve a chat or participant for that recipient.\",\n tried\n );\n }\n\n try {\n M.send(p.text, { to: resolved.target });\n } catch (e) {\n return err(\"SEND_FAILED\", e.message || e, tried);\n }\n\n return ok({\n strategy: resolved.strategy,\n targetKind: resolved.kind,\n // Best effort, and allowed to be null: reading the id back is itself a read.\n targetId: prop(function () { return String(resolved.target.id()); }, null),\n launched: !wasRunning,\n attempts: tried\n });\n}\n";
|
|
749
|
+
//#endregion
|
|
750
|
+
//#region src/server.d.ts
|
|
751
|
+
declare const SERVER_NAME: string;
|
|
752
|
+
declare const SERVER_VERSION: string;
|
|
753
|
+
type CreateServerOptions = {
|
|
754
|
+
config: Config;
|
|
755
|
+
logger?: Logger;
|
|
756
|
+
/** Injected by tests so nothing spawns a process or touches real Messages. */
|
|
757
|
+
osascript?: OsascriptRunner;
|
|
758
|
+
/** Injected by tests so discovery never reaches the developer's real home. */
|
|
759
|
+
home?: string;
|
|
760
|
+
/** Injected by tests, so no test reaches the developer's real address book. */
|
|
761
|
+
contacts?: ConstructorParameters<typeof AppleMessagesClient>[0]["contacts"];
|
|
762
|
+
};
|
|
763
|
+
type CreatedServer = {
|
|
764
|
+
server: McpServer;
|
|
765
|
+
client: AppleMessagesClient;
|
|
766
|
+
};
|
|
767
|
+
/**
|
|
768
|
+
* Build the server. Side-effect free: it opens no database and reads no file,
|
|
769
|
+
* so a test can construct it freely and every external dependency arrives
|
|
770
|
+
* through an option.
|
|
771
|
+
*/
|
|
772
|
+
declare const createServer: (opts: CreateServerOptions) => CreatedServer;
|
|
773
|
+
//#endregion
|
|
774
|
+
//#region src/tools/index.d.ts
|
|
775
|
+
type ToolContext = {
|
|
776
|
+
/**
|
|
777
|
+
* Gates the two tools that change something outside this process.
|
|
778
|
+
*
|
|
779
|
+
* `send_message` is the only one that touches Messages.app, and it is also
|
|
780
|
+
* every Apple Event this server can send, because sending is the only thing
|
|
781
|
+
* Apple Events can do here — there is no read lane to fall back to and never
|
|
782
|
+
* can be. So with writes off this server remains inert with respect to
|
|
783
|
+
* Messages.app: it opens a file and nothing else.
|
|
784
|
+
*
|
|
785
|
+
* `save_attachment` is gated for a different reason. It sends no Apple Event
|
|
786
|
+
* and changes nothing in Messages; what it does is create a file on the
|
|
787
|
+
* user's disk, which Mail and Notes also treat as a write. The claim above
|
|
788
|
+
* survives it intact.
|
|
789
|
+
*/
|
|
790
|
+
allowWrites: boolean;
|
|
791
|
+
};
|
|
792
|
+
/**
|
|
793
|
+
* Register the Apple Messages tools.
|
|
794
|
+
*
|
|
795
|
+
* Five reads, always. Two writes, only when `allowWrites` is on — and on this
|
|
796
|
+
* surface the flag carries a permission claim as well as a safety one: with it
|
|
797
|
+
* off no Apple Event is ever sent, so no Automation grant is ever requested.
|
|
798
|
+
* What is needed either way is Full Disk Access, absolutely — see `diagnostics`.
|
|
799
|
+
*
|
|
800
|
+
* The registered set does NOT vary with whether the store is readable. That is a
|
|
801
|
+
* runtime condition, and MCP clients cache the tool list.
|
|
802
|
+
*/
|
|
803
|
+
declare const registerTools: (server: McpServer, client: AppleMessagesClient, ctx: ToolContext) => void;
|
|
804
|
+
//#endregion
|
|
805
|
+
export { ATTACHMENTS_RELATIVE, AppleMessagesClient, AppleMessagesError, BUILD_INFO, type BuildInfo, CHAT_REF_VERSION, CORE_DATA_EPOCH_OFFSET, ChatNotFoundError, type ChatRow, type Config, type Correspondent, type CreateClientOptions, type CreateServerOptions, type DecodedBody, IndexUnavailableError, InvalidMessageRefError, type LocateResult, MESSAGES_BUNDLE_ID, MESSAGES_SURFACE, MESSAGE_REF_VERSION, MessageNotFoundError, type MessageRow, MessagesStore, MessagesUnavailableError, PRELUDE, type RangeQuery, type RenderedChat, type RenderedMessage, SEND_MESSAGE, SERVER_NAME, SERVER_VERSION, STORE_RELATIVE, SchemaDriftError, SendFailedError, type SendResult, SendTargetNotFoundError, type StoreCapabilities, type ToolContext, appleSecondsSql, createServer, decodeAttributedBody, decodeChatRef, decodeMessageRef, defaultStorePath, encodeChatRef, encodeMessageRef, fromAppleSeconds, introspect, loadConfig, locateStore, openStore, outline, reactionLabel, registerTools, renderInstant, toAppleSeconds };
|
|
806
|
+
//# sourceMappingURL=index.d.ts.map
|