@mgcrea/mcp-apple-messages 1.30.0 → 1.34.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/README.md +13 -11
- package/dist/cli.js +5 -4
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +158 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/{server-rYfdOV2M.js → server-D-2jgV-B.js} +562 -37
- package/dist/server-D-2jgV-B.js.map +1 -0
- package/package.json +6 -5
- package/dist/server-rYfdOV2M.js.map +0 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { AppleAutomationError, AppleAutomationError as AppleMessagesError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, IndexUnavailableError as IndexUnavailableError$1, PreconditionError, SchemaDriftError, SchemaDriftError as SchemaDriftError$1, buildBaseDiagnostics, columnsOf, confirmArg, createOsascriptRunner, describeAggregation, describeStore, escapeLike, extractCode, fail, fingerprintSchema, groupByArg, limitArg, ok, openReadOnly, parseBool, parseBound, parseConfig, parseIntOpt, promptArg, readPackageIdentity, registerSurfaceResources, registerWorkflowPrompt, requiredPromptArg, resolveLimit, trimmed, withBusyRetry, withLazyTools, wrap, wrapResult, writeConfinedFile } from "@mgcrea/mcp-apple-core";
|
|
1
|
+
import { AppleAutomationError, AppleAutomationError as AppleMessagesError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, IndexUnavailableError as IndexUnavailableError$1, PreconditionError, SchemaDriftError, SchemaDriftError as SchemaDriftError$1, buildBaseDiagnostics, columnsOf, confirmArg, createOsascriptRunner, createWatches, describeAggregation, describeStore, escapeLike, extractCode, fail, fileIdentity, fileTrigger, fingerprintSchema, groupByArg, isoMs, limitArg, normalizeFilter, occurrenceTimestamp, ok, openReadOnly, parseBool, parseBound, parseConfig, parseIntOpt, parseSenderArgs, promptArg, readPackageIdentity, registerEvents, registerSurfaceResources, registerWorkflowPrompt, requiredPromptArg, resolveLimit, sameFile, trimmed, withBusyRetry, withLazyTools, wrap, wrapResult, writeConfinedFile } from "@mgcrea/mcp-apple-core";
|
|
2
2
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { basename, isAbsolute, join, resolve, sep } from "node:path";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
5
5
|
import { AppleContactsClient, emailKey, handleKind, loadConfig, suffixKey } from "@mgcrea/mcp-apple-contacts";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import { McpServer } from "@modelcontextprotocol/
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
8
8
|
//#region src/build-info.ts
|
|
9
9
|
const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
|
|
10
10
|
name: "@mgcrea/mcp-apple-messages",
|
|
@@ -13,8 +13,8 @@ const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
|
|
|
13
13
|
const BUILD_INFO = {
|
|
14
14
|
name: pkg.name,
|
|
15
15
|
version: pkg.version,
|
|
16
|
-
gitCommit: "
|
|
17
|
-
gitCommitDate: "2026-10-
|
|
16
|
+
gitCommit: "735f35d",
|
|
17
|
+
gitCommitDate: "2026-10-29T10:51:42+01:00"
|
|
18
18
|
};
|
|
19
19
|
//#endregion
|
|
20
20
|
//#region src/client/dates.ts
|
|
@@ -491,13 +491,12 @@ const locateStore = (opts = {}) => {
|
|
|
491
491
|
*
|
|
492
492
|
* ## Why a GUID rather than a rowid
|
|
493
493
|
*
|
|
494
|
-
* Both exist, and the rowid is faster to look up. The GUID wins anyway
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
* against: plausible, wrong, and silent.
|
|
494
|
+
* Both exist, and the rowid is faster to look up. The GUID wins anyway, and not
|
|
495
|
+
* because rowids are reused: `message` is `AUTOINCREMENT`, so a deleted row's id
|
|
496
|
+
* is never handed to a later insert (the watch cursor relies on exactly that,
|
|
497
|
+
* and `watch.test.ts` pins it). A rowid is a position in this one database file.
|
|
498
|
+
* It means nothing on another device or after a sync rebuilds the store, whereas
|
|
499
|
+
* the GUID is the message's own identity and travels with it.
|
|
501
500
|
*
|
|
502
501
|
* The GUID is also what Apple itself joins on — `associated_message_guid` for
|
|
503
502
|
* reactions, `thread_originator_guid` for replies — so it is the identifier the
|
|
@@ -974,12 +973,17 @@ var MessagesStore = class {
|
|
|
974
973
|
source: "none"
|
|
975
974
|
};
|
|
976
975
|
}
|
|
977
|
-
|
|
976
|
+
/**
|
|
977
|
+
* `bodies: false` projects NULL for both text columns, for a read that never
|
|
978
|
+
* shows a body: `#toRow` would otherwise decode `attributedBody` on every row
|
|
979
|
+
* only for the caller to drop it.
|
|
980
|
+
*/
|
|
981
|
+
#messageColumns({ bodies = true } = {}) {
|
|
978
982
|
const m = this.caps.messageColumns;
|
|
979
983
|
return [
|
|
980
984
|
`m."guid" AS guid`,
|
|
981
|
-
`m."text" AS text`,
|
|
982
|
-
m.has("attributedBody") ? `m."attributedBody" AS attributedBody` : `NULL AS attributedBody`,
|
|
985
|
+
bodies ? `m."text" AS text` : `NULL AS text`,
|
|
986
|
+
bodies && m.has("attributedBody") ? `m."attributedBody" AS attributedBody` : `NULL AS attributedBody`,
|
|
983
987
|
this.#col(m, "m", "subject"),
|
|
984
988
|
this.#col(m, "m", "service"),
|
|
985
989
|
this.#col(m, "m", "is_from_me", "isFromMe"),
|
|
@@ -1068,6 +1072,40 @@ var MessagesStore = class {
|
|
|
1068
1072
|
LIMIT ${Math.max(1, Math.trunc(q.limit))}`;
|
|
1069
1073
|
return this.db.prepare(sql).all(...params).map((r) => this.#toRow(r));
|
|
1070
1074
|
}
|
|
1075
|
+
/** Highest message ROWID now, or 0 for an empty store. The watch cursor's head. */
|
|
1076
|
+
headRowid() {
|
|
1077
|
+
return this.db.prepare(`SELECT MAX("ROWID") AS head FROM "message"`).get().head ?? 0;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Messages with ROWID above `after`, ascending, at most `limit`.
|
|
1081
|
+
*
|
|
1082
|
+
* Nothing is filtered out, reactions and my own messages included: a watch
|
|
1083
|
+
* cursor advances over every ROWID and the caller decides what is excluded.
|
|
1084
|
+
* `message` is AUTOINCREMENT, so a ROWID is never handed out twice and the
|
|
1085
|
+
* cursor can only move forward.
|
|
1086
|
+
*
|
|
1087
|
+
* Exactly one row per ROWID. `#joins` yields a row per chat for a message in
|
|
1088
|
+
* several, which would give one cursor two rows and spend two slots of a read's
|
|
1089
|
+
* limit on one message, so the chat here is picked: the lowest `chat_id`.
|
|
1090
|
+
*
|
|
1091
|
+
* No text: a watch carries metadata only, so `text` is always null here and
|
|
1092
|
+
* the scan never pays for decoding a body.
|
|
1093
|
+
*/
|
|
1094
|
+
arrivalsAfter(after, limit) {
|
|
1095
|
+
const sql = `
|
|
1096
|
+
SELECT m."ROWID" AS rowid, ${this.#messageColumns({ bodies: false })}
|
|
1097
|
+
FROM "message" m
|
|
1098
|
+
LEFT JOIN "handle" h ON h."ROWID" = m."handle_id"
|
|
1099
|
+
LEFT JOIN "chat" c ON c."ROWID" =
|
|
1100
|
+
(SELECT MIN(cmj."chat_id") FROM "chat_message_join" cmj WHERE cmj."message_id" = m."ROWID")
|
|
1101
|
+
WHERE m."ROWID" > ?
|
|
1102
|
+
ORDER BY m."ROWID" ASC
|
|
1103
|
+
LIMIT ?`;
|
|
1104
|
+
return this.db.prepare(sql).all(after, Math.max(1, Math.trunc(limit))).map((r) => ({
|
|
1105
|
+
...this.#toRow(r),
|
|
1106
|
+
rowid: Number(r.rowid)
|
|
1107
|
+
}));
|
|
1108
|
+
}
|
|
1071
1109
|
/**
|
|
1072
1110
|
* Text search, in two passes — and the second one is the point.
|
|
1073
1111
|
*
|
|
@@ -1864,14 +1902,14 @@ var AppleMessagesClient = class {
|
|
|
1864
1902
|
* answered zero while `send_message` to that same string worked. One
|
|
1865
1903
|
* surface, two ideas of what a handle is.
|
|
1866
1904
|
*/
|
|
1867
|
-
#handlesLike(handle) {
|
|
1905
|
+
#handlesLike(handle, pool) {
|
|
1868
1906
|
const store = this.store();
|
|
1869
1907
|
if (!store) return [];
|
|
1870
1908
|
const wanted = handle.trim();
|
|
1871
1909
|
const kind = handleKind(wanted);
|
|
1872
1910
|
const wantedSuffix = kind === "phone" ? suffixKey(wanted) : null;
|
|
1873
1911
|
const wantedEmail = kind === "email" ? emailKey(wanted) : null;
|
|
1874
|
-
return store.handles().filter((h) => {
|
|
1912
|
+
return (pool ?? store.handles()).filter((h) => {
|
|
1875
1913
|
if (h === wanted) return true;
|
|
1876
1914
|
if (wantedEmail) return emailKey(h) === wantedEmail;
|
|
1877
1915
|
if (wantedSuffix) return suffixKey(h) === wantedSuffix;
|
|
@@ -1879,6 +1917,25 @@ var AppleMessagesClient = class {
|
|
|
1879
1917
|
});
|
|
1880
1918
|
}
|
|
1881
1919
|
/**
|
|
1920
|
+
* The stored spellings that name the same correspondent as `typed`. Candidates,
|
|
1921
|
+
* not answers: a locally-spelled number reaches `+33…` here, so a caller that
|
|
1922
|
+
* needs an exact spelling still has to compare. `[]` when the store is closed.
|
|
1923
|
+
* `pool` is the store's handles when the caller already read them.
|
|
1924
|
+
*/
|
|
1925
|
+
storeHandlesFor(typed, pool) {
|
|
1926
|
+
return this.#handlesLike(typed, pool);
|
|
1927
|
+
}
|
|
1928
|
+
/**
|
|
1929
|
+
* The open store and the file it came from, for a watch. Throws the same
|
|
1930
|
+
* reason every other read throws when the store is off or unreadable.
|
|
1931
|
+
*/
|
|
1932
|
+
watchStore() {
|
|
1933
|
+
return {
|
|
1934
|
+
store: this.#require(),
|
|
1935
|
+
path: this.located().storePath
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1882
1939
|
* Find the row Messages wrote, or say plainly that it was not found.
|
|
1883
1940
|
*
|
|
1884
1941
|
* `docs/messages.md` left this as an open question — "whether a send should
|
|
@@ -2131,7 +2188,27 @@ const ConfigSchema = BaseConfigSchema.extend({
|
|
|
2131
2188
|
* reassuring one. `attachmentDir` is a real boundary because it constrains
|
|
2132
2189
|
* where this server WRITES, which nothing else on this surface can do.
|
|
2133
2190
|
*/
|
|
2134
|
-
allowFileSend: z.boolean().default(false)
|
|
2191
|
+
allowFileSend: z.boolean().default(false),
|
|
2192
|
+
/**
|
|
2193
|
+
* Lets a client register a watch on handles or email domains and be told when
|
|
2194
|
+
* a matching message arrives. Off by default, and independent of `allowWrites`
|
|
2195
|
+
* and `exposePrompts`.
|
|
2196
|
+
*
|
|
2197
|
+
* Not a write gate: a watch only reads chat.db. It is a gate because it is the
|
|
2198
|
+
* one thing here that runs unasked, a file watcher in a process that otherwise
|
|
2199
|
+
* has none, and because the handle filter it takes decides whose messages may
|
|
2200
|
+
* reach a session without the model asking for it. Off, neither watch tool is
|
|
2201
|
+
* registered and the server does not declare `resources.subscribe`.
|
|
2202
|
+
*/
|
|
2203
|
+
allowWatches: z.boolean().default(false),
|
|
2204
|
+
/**
|
|
2205
|
+
* Serves the draft MCP Events extension (`messages.received`) to clients on the 2026
|
|
2206
|
+
* protocol. Off by default, and independent of `allowWatches`, `allowWrites` and
|
|
2207
|
+
* `exposePrompts`. Like a watch it only reads chat.db, and is gated for the same
|
|
2208
|
+
* reason: it runs unasked, and its handle filter decides whose messages may reach a
|
|
2209
|
+
* session. Off, no `events/*` method exists and nothing is declared.
|
|
2210
|
+
*/
|
|
2211
|
+
allowEvents: z.boolean().default(false)
|
|
2135
2212
|
}).strict();
|
|
2136
2213
|
const loadConfig$1 = (env = process.env) => parseConfig(ConfigSchema, {
|
|
2137
2214
|
allowWrites: parseBool(env.APPLE_MESSAGES_ALLOW_WRITES),
|
|
@@ -2145,6 +2222,8 @@ const loadConfig$1 = (env = process.env) => parseConfig(ConfigSchema, {
|
|
|
2145
2222
|
sendReconcileMs: parseIntOpt(env.APPLE_MESSAGES_SEND_RECONCILE_MS),
|
|
2146
2223
|
allowCodes: parseBool(env.APPLE_MESSAGES_ALLOW_CODES),
|
|
2147
2224
|
allowFileSend: parseBool(env.APPLE_MESSAGES_ALLOW_FILE_SEND),
|
|
2225
|
+
allowWatches: parseBool(env.APPLE_MESSAGES_ALLOW_WATCHES),
|
|
2226
|
+
allowEvents: parseBool(env.APPLE_MESSAGES_ALLOW_EVENTS),
|
|
2148
2227
|
osascriptPath: trimmed(env.APPLE_MESSAGES_OSASCRIPT_PATH),
|
|
2149
2228
|
osascriptTimeoutMs: parseIntOpt(env.APPLE_MESSAGES_OSASCRIPT_TIMEOUT_MS),
|
|
2150
2229
|
maxResults: parseIntOpt(env.APPLE_MESSAGES_MAX_RESULTS)
|
|
@@ -2212,6 +2291,45 @@ there, that is this, not silence.
|
|
|
2212
2291
|
conversations and reported on the message they target. A "liked" is not a reply
|
|
2213
2292
|
and should not be summarised as one.
|
|
2214
2293
|
|
|
2294
|
+
## Watches
|
|
2295
|
+
|
|
2296
|
+
\`apple_messages_watch\` and \`apple_messages_unwatch\` exist only when the watch
|
|
2297
|
+
gate (\`APPLE_MESSAGES_ALLOW_WATCHES\`) is on. If you cannot see them, watches
|
|
2298
|
+
are off.
|
|
2299
|
+
|
|
2300
|
+
- **A filter is required.** Give \`from\` or \`domains\` (email handles only; a
|
|
2301
|
+
phone number never matches a domain). There is no watch on all messages.
|
|
2302
|
+
- **Spell phone numbers in international form**: \`+33612345678\`, with no trunk
|
|
2303
|
+
\`0\` and no \`(0)\`. Spaces and punctuation are ignored, but a local spelling
|
|
2304
|
+
such as \`06 12 34 56 78\` never matches. An entry the store does not hold is
|
|
2305
|
+
kept without spaces or punctuation, lowercased, and listed in \`unresolved\`.
|
|
2306
|
+
That is NOT proof the person has not written: it fires only if the store later
|
|
2307
|
+
holds exactly that spelling. When the store holds near misses they come back
|
|
2308
|
+
as \`suggestions\` (\`typed\`, \`candidates\`); retry with the right spelling.
|
|
2309
|
+
- **Check the watch yourself.** \`apple_messages_watch\` returns a handle and a
|
|
2310
|
+
\`uri\`. Only the host can subscribe to that uri, there is no tool for it, and
|
|
2311
|
+
many hosts never do: Claude Code does not, in either protocol (checked on
|
|
2312
|
+
2.1.311). So do not promise the user a push. The watch still works: read the
|
|
2313
|
+
uri whenever you want to know what came in.
|
|
2314
|
+
- **\`after\` is yours to keep.** A read returns what arrived since the watch
|
|
2315
|
+
began, or since the \`after\` you pass, oldest first. The \`cursor\` it returns
|
|
2316
|
+
is the last row scanned; pass it back as \`after\` next time. The server does
|
|
2317
|
+
not remember what you have seen. When \`more\` is true, read again from
|
|
2318
|
+
\`cursor\`.
|
|
2319
|
+
- **Reads carry no text.** Each message is \`id\`, \`handle\`, \`chat\` and \`date\`.
|
|
2320
|
+
Use \`apple_messages_get_message\` with that \`id\` for the text.
|
|
2321
|
+
- **Your own messages, tapbacks and group events never fire a watch.** A group
|
|
2322
|
+
event is a rename, or someone joining or leaving.
|
|
2323
|
+
- **Limits.** At most 10 watches per connection and 50 entries across \`from\`
|
|
2324
|
+
and \`domains\` in one watch. A read returns at most 50 matches and scans at
|
|
2325
|
+
most 1,000 rows; \`more\` says when either stopped it.
|
|
2326
|
+
- **Messages need not be running.** The store is written by the system, not by
|
|
2327
|
+
the app, so a watch works with Messages closed.
|
|
2328
|
+
- Watches end with the connection: after a reconnect, call
|
|
2329
|
+
\`apple_messages_watch\` again. Reading a watch that does not exist is a
|
|
2330
|
+
not-found error, which is also what a reconnect looks like. A uri with a query
|
|
2331
|
+
other than \`after\` is refused as a bad query.
|
|
2332
|
+
|
|
2215
2333
|
## Sending
|
|
2216
2334
|
|
|
2217
2335
|
\`apple_messages_send_message\` exists only when writes are enabled, and it is the
|
|
@@ -2366,11 +2484,94 @@ const buildDiagnostics = async (client) => {
|
|
|
2366
2484
|
const registerDiagnosticsTools = (server, client) => {
|
|
2367
2485
|
server.registerTool("apple_messages_diagnostics", {
|
|
2368
2486
|
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.",
|
|
2369
|
-
inputSchema: {},
|
|
2487
|
+
inputSchema: z.object({}),
|
|
2370
2488
|
annotations: { readOnlyHint: true }
|
|
2371
2489
|
}, async () => wrap(() => buildDiagnostics(client)));
|
|
2372
2490
|
};
|
|
2373
2491
|
//#endregion
|
|
2492
|
+
//#region src/tools/events.ts
|
|
2493
|
+
/**
|
|
2494
|
+
* Messages' `messages.received` event type for the draft MCP Events extension.
|
|
2495
|
+
*
|
|
2496
|
+
* It is an adapter over a `MessagesWatchService`, the same store handle, handle
|
|
2497
|
+
* resolution and trigger a watch uses. The service handed over must be its own,
|
|
2498
|
+
* never one bound to a watch registry: events and watches then keep separate
|
|
2499
|
+
* handles on chat.db and both are closed with the connection.
|
|
2500
|
+
*/
|
|
2501
|
+
const Args = z.object({
|
|
2502
|
+
from: z.array(z.string().min(1)).optional(),
|
|
2503
|
+
domains: z.array(z.string().min(1)).optional()
|
|
2504
|
+
});
|
|
2505
|
+
const messagesEventAdapter = (service) => ({
|
|
2506
|
+
descriptor: {
|
|
2507
|
+
name: "messages.received",
|
|
2508
|
+
description: "New messages from chosen handles. Give `from` (email addresses, or phone numbers in international form such as +33612345678) and/or `domains` (email handles only; matches the domain and its subdomains); a sender is required. Metadata only, so fetch the text with apple_messages_get_message and the `id`. Your own messages and tapbacks never match. Needs Full Disk Access; Messages does not have to be running.",
|
|
2509
|
+
delivery: ["poll", "push"],
|
|
2510
|
+
inputSchema: {
|
|
2511
|
+
type: "object",
|
|
2512
|
+
properties: {
|
|
2513
|
+
from: {
|
|
2514
|
+
type: "array",
|
|
2515
|
+
items: {
|
|
2516
|
+
type: "string",
|
|
2517
|
+
minLength: 1
|
|
2518
|
+
},
|
|
2519
|
+
description: "Email addresses, or phone numbers in international form (+33612345678: no trunk 0, no (0)); spaces and punctuation are ignored, but a local spelling like 06 12 34 56 78 never matches. An entry the store does not hold is kept without spaces or punctuation, lowercased: it fires only if the store later holds exactly that spelling."
|
|
2520
|
+
},
|
|
2521
|
+
domains: {
|
|
2522
|
+
type: "array",
|
|
2523
|
+
items: {
|
|
2524
|
+
type: "string",
|
|
2525
|
+
minLength: 1
|
|
2526
|
+
},
|
|
2527
|
+
description: "Email domains, e.g. acme.com. Matches only email handles, never a phone number."
|
|
2528
|
+
}
|
|
2529
|
+
},
|
|
2530
|
+
anyOf: [{ required: ["from"] }, { required: ["domains"] }]
|
|
2531
|
+
},
|
|
2532
|
+
payloadSchema: {
|
|
2533
|
+
type: "object",
|
|
2534
|
+
properties: {
|
|
2535
|
+
id: { type: "string" },
|
|
2536
|
+
chat: { type: ["string", "null"] },
|
|
2537
|
+
handle: { type: ["string", "null"] },
|
|
2538
|
+
date: { type: ["string", "null"] }
|
|
2539
|
+
},
|
|
2540
|
+
required: [
|
|
2541
|
+
"id",
|
|
2542
|
+
"chat",
|
|
2543
|
+
"handle",
|
|
2544
|
+
"date"
|
|
2545
|
+
]
|
|
2546
|
+
}
|
|
2547
|
+
},
|
|
2548
|
+
parseArguments: (args) => parseSenderArgs("messages.received", Args, args, {
|
|
2549
|
+
open: () => service.ensure(),
|
|
2550
|
+
resolve: async ({ from, domains }) => {
|
|
2551
|
+
const resolved = service.resolveFrom(from ?? []).from;
|
|
2552
|
+
return {
|
|
2553
|
+
...resolved.length > 0 ? { from: resolved } : {},
|
|
2554
|
+
...domains ? { domains } : {}
|
|
2555
|
+
};
|
|
2556
|
+
}
|
|
2557
|
+
}),
|
|
2558
|
+
occurrence: (row) => ({
|
|
2559
|
+
eventId: row.key,
|
|
2560
|
+
timestamp: occurrenceTimestamp(row.date),
|
|
2561
|
+
data: {
|
|
2562
|
+
id: row.id,
|
|
2563
|
+
chat: row.chat,
|
|
2564
|
+
handle: row.handle,
|
|
2565
|
+
date: row.date
|
|
2566
|
+
}
|
|
2567
|
+
}),
|
|
2568
|
+
timestampMs: (row) => isoMs(row.date),
|
|
2569
|
+
source: service.source,
|
|
2570
|
+
ensure: () => service.refresh(),
|
|
2571
|
+
identity: () => service.identity(),
|
|
2572
|
+
startTrigger: (onChange) => service.startTrigger(onChange)
|
|
2573
|
+
});
|
|
2574
|
+
//#endregion
|
|
2374
2575
|
//#region src/tools/actions.ts
|
|
2375
2576
|
/**
|
|
2376
2577
|
* The payload half of the description, which changes with `allowFileSend` — the
|
|
@@ -2381,7 +2582,7 @@ const PAYLOAD_HELP = (allowFileSend) => "Send exactly ONE thing per call: text,
|
|
|
2381
2582
|
const registerActionTools = (server, client, ctx) => {
|
|
2382
2583
|
server.registerTool("apple_messages_send_message", {
|
|
2383
2584
|
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. Sending a file is the same act and less recoverable: once it leaves this Mac the recipient has a copy, so confirm WHICH file with the user, by name, before calling this with an attachment. 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. " + PAYLOAD_HELP(ctx.allowFileSend),
|
|
2384
|
-
inputSchema: {
|
|
2585
|
+
inputSchema: z.object({
|
|
2385
2586
|
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."),
|
|
2386
2587
|
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."),
|
|
2387
2588
|
text: z.string().min(1).optional().describe("Exactly what to send, verbatim. Give this OR an attachment, never both in one call."),
|
|
@@ -2393,7 +2594,7 @@ const registerActionTools = (server, client, ctx) => {
|
|
|
2393
2594
|
"rcs"
|
|
2394
2595
|
]).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."),
|
|
2395
2596
|
confirm: confirmArg
|
|
2396
|
-
},
|
|
2597
|
+
}),
|
|
2397
2598
|
annotations: {
|
|
2398
2599
|
readOnlyHint: false,
|
|
2399
2600
|
destructiveHint: true,
|
|
@@ -2442,11 +2643,11 @@ const registerActionTools = (server, client, ctx) => {
|
|
|
2442
2643
|
const registerAttachmentTools = (server, client) => {
|
|
2443
2644
|
server.registerTool("apple_messages_save_attachment", {
|
|
2444
2645
|
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.",
|
|
2445
|
-
inputSchema: {
|
|
2646
|
+
inputSchema: z.object({
|
|
2446
2647
|
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."),
|
|
2447
2648
|
directory: z.string().optional().describe("Override the target directory. Must still resolve inside the configured one."),
|
|
2448
2649
|
overwrite: z.boolean().optional().describe("Replace an existing file. Default false.")
|
|
2449
|
-
},
|
|
2650
|
+
}),
|
|
2450
2651
|
annotations: {
|
|
2451
2652
|
readOnlyHint: false,
|
|
2452
2653
|
destructiveHint: false,
|
|
@@ -2468,7 +2669,7 @@ const registerAttachmentTools = (server, client) => {
|
|
|
2468
2669
|
const registerChatTools = (server, client) => {
|
|
2469
2670
|
server.registerTool("apple_messages_list_chats", {
|
|
2470
2671
|
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.",
|
|
2471
|
-
inputSchema: { limit: limitArg },
|
|
2672
|
+
inputSchema: z.object({ limit: limitArg }),
|
|
2472
2673
|
annotations: { readOnlyHint: true }
|
|
2473
2674
|
}, async ({ limit }) => wrap(async () => client.listChats(limit)));
|
|
2474
2675
|
};
|
|
@@ -2503,11 +2704,11 @@ CHECK \`ageSeconds\` BEFORE USING A CODE. An expired code is still in the store
|
|
|
2503
2704
|
A \`matched\` value ending in \`-ambiguous\` means the message held more than one plausible code and this tool did not guess — confirm which one is wanted.
|
|
2504
2705
|
|
|
2505
2706
|
Returning nothing is the normal result when no code has arrived. It does NOT mean the code was missed, and it is not a reason to re-run with a longer window: if a code was sent it is in the store within seconds. Prefer asking the user to trigger a new one.`,
|
|
2506
|
-
inputSchema: {
|
|
2707
|
+
inputSchema: z.object({
|
|
2507
2708
|
service: z.string().optional().describe("Narrow to one issuer by matching the sender or the message text — \"Google\", \"bank\", \"Github\". Case-insensitive substring. Omit to see every recent code."),
|
|
2508
2709
|
withinMinutes: z.number().int().min(1).max(MAX_WINDOW_MINUTES).optional().describe(`How far back to look, in minutes. Default ${DEFAULT_WINDOW_MINUTES}, max ${MAX_WINDOW_MINUTES}. Keep it short: a code older than a few minutes has usually expired, and a wide window just returns history.`),
|
|
2509
2710
|
limit: limitArg
|
|
2510
|
-
},
|
|
2711
|
+
}),
|
|
2511
2712
|
annotations: {
|
|
2512
2713
|
readOnlyHint: true,
|
|
2513
2714
|
idempotentHint: false
|
|
@@ -2571,7 +2772,7 @@ Returning nothing is the normal result when no code has arrived. It does NOT mea
|
|
|
2571
2772
|
const registerCountTools = (server, client) => {
|
|
2572
2773
|
server.registerTool("apple_messages_count_messages", {
|
|
2573
2774
|
description: "Count messages without reading them. Answers 'how many', 'how many did I send', 'who do I message most' and 'which month was busiest' as arithmetic the store does, rather than a page of conversations you tally by hand. Every result splits `sent` and `received`.\n\n`groupBy` counts per day, month, chat, handle or direction, over EVERY match rather than the first `limit` of them — `limit` caps how many groups come back. Grouping by handle is the one to reach for when a person has several conversations, since apple_messages_list_chats already gives you per-CHAT totals.\n\nCounts metadata only: there is no text filter here, because message bodies from 2026 onward live in an archived blob that SQL cannot see, and a count that quietly skipped them would look exactly like a correct one. Use apple_messages_search_messages for anything about what was said. Tapbacks are excluded unless you ask for them.",
|
|
2574
|
-
inputSchema: {
|
|
2775
|
+
inputSchema: z.object({
|
|
2575
2776
|
chatRef: chatRefArg,
|
|
2576
2777
|
handle: z.string().optional().describe("Only messages with this correspondent — a phone number or email. Spellings of the same number match each other, so \"06 12 34 56 78\" and \"+33612345678\" are the same person, exactly as they are for send_message. A fragment that resolves to nobody falls back to a substring match. Spans every chat they appear in, which is the difference from chatRef."),
|
|
2577
2778
|
direction: z.enum(["sent", "received"]).optional().describe("Only what you sent, or only what you received. Omit for both."),
|
|
@@ -2580,7 +2781,7 @@ const registerCountTools = (server, client) => {
|
|
|
2580
2781
|
includeReactions: includeReactionsArg,
|
|
2581
2782
|
groupBy: groupByArg(COUNT_GROUP_FIELDS, "Day and month buckets are LOCAL calendar dates."),
|
|
2582
2783
|
limit: limitArg
|
|
2583
|
-
},
|
|
2784
|
+
}),
|
|
2584
2785
|
annotations: { readOnlyHint: true }
|
|
2585
2786
|
}, async ({ chatRef, handle, direction, from, to, includeReactions, groupBy, limit }) => wrap(async () => {
|
|
2586
2787
|
const window = client.window(bound(from, "from", "start"), bound(to, "to", "end"));
|
|
@@ -2621,13 +2822,13 @@ const closeRange = (from, to, days) => {
|
|
|
2621
2822
|
const registerMessageTools = (server, client) => {
|
|
2622
2823
|
server.registerTool("apple_messages_list_messages", {
|
|
2623
2824
|
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.",
|
|
2624
|
-
inputSchema: {
|
|
2825
|
+
inputSchema: z.object({
|
|
2625
2826
|
chatRef: chatRefArg,
|
|
2626
2827
|
from: fromArg,
|
|
2627
2828
|
to: toArg,
|
|
2628
2829
|
includeReactions: includeReactionsArg,
|
|
2629
2830
|
limit: limitArg
|
|
2630
|
-
},
|
|
2831
|
+
}),
|
|
2631
2832
|
annotations: { readOnlyHint: true }
|
|
2632
2833
|
}, async ({ chatRef, from, to, includeReactions, limit }) => wrap(async () => {
|
|
2633
2834
|
const fromDate = bound(from, "from", "start");
|
|
@@ -2641,15 +2842,15 @@ const registerMessageTools = (server, client) => {
|
|
|
2641
2842
|
}));
|
|
2642
2843
|
server.registerTool("apple_messages_search_messages", {
|
|
2643
2844
|
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.",
|
|
2644
|
-
inputSchema: {
|
|
2845
|
+
inputSchema: z.object({
|
|
2645
2846
|
query: z.string().min(1).describe("Text to look for."),
|
|
2646
2847
|
limit: limitArg
|
|
2647
|
-
},
|
|
2848
|
+
}),
|
|
2648
2849
|
annotations: { readOnlyHint: true }
|
|
2649
2850
|
}, async ({ query, limit }) => wrap(async () => client.searchMessages(query, limit)));
|
|
2650
2851
|
server.registerTool("apple_messages_get_message", {
|
|
2651
2852
|
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.",
|
|
2652
|
-
inputSchema: { ref: messageRefArg },
|
|
2853
|
+
inputSchema: z.object({ ref: messageRefArg }),
|
|
2653
2854
|
annotations: { readOnlyHint: true }
|
|
2654
2855
|
}, async ({ ref }) => wrapResult(async () => {
|
|
2655
2856
|
const message = client.getMessage(decodeMessageRef(ref));
|
|
@@ -2658,6 +2859,284 @@ const registerMessageTools = (server, client) => {
|
|
|
2658
2859
|
}));
|
|
2659
2860
|
};
|
|
2660
2861
|
//#endregion
|
|
2862
|
+
//#region src/client/watch.ts
|
|
2863
|
+
var MessagesArrivalSource = class {
|
|
2864
|
+
#store;
|
|
2865
|
+
constructor(opts) {
|
|
2866
|
+
this.#store = opts.store;
|
|
2867
|
+
}
|
|
2868
|
+
head() {
|
|
2869
|
+
return this.#store.headRowid();
|
|
2870
|
+
}
|
|
2871
|
+
since(after, limit) {
|
|
2872
|
+
return this.#store.arrivalsAfter(after, limit).map((r) => {
|
|
2873
|
+
const excluded = r.isFromMe || r.reactionType !== null || r.itemType !== null && r.itemType !== 0;
|
|
2874
|
+
return {
|
|
2875
|
+
cursor: r.rowid,
|
|
2876
|
+
key: r.guid,
|
|
2877
|
+
sender: r.handle?.trim().toLowerCase() || null,
|
|
2878
|
+
...excluded ? { excluded: true } : {},
|
|
2879
|
+
id: encodeMessageRef(r.guid),
|
|
2880
|
+
chat: r.chatGuid ? encodeChatRef(r.chatGuid) : null,
|
|
2881
|
+
handle: r.handle,
|
|
2882
|
+
date: renderInstant(r.sentAt)
|
|
2883
|
+
};
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
};
|
|
2887
|
+
//#endregion
|
|
2888
|
+
//#region src/tools/watch.ts
|
|
2889
|
+
/**
|
|
2890
|
+
* Messages' side of incoming watches: the lazily opened source, the trigger, the
|
|
2891
|
+
* handle resolution and the two tools.
|
|
2892
|
+
*
|
|
2893
|
+
* ## Why the source is lazy
|
|
2894
|
+
*
|
|
2895
|
+
* `createWatches` needs an `ArrivalSource` when the server is built, but a server
|
|
2896
|
+
* is built far more often than it is used (`serveStdio` makes one per
|
|
2897
|
+
* `server/discover` probe and throws it away). So the source handed over is an
|
|
2898
|
+
* adapter that delegates to a `MessagesArrivalSource` built by `ensure()`, which
|
|
2899
|
+
* runs on the first `watch` call. A server that never sees one opens nothing.
|
|
2900
|
+
*
|
|
2901
|
+
* ## Messages need not be running
|
|
2902
|
+
*
|
|
2903
|
+
* `imagent` writes chat.db, not Messages.app, so there is no running check and no
|
|
2904
|
+
* lookup to refresh: the trigger just ticks the registry.
|
|
2905
|
+
*
|
|
2906
|
+
* ## The watch keeps its own handle, and reopens it when the file is replaced
|
|
2907
|
+
*
|
|
2908
|
+
* A connection opened on chat.db reads that inode for as long as it lives. A
|
|
2909
|
+
* store rebuilt as a new file and renamed over the old name leaves it reading
|
|
2910
|
+
* the deleted one: nothing new ever arrives there, and nothing errors. So the
|
|
2911
|
+
* service records the file's inode when it opens, compares on every trigger, and
|
|
2912
|
+
* reopens before the tick when it changed. The registry then sees the renumbered
|
|
2913
|
+
* head below its mark and resets without notifying, which is what a rebuild is.
|
|
2914
|
+
*
|
|
2915
|
+
* The handle is the service's own rather than the client's: the client closes
|
|
2916
|
+
* its store to reopen it after a send (`#reconcile`), which would close a shared
|
|
2917
|
+
* handle under a running watch, and reopening the client's would change what
|
|
2918
|
+
* every other tool reads in the middle of its call.
|
|
2919
|
+
*
|
|
2920
|
+
* ## Handles
|
|
2921
|
+
*
|
|
2922
|
+
* A watch's `from` holds exact store spellings, because the source stamps each row
|
|
2923
|
+
* with `handle.id` lowercased and core compares for equality. What the caller types
|
|
2924
|
+
* is resolved against the store's handles first (see `resolveFrom`).
|
|
2925
|
+
*/
|
|
2926
|
+
const walReason = (noun) => `The Messages store was opened in immutable mode, which cannot see its WAL, so the newest messages are exactly what a ${noun} would miss. APPLE_MESSAGES_INDEX_MODE=immutable asks for that: set it to auto or ro, or unset it, then try again.`;
|
|
2927
|
+
/** How a handle compares: emails case-folded, phone numbers without their punctuation. */
|
|
2928
|
+
const handleKey = (value) => {
|
|
2929
|
+
const v = value.trim().toLowerCase();
|
|
2930
|
+
return v.includes("@") ? v : v.replaceAll(/[\s.\-()]/g, "");
|
|
2931
|
+
};
|
|
2932
|
+
var MessagesWatchService = class {
|
|
2933
|
+
#client;
|
|
2934
|
+
#logger;
|
|
2935
|
+
#noun;
|
|
2936
|
+
#registry;
|
|
2937
|
+
#inner;
|
|
2938
|
+
#dir;
|
|
2939
|
+
/** The service's own handle on chat.db, never the client's. */
|
|
2940
|
+
#store;
|
|
2941
|
+
#path;
|
|
2942
|
+
#identity = null;
|
|
2943
|
+
#reopenFailing = false;
|
|
2944
|
+
#init;
|
|
2945
|
+
/** What `createWatches` is given. Delegates to the source `ensure()` builds. */
|
|
2946
|
+
source = {
|
|
2947
|
+
head: () => this.#ready().head(),
|
|
2948
|
+
since: (after, limit) => this.#ready().since(after, limit)
|
|
2949
|
+
};
|
|
2950
|
+
constructor(opts) {
|
|
2951
|
+
this.#client = opts.client;
|
|
2952
|
+
this.#logger = opts.logger;
|
|
2953
|
+
this.#noun = opts.mode === "events" ? "subscription" : "watch";
|
|
2954
|
+
}
|
|
2955
|
+
/** How many watches this connection holds. */
|
|
2956
|
+
get count() {
|
|
2957
|
+
return this.#registry?.size ?? 0;
|
|
2958
|
+
}
|
|
2959
|
+
/** Called once, by `createServer`, after `createWatches` returns. */
|
|
2960
|
+
bind(registry) {
|
|
2961
|
+
this.#registry = registry;
|
|
2962
|
+
}
|
|
2963
|
+
#ready() {
|
|
2964
|
+
if (!this.#inner) throw new Error("The messages watch source was used before it was set up.");
|
|
2965
|
+
return this.#inner;
|
|
2966
|
+
}
|
|
2967
|
+
/**
|
|
2968
|
+
* Open the store and build the source, once. Concurrency-safe: two simultaneous
|
|
2969
|
+
* `watch` calls share the promise. A failure is not kept, so a later call runs
|
|
2970
|
+
* setup again.
|
|
2971
|
+
*/
|
|
2972
|
+
ensure() {
|
|
2973
|
+
this.#init ??= Promise.resolve().then(() => this.#setUp()).catch((err) => {
|
|
2974
|
+
this.#init = void 0;
|
|
2975
|
+
throw err;
|
|
2976
|
+
});
|
|
2977
|
+
return this.#init;
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* `ensure`, then reopen chat.db if it was replaced since. What an events poll
|
|
2981
|
+
* or tick runs before it reads: a poll has no trigger to notice a rebuild for
|
|
2982
|
+
* it, and without this would read the deleted file for as long as the
|
|
2983
|
+
* connection lives. `ensure` itself does not reopen, so watches are unchanged.
|
|
2984
|
+
*/
|
|
2985
|
+
async refresh() {
|
|
2986
|
+
await this.ensure();
|
|
2987
|
+
this.#reopenIfReplaced();
|
|
2988
|
+
}
|
|
2989
|
+
#setUp() {
|
|
2990
|
+
const { store, path } = this.#client.watchStore();
|
|
2991
|
+
if (store.mode !== "ro") throw new PreconditionError(walReason(this.#noun));
|
|
2992
|
+
this.#open(path);
|
|
2993
|
+
this.#dir = dirname(path);
|
|
2994
|
+
}
|
|
2995
|
+
/** Open (or reopen) the service's own handle, and only then let go of the old one. */
|
|
2996
|
+
#open(path) {
|
|
2997
|
+
const identity = fileIdentity(path);
|
|
2998
|
+
const store = openStore(path, "ro", this.#logger);
|
|
2999
|
+
if (!store) throw new PreconditionError("The Messages store was not located.");
|
|
3000
|
+
const old = this.#store;
|
|
3001
|
+
this.#store = store;
|
|
3002
|
+
this.#path = path;
|
|
3003
|
+
this.#identity = identity;
|
|
3004
|
+
this.#inner = new MessagesArrivalSource({ store });
|
|
3005
|
+
old?.close();
|
|
3006
|
+
}
|
|
3007
|
+
/**
|
|
3008
|
+
* Reopen when chat.db is now a different file. A path that cannot be read is
|
|
3009
|
+
* a replacement in progress, so it is left for the next trigger. A reopen that
|
|
3010
|
+
* fails keeps the old handle and is logged once until one succeeds.
|
|
3011
|
+
*/
|
|
3012
|
+
#reopenIfReplaced() {
|
|
3013
|
+
if (!this.#path) return;
|
|
3014
|
+
const now = fileIdentity(this.#path);
|
|
3015
|
+
if (now === null || this.#identity !== null && sameFile(now, this.#identity)) return;
|
|
3016
|
+
try {
|
|
3017
|
+
this.#open(this.#path);
|
|
3018
|
+
this.#reopenFailing = false;
|
|
3019
|
+
} catch (err) {
|
|
3020
|
+
if (!this.#reopenFailing) this.#logger?.warn?.("watch: chat.db was replaced and could not be reopened", err);
|
|
3021
|
+
this.#reopenFailing = true;
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
/** What `createWatches` is given as `startTrigger`. Reads the directory now, not at construction. */
|
|
3025
|
+
startTrigger = (onChange) => {
|
|
3026
|
+
if (!this.#dir) throw new Error("A watch trigger was started before the store was located.");
|
|
3027
|
+
return fileTrigger({
|
|
3028
|
+
dir: this.#dir,
|
|
3029
|
+
names: ["chat.db", "chat.db-wal"],
|
|
3030
|
+
...this.#logger ? { logger: this.#logger } : {}
|
|
3031
|
+
})(() => this.onTrigger(onChange));
|
|
3032
|
+
};
|
|
3033
|
+
/** The trigger's callback: reopen a replaced store, then tick. */
|
|
3034
|
+
onTrigger(tick) {
|
|
3035
|
+
this.#reopenIfReplaced();
|
|
3036
|
+
tick();
|
|
3037
|
+
}
|
|
3038
|
+
/** The file identity of the chat.db the service reads now, or null when it is unknown or unreadable. */
|
|
3039
|
+
identity() {
|
|
3040
|
+
return this.#identity;
|
|
3041
|
+
}
|
|
3042
|
+
/** Close the service's own handle. Called when the connection ends. */
|
|
3043
|
+
close() {
|
|
3044
|
+
this.#store?.close();
|
|
3045
|
+
this.#store = void 0;
|
|
3046
|
+
this.#inner = void 0;
|
|
3047
|
+
this.#path = void 0;
|
|
3048
|
+
this.#init = void 0;
|
|
3049
|
+
}
|
|
3050
|
+
remove(id) {
|
|
3051
|
+
return this.#registry?.remove(id) ?? false;
|
|
3052
|
+
}
|
|
3053
|
+
/**
|
|
3054
|
+
* Turn what the caller typed into exact store spellings.
|
|
3055
|
+
*
|
|
3056
|
+
* `storeHandlesFor` is a candidate list (a local "06 12 ..." reaches "+33 6 12 ..."),
|
|
3057
|
+
* so a typed value counts as resolved only when a candidate equals it after
|
|
3058
|
+
* normalisation. A short "+336" therefore resolves nothing, rather than every
|
|
3059
|
+
* French number. What does not resolve is kept, normalised and lowercased, and
|
|
3060
|
+
* reported (with `suggestions` when the store holds near misses).
|
|
3061
|
+
*/
|
|
3062
|
+
resolveFrom(typed) {
|
|
3063
|
+
const from = [];
|
|
3064
|
+
const unresolved = [];
|
|
3065
|
+
const suggestions = [];
|
|
3066
|
+
const pool = this.#store?.handles();
|
|
3067
|
+
for (const entry of typed) {
|
|
3068
|
+
const key = handleKey(entry);
|
|
3069
|
+
if (key === "") continue;
|
|
3070
|
+
const candidates = this.#client.storeHandlesFor(entry, pool);
|
|
3071
|
+
const exact = candidates.filter((h) => handleKey(h) === key);
|
|
3072
|
+
if (exact.length > 0) from.push(...exact);
|
|
3073
|
+
else {
|
|
3074
|
+
from.push(key);
|
|
3075
|
+
unresolved.push(key);
|
|
3076
|
+
if (candidates.length > 0) suggestions.push({
|
|
3077
|
+
typed: entry,
|
|
3078
|
+
candidates
|
|
3079
|
+
});
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
return {
|
|
3083
|
+
from: [...new Set(from)],
|
|
3084
|
+
unresolved: [...new Set(unresolved)],
|
|
3085
|
+
suggestions
|
|
3086
|
+
};
|
|
3087
|
+
}
|
|
3088
|
+
async add(input) {
|
|
3089
|
+
const registry = this.#registry;
|
|
3090
|
+
if (!registry) throw new Error("Messages watches are not bound to a registry.");
|
|
3091
|
+
normalizeFilter({
|
|
3092
|
+
...input.from ? { from: input.from } : {},
|
|
3093
|
+
...input.domains ? { domains: input.domains } : {}
|
|
3094
|
+
});
|
|
3095
|
+
await this.ensure();
|
|
3096
|
+
const { from, unresolved, suggestions } = this.resolveFrom(input.from ?? []);
|
|
3097
|
+
const filter = normalizeFilter({
|
|
3098
|
+
...from.length ? { from } : {},
|
|
3099
|
+
...input.domains ? { domains: input.domains } : {}
|
|
3100
|
+
});
|
|
3101
|
+
return {
|
|
3102
|
+
watch: registry.add(filter),
|
|
3103
|
+
unresolved,
|
|
3104
|
+
suggestions
|
|
3105
|
+
};
|
|
3106
|
+
}
|
|
3107
|
+
};
|
|
3108
|
+
const registerWatchTools = (server, service) => {
|
|
3109
|
+
server.registerTool("apple_messages_watch", {
|
|
3110
|
+
description: "Watch for new messages from chosen handles. Give `from` (email addresses, or phone numbers in international form such as +33612345678) and/or `domains` (email handles only; matches the domain and its subdomains); a sender is required, there is no watch on all messages. Returns a handle and its `uri`. Read the uri to see what arrived since the watch began (or since `after`): a host that subscribes is told when to read it, but many never do, so check it yourself rather than promising the user a notification. Metadata only, so fetch the text with apple_messages_get_message and the `id`. The watch lasts as long as this connection. Your own messages and tapbacks never match. Needs Full Disk Access; Messages does not have to be running.",
|
|
3111
|
+
inputSchema: z.object({
|
|
3112
|
+
from: z.array(z.string().min(1)).optional().describe("Email addresses, or phone numbers in international form (+33612345678: no trunk 0, no (0)); spaces and punctuation are ignored, but a local spelling like 06 12 34 56 78 never matches. An entry the store does not hold is kept without spaces or punctuation, lowercased, and listed in `unresolved`, which does not prove the person has not written: it fires only if the store later holds exactly that spelling. Near misses come back as `suggestions` to retry with."),
|
|
3113
|
+
domains: z.array(z.string().min(1)).optional().describe("Email domains, e.g. acme.com. Matches only email handles, never a phone number.")
|
|
3114
|
+
}),
|
|
3115
|
+
annotations: { readOnlyHint: true }
|
|
3116
|
+
}, async ({ from, domains }) => wrap(async () => {
|
|
3117
|
+
const { watch, unresolved, suggestions } = await service.add({
|
|
3118
|
+
from,
|
|
3119
|
+
domains
|
|
3120
|
+
});
|
|
3121
|
+
return {
|
|
3122
|
+
watch: watch.id,
|
|
3123
|
+
uri: watch.uri,
|
|
3124
|
+
filter: {
|
|
3125
|
+
from: watch.filter.from,
|
|
3126
|
+
domains: watch.filter.domains
|
|
3127
|
+
},
|
|
3128
|
+
cursor: watch.createdAt,
|
|
3129
|
+
unresolved,
|
|
3130
|
+
...suggestions.length ? { suggestions } : {}
|
|
3131
|
+
};
|
|
3132
|
+
}));
|
|
3133
|
+
server.registerTool("apple_messages_unwatch", {
|
|
3134
|
+
description: "Stop a watch made by apple_messages_watch, by its `watch` id. Returns whether one was removed. Watches also end on their own when the connection closes, so this is only needed to free one of the ten a connection may hold.",
|
|
3135
|
+
inputSchema: z.object({ watch: z.string().min(1).describe("The `watch` id from apple_messages_watch.") }),
|
|
3136
|
+
annotations: { readOnlyHint: true }
|
|
3137
|
+
}, async ({ watch }) => wrap(async () => ({ removed: service.remove(watch) })));
|
|
3138
|
+
};
|
|
3139
|
+
//#endregion
|
|
2661
3140
|
//#region src/tools/index.ts
|
|
2662
3141
|
/**
|
|
2663
3142
|
* Register the Apple Messages tools.
|
|
@@ -2666,7 +3145,8 @@ const registerMessageTools = (server, client) => {
|
|
|
2666
3145
|
* surface the flag carries a permission claim as well as a safety one: with it
|
|
2667
3146
|
* off no Apple Event is ever sent, so no Automation grant is ever requested.
|
|
2668
3147
|
* What is needed either way is Full Disk Access, absolutely — see `diagnostics`.
|
|
2669
|
-
* One further read, `find_codes`, only when `allowCodes` is on.
|
|
3148
|
+
* One further read, `find_codes`, only when `allowCodes` is on. Two more, the watch
|
|
3149
|
+
* tools, only when `allowWatches` is on (`ctx.watches`).
|
|
2670
3150
|
*
|
|
2671
3151
|
* The registered set is a pure function of STATIC CONFIGURATION, never of
|
|
2672
3152
|
* runtime state. In particular it does NOT vary with whether the store is
|
|
@@ -2687,6 +3167,7 @@ const registerTools = (server, client, ctx) => {
|
|
|
2687
3167
|
registerMessageTools(server, client);
|
|
2688
3168
|
registerCountTools(server, client);
|
|
2689
3169
|
if (ctx.allowCodes) registerCodeTools(server, client);
|
|
3170
|
+
if (ctx.watches) registerWatchTools(server, ctx.watches);
|
|
2690
3171
|
if (!ctx.allowWrites) return;
|
|
2691
3172
|
registerActionTools(server, client, ctx);
|
|
2692
3173
|
registerAttachmentTools(server, client);
|
|
@@ -2713,6 +3194,47 @@ const createServer = (opts) => {
|
|
|
2713
3194
|
...opts.home ? { home: opts.home } : {},
|
|
2714
3195
|
...opts.contacts === void 0 ? {} : { contacts: opts.contacts }
|
|
2715
3196
|
});
|
|
3197
|
+
const closers = [];
|
|
3198
|
+
let watches;
|
|
3199
|
+
if (config.allowWatches) {
|
|
3200
|
+
watches = new MessagesWatchService({
|
|
3201
|
+
client,
|
|
3202
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
3203
|
+
});
|
|
3204
|
+
const service = watches;
|
|
3205
|
+
const registry = createWatches(server, {
|
|
3206
|
+
surface: "messages",
|
|
3207
|
+
displayName: "Messages",
|
|
3208
|
+
era: opts.era ?? "legacy",
|
|
3209
|
+
source: service.source,
|
|
3210
|
+
startTrigger: service.startTrigger
|
|
3211
|
+
});
|
|
3212
|
+
service.bind(registry);
|
|
3213
|
+
closers.push(() => {
|
|
3214
|
+
registry.close();
|
|
3215
|
+
service.close();
|
|
3216
|
+
});
|
|
3217
|
+
}
|
|
3218
|
+
let events;
|
|
3219
|
+
if (config.allowEvents) {
|
|
3220
|
+
events = new MessagesWatchService({
|
|
3221
|
+
client,
|
|
3222
|
+
mode: "events",
|
|
3223
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
3224
|
+
});
|
|
3225
|
+
const service = events;
|
|
3226
|
+
const served = registerEvents(server, {
|
|
3227
|
+
era: opts.era ?? "legacy",
|
|
3228
|
+
adapters: [messagesEventAdapter(service)]
|
|
3229
|
+
});
|
|
3230
|
+
closers.push(() => {
|
|
3231
|
+
served.close();
|
|
3232
|
+
service.close();
|
|
3233
|
+
});
|
|
3234
|
+
}
|
|
3235
|
+
if (closers.length > 0) server.server.onclose = () => {
|
|
3236
|
+
for (const close of closers) close();
|
|
3237
|
+
};
|
|
2716
3238
|
withLazyTools(server, {
|
|
2717
3239
|
surface: "messages",
|
|
2718
3240
|
displayName: "Messages",
|
|
@@ -2721,7 +3243,8 @@ const createServer = (opts) => {
|
|
|
2721
3243
|
}, (target, allowWrites) => registerTools(target, client, {
|
|
2722
3244
|
allowWrites,
|
|
2723
3245
|
allowCodes: config.allowCodes,
|
|
2724
|
-
allowFileSend: config.allowFileSend
|
|
3246
|
+
allowFileSend: config.allowFileSend,
|
|
3247
|
+
watches
|
|
2725
3248
|
}));
|
|
2726
3249
|
if (config.exposePrompts) {
|
|
2727
3250
|
registerPrompts(server, config.allowWrites);
|
|
@@ -2734,10 +3257,12 @@ const createServer = (opts) => {
|
|
|
2734
3257
|
}
|
|
2735
3258
|
return {
|
|
2736
3259
|
server,
|
|
2737
|
-
client
|
|
3260
|
+
client,
|
|
3261
|
+
watches,
|
|
3262
|
+
events
|
|
2738
3263
|
};
|
|
2739
3264
|
};
|
|
2740
3265
|
//#endregion
|
|
2741
3266
|
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 };
|
|
2742
3267
|
|
|
2743
|
-
//# sourceMappingURL=server-
|
|
3268
|
+
//# sourceMappingURL=server-D-2jgV-B.js.map
|