@elizaos/plugin-imessage 2.0.0-beta.1 → 2.0.3-beta.3

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -18
  3. package/auto-enable.ts +1 -1
  4. package/dist/accounts.d.ts +2 -2
  5. package/dist/accounts.js +3 -3
  6. package/dist/accounts.js.map +1 -1
  7. package/dist/api/bluebubbles-routes.d.ts +1 -1
  8. package/dist/api/bluebubbles-routes.d.ts.map +1 -1
  9. package/dist/api/imessage-routes.d.ts +9 -21
  10. package/dist/api/imessage-routes.d.ts.map +1 -1
  11. package/dist/api/imessage-routes.js +5 -7
  12. package/dist/api/imessage-routes.js.map +1 -1
  13. package/dist/chatdb-reader.d.ts +1 -1
  14. package/dist/chatdb-reader.d.ts.map +1 -1
  15. package/dist/chatdb-reader.js +21 -1
  16. package/dist/chatdb-reader.js.map +1 -1
  17. package/dist/config.d.ts +1 -1
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/connector-account-provider.d.ts +1 -1
  20. package/dist/connector-account-provider.js +5 -5
  21. package/dist/connector-account-provider.js.map +1 -1
  22. package/dist/contacts-reader.d.ts +23 -29
  23. package/dist/contacts-reader.d.ts.map +1 -1
  24. package/dist/contacts-reader.js +250 -372
  25. package/dist/contacts-reader.js.map +1 -1
  26. package/dist/data-routes.d.ts +21 -0
  27. package/dist/data-routes.d.ts.map +1 -0
  28. package/dist/data-routes.js +280 -0
  29. package/dist/data-routes.js.map +1 -0
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +6 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/rpc.js +5 -5
  35. package/dist/rpc.js.map +1 -1
  36. package/dist/service.d.ts +20 -22
  37. package/dist/service.d.ts.map +1 -1
  38. package/dist/service.js +65 -54
  39. package/dist/service.js.map +1 -1
  40. package/dist/setup-routes.d.ts +15 -27
  41. package/dist/setup-routes.d.ts.map +1 -1
  42. package/dist/setup-routes.js +101 -284
  43. package/dist/setup-routes.js.map +1 -1
  44. package/package.json +22 -4
  45. package/registry-entry.json +103 -0
@@ -10,28 +10,134 @@
10
10
  *
11
11
  * ---
12
12
  *
13
- * Backend: **AppleScript against Contacts.app**. Unlike Messages.app
14
- * (whose scripting dictionary does not expose the message table), the
15
- * Contacts app ships with a complete and officially-supported AppleScript
16
- * vocabulary covering `person`, `phone`, and `email` classes. Reading
17
- * contacts this way is the Apple-blessed path and does not require Full
18
- * Disk Access — only the macOS "Contacts" TCC permission, which the OS
19
- * prompts for on first use.
13
+ * Backend: **CNContactStore** through the shared native macOS dylib. This
14
+ * keeps the feature aligned with the macOS Contacts privacy grant and avoids
15
+ * asking for Automation access to the Contacts app.
20
16
  *
21
- * The reader runs once on service start and caches the result. Contacts
22
- * rarely change mid-session, so a periodic refresh is overkill for v1.
23
- * Callers can force a reload by constructing a fresh reader instance.
17
+ * The service calls this lazily when it actually needs name resolution or
18
+ * contact CRUD. Contacts rarely change mid-session, so the iMessage service
19
+ * caches the returned map for v1.
24
20
  *
25
21
  * Graceful degradation: if Contacts is not authorized, or returns no
26
- * rows, or AppleScript fails for any other reason, the reader returns
22
+ * rows, or the native bridge fails for any other reason, the reader returns
27
23
  * an empty map. The service treats that as "handles remain anonymous"
28
24
  * and proceeds normally — no crash, no hard failure.
29
25
  */
30
- import { execFile } from "node:child_process";
31
- import { promisify } from "node:util";
26
+ import { existsSync } from "node:fs";
27
+ import path from "node:path";
32
28
  import { logger } from "@elizaos/core";
33
- const execFileAsync = promisify(execFile);
34
- const DEFAULT_CONTACTS_SCRIPT_TIMEOUT_MS = 5_000;
29
+ const NATIVE_DYLIB_CANDIDATES = [
30
+ process.env.ELIZA_NATIVE_PERMISSIONS_DYLIB ?? "",
31
+ "../../../packages/app-core/platforms/electrobun/src/libMacWindowEffects.dylib",
32
+ ].filter(Boolean);
33
+ let nativeContactsBridge;
34
+ let lastContactsFailure = null;
35
+ export function getLastContactsFailure() {
36
+ return lastContactsFailure;
37
+ }
38
+ function cStringBuffer(value) {
39
+ const bytes = Buffer.from(value, "utf8");
40
+ const buffer = Buffer.alloc(bytes.byteLength + 1);
41
+ bytes.copy(buffer);
42
+ return buffer;
43
+ }
44
+ async function loadNativeContactsBridge() {
45
+ if (nativeContactsBridge !== undefined)
46
+ return nativeContactsBridge;
47
+ nativeContactsBridge = null;
48
+ if (process.platform !== "darwin")
49
+ return null;
50
+ for (const candidate of NATIVE_DYLIB_CANDIDATES) {
51
+ const dylibPath = path.isAbsolute(candidate)
52
+ ? candidate
53
+ : path.resolve(import.meta.dir, candidate);
54
+ if (!existsSync(dylibPath))
55
+ continue;
56
+ try {
57
+ const { CString, FFIType, dlopen, ptr } = await import("bun:ffi");
58
+ const lib = dlopen(dylibPath, {
59
+ loadContactsJson: { args: [], returns: FFIType.ptr },
60
+ listAllContactsJson: { args: [], returns: FFIType.ptr },
61
+ addContactJson: { args: [FFIType.ptr], returns: FFIType.ptr },
62
+ updateContactJson: {
63
+ args: [FFIType.ptr, FFIType.ptr],
64
+ returns: FFIType.ptr,
65
+ },
66
+ deleteContactJson: { args: [FFIType.ptr], returns: FFIType.ptr },
67
+ freeNativeCString: { args: [FFIType.ptr], returns: FFIType.void },
68
+ });
69
+ const takeNativeString = (value) => {
70
+ if (!value)
71
+ return null;
72
+ try {
73
+ return new CString(value).toString();
74
+ }
75
+ finally {
76
+ lib.symbols.freeNativeCString(value);
77
+ }
78
+ };
79
+ nativeContactsBridge = {
80
+ loadContacts() {
81
+ return takeNativeString(lib.symbols.loadContactsJson());
82
+ },
83
+ listAllContacts() {
84
+ return takeNativeString(lib.symbols.listAllContactsJson());
85
+ },
86
+ addContact(payloadJson) {
87
+ const payload = cStringBuffer(payloadJson);
88
+ return takeNativeString(lib.symbols.addContactJson(ptr(payload)));
89
+ },
90
+ updateContact(personId, payloadJson) {
91
+ const id = cStringBuffer(personId);
92
+ const payload = cStringBuffer(payloadJson);
93
+ return takeNativeString(lib.symbols.updateContactJson(ptr(id), ptr(payload)));
94
+ },
95
+ deleteContact(personId) {
96
+ const id = cStringBuffer(personId);
97
+ return takeNativeString(lib.symbols.deleteContactJson(ptr(id)));
98
+ },
99
+ };
100
+ return nativeContactsBridge;
101
+ }
102
+ catch (error) {
103
+ logger.warn(`[imessage] Failed to load native Contacts bridge from ${dylibPath}: ${error instanceof Error ? error.message : String(error)}`);
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ function parseNativeContactsResponse(raw) {
109
+ if (!raw) {
110
+ return {
111
+ ok: false,
112
+ error: "native_error",
113
+ message: "Native Contacts bridge returned no response.",
114
+ };
115
+ }
116
+ try {
117
+ const parsed = JSON.parse(raw);
118
+ return {
119
+ ok: parsed.ok === true,
120
+ error: typeof parsed.error === "string" ? parsed.error : undefined,
121
+ id: typeof parsed.id === "string" ? parsed.id : undefined,
122
+ message: typeof parsed.message === "string" ? parsed.message : undefined,
123
+ contacts: Array.isArray(parsed.contacts) ? parsed.contacts : undefined,
124
+ };
125
+ }
126
+ catch {
127
+ return {
128
+ ok: false,
129
+ error: "native_error",
130
+ message: "Native Contacts bridge returned invalid JSON.",
131
+ };
132
+ }
133
+ }
134
+ function isRecord(value) {
135
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
136
+ }
137
+ function stringField(record, key) {
138
+ const value = record[key];
139
+ return typeof value === "string" ? value : "";
140
+ }
35
141
  /**
36
142
  * Normalize a handle to the canonical form used as a key in the
37
143
  * ContactsMap. Strips whitespace, parentheses, hyphens, and dots from
@@ -51,52 +157,16 @@ export function normalizeContactHandle(raw) {
51
157
  return hasPlus ? `+${digitsOnly}` : digitsOnly;
52
158
  }
53
159
  /**
54
- * AppleScript that asks Contacts.app to dump every person's name and
55
- * every phone/email handle they own, one row per handle, tab-delimited.
56
- *
57
- * Format per line: `<kind>\t<handle>\t<name>` where `<kind>` is
58
- * `phone` or `email`. Lines with empty values are skipped by the parser.
59
- */
60
- const CONTACTS_DUMP_SCRIPT = `
61
- tell application "Contacts"
62
- launch
63
- set AppleScript's text item delimiters to tab
64
- set outputLines to {}
65
- repeat with p in people
66
- set personName to name of p
67
- if personName is missing value then set personName to ""
68
- repeat with ph in phones of p
69
- set phoneValue to value of ph
70
- if phoneValue is not missing value then
71
- set end of outputLines to "phone" & tab & phoneValue & tab & personName
72
- end if
73
- end repeat
74
- repeat with em in emails of p
75
- set emailValue to value of em
76
- if emailValue is not missing value then
77
- set end of outputLines to "email" & tab & emailValue & tab & personName
78
- end if
79
- end repeat
80
- end repeat
81
- set AppleScript's text item delimiters to linefeed
82
- set outputText to outputLines as string
83
- set AppleScript's text item delimiters to ""
84
- return outputText
85
- end tell
86
- `.trim();
87
- /**
88
- * Parse the tab-delimited output of `CONTACTS_DUMP_SCRIPT` into a
89
- * ContactsMap. Exported so tests can exercise it with fixture strings
90
- * without needing a live Contacts.app.
160
+ * Parse legacy tab-delimited contact fixture output into a ContactsMap.
161
+ * Exported so tests can exercise normalization without a live address book.
91
162
  *
92
163
  * Input format per line: `kind\thandle\tname`.
93
164
  * Empty lines are skipped. Lines with fewer than 3 fields are skipped.
94
- * Empty handles are skipped. Duplicate handles keep the first entry
95
- * (AppleScript's iteration order is generally stable).
165
+ * Empty handles are skipped. Duplicate handles keep the first entry.
96
166
  */
97
167
  export function parseContactsOutput(raw) {
98
168
  const map = new Map();
99
- if (!raw?.trim())
169
+ if (!raw.trim())
100
170
  return map;
101
171
  for (const line of raw.split("\n")) {
102
172
  const trimmed = line.trim();
@@ -117,365 +187,173 @@ export function parseContactsOutput(raw) {
117
187
  }
118
188
  return map;
119
189
  }
190
+ function contactsMapFromNativeRows(rows) {
191
+ const map = new Map();
192
+ for (const row of rows ?? []) {
193
+ if (!isRecord(row))
194
+ continue;
195
+ const handle = stringField(row, "handle");
196
+ const name = stringField(row, "name");
197
+ if (!handle || !name)
198
+ continue;
199
+ const normalized = normalizeContactHandle(handle);
200
+ if (!normalized)
201
+ continue;
202
+ if (map.has(normalized))
203
+ continue;
204
+ map.set(normalized, { name: name.trim() });
205
+ }
206
+ return map;
207
+ }
120
208
  /**
121
- * Run the contacts dump AppleScript and return a ContactsMap. Returns
209
+ * Read Apple Contacts through CNContactStore and return a ContactsMap. Returns
122
210
  * an empty map (with a warning log) on any failure — most commonly, the
123
- * user hasn't authorized Contacts access yet, in which case macOS
124
- * surfaces a one-time system prompt and this call returns empty until
125
- * the user accepts on a subsequent run.
211
+ * user hasn't authorized Contacts access yet.
126
212
  */
127
213
  export async function loadContacts() {
128
- try {
129
- const stdout = await runContactsScript(CONTACTS_DUMP_SCRIPT);
130
- const map = parseContactsOutput(stdout);
131
- logger.info(`[imessage] Contacts loaded: ${map.size} handle(s) resolved from Contacts.app`);
132
- return map;
133
- }
134
- catch (error) {
135
- const reason = error instanceof Error ? error.message : String(error);
136
- if (/not authorized|1743/.test(reason)) {
137
- logger.warn("[imessage] Contacts access not yet authorized. macOS will prompt " +
138
- "the user on the next run. Inbound messages will use raw handles " +
139
- "(phone numbers / emails) until Contacts access is granted.");
140
- }
141
- else {
142
- logger.warn(`[imessage] Failed to load Contacts.app data: ${reason}. ` +
143
- "Inbound messages will use raw handles instead of names.");
144
- }
214
+ const bridge = await loadNativeContactsBridge();
215
+ if (!bridge) {
216
+ lastContactsFailure = "bridge_unavailable";
217
+ logger.warn("[imessage] Native Contacts bridge unavailable. Inbound messages will use raw handles.");
145
218
  return new Map();
146
219
  }
147
- }
148
- /**
149
- * Escape a JavaScript string so it can be embedded inside an
150
- * AppleScript double-quoted string literal. Handles backslashes and
151
- * quotes AppleScript doesn't treat newlines specially inside quoted
152
- * strings, so those pass through as-is.
153
- */
154
- function escapeAppleScriptString(value) {
155
- return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
156
- }
157
- /**
158
- * Whether we've already confirmed Contacts.app is running in this
159
- * process lifetime. Avoids paying the `open -a` cost on every call.
160
- */
161
- let contactsLaunched = false;
162
- function getContactsScriptTimeoutMs() {
163
- const raw = Number(process.env.IMESSAGE_CONTACTS_TIMEOUT_MS);
164
- return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_CONTACTS_SCRIPT_TIMEOUT_MS;
165
- }
166
- /**
167
- * Ensure Contacts.app is actually in the running process list before
168
- * handing an AppleScript to it. Annoyingly, the `launch` verb inside
169
- * `tell application "Contacts"` is a no-op on modern macOS when the
170
- * app isn't already running — it doesn't actually start the process,
171
- * and the subsequent scripting call returns `Application isn't running
172
- * (-600)`. The reliable fix is to use the shell-level `open` which
173
- * goes through LaunchServices and spawns the app.
174
- *
175
- * `-g` launches in the background so Contacts doesn't steal focus from
176
- * whatever window the user was just looking at (a plain `open -a`
177
- * raises the app to the foreground, which interrupts the UI mid-boot).
178
- * `-j` hides the app entirely on first launch so the user never sees a
179
- * Contacts window flash across the screen — the AppleScript bridge
180
- * still works against a hidden instance.
181
- *
182
- * Idempotent: after the first successful launch we set a flag and
183
- * subsequent calls are no-ops. If Contacts.app is force-quit mid-run
184
- * the next osascript will still throw -600 and the caller's error
185
- * path will log it; at that point restarting the plugin recovers.
186
- */
187
- async function ensureContactsLaunched() {
188
- if (contactsLaunched)
189
- return;
190
- try {
191
- await execFileAsync("open", ["-g", "-j", "-a", "Contacts"]);
192
- // Give LaunchServices a beat to register the process so the next
193
- // scripting-bridge call finds it. 400ms is empirical: 100ms was
194
- // flaky, 250ms worked most of the time, 400ms has been reliable.
195
- await new Promise((r) => setTimeout(r, 400));
196
- contactsLaunched = true;
220
+ const response = parseNativeContactsResponse(bridge.loadContacts());
221
+ if (response.ok) {
222
+ lastContactsFailure = null;
223
+ const map = contactsMapFromNativeRows(response.contacts);
224
+ logger.info(`[imessage] Contacts loaded: ${map.size} handle(s) resolved from Apple Contacts`);
225
+ return map;
197
226
  }
198
- catch {
199
- // If `open` fails (unlikely — it's a LaunchServices primitive
200
- // that almost never fails on a healthy Mac), we still try the
201
- // osascript below. The error from there will be more informative.
227
+ if (response.error === "permission") {
228
+ lastContactsFailure = "permission";
229
+ logger.warn("[imessage] Contacts access not authorized. Inbound messages will use raw handles until Contacts access is granted.");
202
230
  }
231
+ else {
232
+ lastContactsFailure = "native_error";
233
+ logger.warn(`[imessage] Failed to load Apple Contacts data: ${response.message ?? response.error ?? "unknown error"}. Inbound messages will use raw handles instead of names.`);
234
+ }
235
+ return new Map();
203
236
  }
204
- /**
205
- * Run an AppleScript against Contacts.app and return its stdout. Shared
206
- * helper used by every read/write in this file. On failure — which
207
- * includes both TCC denials and actual script errors — throws an Error
208
- * with the stderr/message for the caller to classify.
209
- */
210
- async function runContactsScript(script) {
211
- await ensureContactsLaunched();
212
- const { stdout } = await execFileAsync("osascript", ["-e", script], {
213
- maxBuffer: 10 * 1024 * 1024,
214
- timeout: getContactsScriptTimeoutMs(),
215
- killSignal: "SIGTERM",
237
+ function labeledValuesFromNativeRows(rows) {
238
+ if (!Array.isArray(rows))
239
+ return [];
240
+ return rows.flatMap((entry) => {
241
+ if (!isRecord(entry))
242
+ return [];
243
+ const value = stringField(entry, "value");
244
+ if (!value)
245
+ return [];
246
+ const label = stringField(entry, "label");
247
+ return [{ label: label || null, value }];
216
248
  });
217
- return stdout;
218
249
  }
219
- /**
220
- * Parse the tab-delimited output of the full-contacts dump. One row per
221
- * contact with pipe-separated phone and email lists so the script can
222
- * return everything in a single round-trip.
223
- *
224
- * Row format (tab-delimited):
225
- * `<id>\t<name>\t<firstName>\t<lastName>\t<phones>\t<emails>`
226
- * where <phones> is `label1=value1|label2=value2` and same for emails.
227
- * Empty fields are empty strings, not missing.
228
- */
229
- function parseFullContactsOutput(raw) {
230
- const out = [];
231
- if (!raw?.trim())
232
- return out;
233
- for (const line of raw.split("\n")) {
234
- const trimmed = line.trim();
235
- if (!trimmed)
236
- continue;
237
- const fields = trimmed.split("\t");
238
- if (fields.length < 6)
239
- continue;
240
- const [id, name, firstName, lastName, phonesField, emailsField] = fields;
250
+ function fullContactsFromNativeRows(rows) {
251
+ return (rows ?? []).flatMap((entry) => {
252
+ if (!isRecord(entry))
253
+ return [];
254
+ const id = stringField(entry, "id");
241
255
  if (!id)
242
- continue;
243
- const parsePairs = (input) => {
244
- if (!input)
245
- return [];
246
- return input
247
- .split("|")
248
- .filter(Boolean)
249
- .map((pair) => {
250
- const eqIdx = pair.indexOf("=");
251
- if (eqIdx === -1)
252
- return { label: null, value: pair };
253
- return {
254
- label: pair.slice(0, eqIdx) || null,
255
- value: pair.slice(eqIdx + 1),
256
- };
257
- });
258
- };
259
- out.push({
260
- id,
261
- name: name || "",
262
- firstName: firstName || null,
263
- lastName: lastName || null,
264
- phones: parsePairs(phonesField),
265
- emails: parsePairs(emailsField),
266
- });
267
- }
268
- return out;
256
+ return [];
257
+ return [
258
+ {
259
+ id,
260
+ name: stringField(entry, "name"),
261
+ firstName: stringField(entry, "firstName") || null,
262
+ lastName: stringField(entry, "lastName") || null,
263
+ phones: labeledValuesFromNativeRows(entry.phones),
264
+ emails: labeledValuesFromNativeRows(entry.emails),
265
+ },
266
+ ];
267
+ });
269
268
  }
270
- /**
271
- * AppleScript that dumps every contact as a tab-delimited row with
272
- * pipe-separated phone and email lists. Matches `parseFullContactsOutput`
273
- * above. Runs in a single round-trip against Contacts.app.
274
- */
275
- const FULL_CONTACTS_DUMP_SCRIPT = `
276
- tell application "Contacts"
277
- launch
278
- set AppleScript's text item delimiters to tab
279
- set outputLines to {}
280
- repeat with p in people
281
- set personId to id of p
282
- set personName to name of p
283
- if personName is missing value then set personName to ""
284
- set personFirst to first name of p
285
- if personFirst is missing value then set personFirst to ""
286
- set personLast to last name of p
287
- if personLast is missing value then set personLast to ""
288
-
289
- set phoneList to ""
290
- repeat with ph in phones of p
291
- set phoneValue to value of ph
292
- if phoneValue is not missing value then
293
- set phoneLabel to label of ph
294
- if phoneLabel is missing value then set phoneLabel to ""
295
- if phoneList is not "" then set phoneList to phoneList & "|"
296
- set phoneList to phoneList & phoneLabel & "=" & phoneValue
297
- end if
298
- end repeat
299
-
300
- set emailList to ""
301
- repeat with em in emails of p
302
- set emailValue to value of em
303
- if emailValue is not missing value then
304
- set emailLabel to label of em
305
- if emailLabel is missing value then set emailLabel to ""
306
- if emailList is not "" then set emailList to emailList & "|"
307
- set emailList to emailList & emailLabel & "=" & emailValue
308
- end if
309
- end repeat
310
-
311
- set end of outputLines to personId & tab & personName & tab & personFirst & tab & personLast & tab & phoneList & tab & emailList
312
- end repeat
313
- set AppleScript's text item delimiters to linefeed
314
- set outputText to outputLines as string
315
- set AppleScript's text item delimiters to ""
316
- return outputText
317
- end tell
318
- `.trim();
319
269
  /**
320
270
  * List every contact in the user's address book as a full `FullContact`
321
271
  * record. Returns an empty array on any failure (permission denied,
322
- * script error, etc.) with a warning log.
272
+ * native bridge error, etc.) with a warning log.
323
273
  */
324
274
  export async function listAllContacts() {
325
- try {
326
- const stdout = await runContactsScript(FULL_CONTACTS_DUMP_SCRIPT);
327
- return parseFullContactsOutput(stdout);
328
- }
329
- catch (error) {
330
- logger.warn(`[imessage] listAllContacts failed: ${error instanceof Error ? error.message : String(error)}`);
275
+ const bridge = await loadNativeContactsBridge();
276
+ if (!bridge) {
277
+ lastContactsFailure = "bridge_unavailable";
278
+ logger.warn("[imessage] listAllContacts failed: native bridge unavailable");
331
279
  return [];
332
280
  }
281
+ const response = parseNativeContactsResponse(bridge.listAllContacts());
282
+ if (response.ok) {
283
+ lastContactsFailure = null;
284
+ return fullContactsFromNativeRows(response.contacts);
285
+ }
286
+ lastContactsFailure = response.error === "permission" ? "permission" : "native_error";
287
+ logger.warn(`[imessage] listAllContacts failed: ${response.message ?? response.error ?? "unknown error"}`);
288
+ return [];
333
289
  }
334
290
  /**
335
- * Create a new contact in Contacts.app. Returns the new person's id on
291
+ * Create a new Apple Contacts record. Returns the new person's id on
336
292
  * success, or null on failure (permission denied, validation, etc.).
337
293
  *
338
- * Requires Contacts WRITE permission, which macOS prompts for on the
339
- * first write call. Read-only sessions never trigger this prompt.
294
+ * Requires the Contacts privacy grant.
340
295
  */
341
296
  export async function addContact(input) {
342
- const first = escapeAppleScriptString(input.firstName ?? "");
343
- const last = escapeAppleScriptString(input.lastName ?? "");
344
- // Build phone + email blocks as separate `make new phone/email` verbs
345
- // nested inside a `tell newPerson` block. This lets us set the label
346
- // on each one, which the simpler `{phones: {...}}` property syntax
347
- // doesn't support.
348
- const phoneLines = (input.phones ?? [])
349
- .filter((p) => p.value)
350
- .map((p) => {
351
- const value = escapeAppleScriptString(p.value);
352
- const label = escapeAppleScriptString(p.label ?? "mobile");
353
- return `make new phone at end of phones with properties {value:"${value}", label:"${label}"}`;
354
- })
355
- .join("\n ");
356
- const emailLines = (input.emails ?? [])
357
- .filter((e) => e.value)
358
- .map((e) => {
359
- const value = escapeAppleScriptString(e.value);
360
- const label = escapeAppleScriptString(e.label ?? "home");
361
- return `make new email at end of emails with properties {value:"${value}", label:"${label}"}`;
362
- })
363
- .join("\n ");
364
- const script = `
365
- tell application "Contacts"
366
- launch
367
- set newPerson to make new person with properties {first name:"${first}", last name:"${last}"}
368
- tell newPerson
369
- ${phoneLines}
370
- ${emailLines}
371
- end tell
372
- save
373
- return id of newPerson
374
- end tell
375
- `.trim();
376
- try {
377
- const stdout = await runContactsScript(script);
378
- const id = stdout.trim();
379
- if (!id)
380
- return null;
381
- logger.info(`[imessage] Contact created: ${id}`);
382
- return id;
383
- }
384
- catch (error) {
385
- logger.warn(`[imessage] addContact failed: ${error instanceof Error ? error.message : String(error)}`);
297
+ const bridge = await loadNativeContactsBridge();
298
+ if (!bridge) {
299
+ lastContactsFailure = "bridge_unavailable";
300
+ logger.warn("[imessage] addContact failed: native bridge unavailable");
386
301
  return null;
387
302
  }
303
+ const response = parseNativeContactsResponse(bridge.addContact(JSON.stringify(input)));
304
+ if (response.ok && response.id) {
305
+ lastContactsFailure = null;
306
+ logger.info(`[imessage] Contact created: ${response.id}`);
307
+ return response.id;
308
+ }
309
+ lastContactsFailure = response.error === "permission" ? "permission" : "native_error";
310
+ logger.warn(`[imessage] addContact failed: ${response.message ?? response.error ?? "unknown error"}`);
311
+ return null;
388
312
  }
389
313
  export async function updateContact(personId, patch) {
390
- const id = escapeAppleScriptString(personId);
391
- const fragments = [];
392
- if (patch.firstName !== undefined) {
393
- fragments.push(`set first name of thePerson to "${escapeAppleScriptString(patch.firstName)}"`);
394
- }
395
- if (patch.lastName !== undefined) {
396
- fragments.push(`set last name of thePerson to "${escapeAppleScriptString(patch.lastName)}"`);
397
- }
398
- for (const phone of patch.addPhones ?? []) {
399
- if (!phone.value)
400
- continue;
401
- fragments.push(`tell thePerson to make new phone at end of phones with properties {value:"${escapeAppleScriptString(phone.value)}", label:"${escapeAppleScriptString(phone.label ?? "mobile")}"}`);
402
- }
403
- for (const phoneValue of patch.removePhones ?? []) {
404
- if (!phoneValue)
405
- continue;
406
- fragments.push(`
407
- tell thePerson
408
- repeat with ph in phones
409
- if (value of ph) is "${escapeAppleScriptString(phoneValue)}" then
410
- delete ph
411
- exit repeat
412
- end if
413
- end repeat
414
- end tell`);
415
- }
416
- for (const email of patch.addEmails ?? []) {
417
- if (!email.value)
418
- continue;
419
- fragments.push(`tell thePerson to make new email at end of emails with properties {value:"${escapeAppleScriptString(email.value)}", label:"${escapeAppleScriptString(email.label ?? "home")}"}`);
420
- }
421
- for (const emailValue of patch.removeEmails ?? []) {
422
- if (!emailValue)
423
- continue;
424
- fragments.push(`
425
- tell thePerson
426
- repeat with em in emails
427
- if (value of em) is "${escapeAppleScriptString(emailValue)}" then
428
- delete em
429
- exit repeat
430
- end if
431
- end repeat
432
- end tell`);
433
- }
434
- if (fragments.length === 0) {
435
- // Nothing to do — treat as a successful no-op.
314
+ if (patch.firstName === undefined &&
315
+ patch.lastName === undefined &&
316
+ (patch.addPhones?.length ?? 0) === 0 &&
317
+ (patch.removePhones?.length ?? 0) === 0 &&
318
+ (patch.addEmails?.length ?? 0) === 0 &&
319
+ (patch.removeEmails?.length ?? 0) === 0) {
436
320
  return true;
437
321
  }
438
- const script = `
439
- tell application "Contacts"
440
- launch
441
- set thePerson to person id "${id}"
442
- ${fragments.join("\n ")}
443
- save
444
- return "ok"
445
- end tell
446
- `.trim();
447
- try {
448
- await runContactsScript(script);
322
+ const bridge = await loadNativeContactsBridge();
323
+ if (!bridge) {
324
+ lastContactsFailure = "bridge_unavailable";
325
+ logger.warn(`[imessage] updateContact failed for ${personId}: native bridge unavailable`);
326
+ return false;
327
+ }
328
+ const response = parseNativeContactsResponse(bridge.updateContact(personId, JSON.stringify(patch)));
329
+ if (response.ok) {
330
+ lastContactsFailure = null;
449
331
  logger.info(`[imessage] Contact updated: ${personId}`);
450
332
  return true;
451
333
  }
452
- catch (error) {
453
- logger.warn(`[imessage] updateContact failed for ${personId}: ${error instanceof Error ? error.message : String(error)}`);
454
- return false;
455
- }
334
+ lastContactsFailure = response.error === "permission" ? "permission" : "native_error";
335
+ logger.warn(`[imessage] updateContact failed for ${personId}: ${response.message ?? response.error ?? "unknown error"}`);
336
+ return false;
456
337
  }
457
338
  /**
458
- * Delete a contact by Contacts.app id. Requires write permission.
339
+ * Delete a contact by Apple Contacts id. Requires the Contacts privacy grant.
459
340
  * Returns false on any failure (not found, permission denied, etc.).
460
341
  */
461
342
  export async function deleteContact(personId) {
462
- const id = escapeAppleScriptString(personId);
463
- const script = `
464
- tell application "Contacts"
465
- launch
466
- delete (person id "${id}")
467
- save
468
- return "ok"
469
- end tell
470
- `.trim();
471
- try {
472
- await runContactsScript(script);
343
+ const bridge = await loadNativeContactsBridge();
344
+ if (!bridge) {
345
+ lastContactsFailure = "bridge_unavailable";
346
+ logger.warn(`[imessage] deleteContact failed for ${personId}: native bridge unavailable`);
347
+ return false;
348
+ }
349
+ const response = parseNativeContactsResponse(bridge.deleteContact(personId));
350
+ if (response.ok) {
351
+ lastContactsFailure = null;
473
352
  logger.info(`[imessage] Contact deleted: ${personId}`);
474
353
  return true;
475
354
  }
476
- catch (error) {
477
- logger.warn(`[imessage] deleteContact failed for ${personId}: ${error instanceof Error ? error.message : String(error)}`);
478
- return false;
479
- }
355
+ lastContactsFailure = response.error === "permission" ? "permission" : "native_error";
356
+ logger.warn(`[imessage] deleteContact failed for ${personId}: ${response.message ?? response.error ?? "unknown error"}`);
357
+ return false;
480
358
  }
481
359
  //# sourceMappingURL=contacts-reader.js.map