@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.
- package/LICENSE +21 -0
- package/README.md +13 -18
- package/auto-enable.ts +1 -1
- package/dist/accounts.d.ts +2 -2
- package/dist/accounts.js +3 -3
- package/dist/accounts.js.map +1 -1
- package/dist/api/bluebubbles-routes.d.ts +1 -1
- package/dist/api/bluebubbles-routes.d.ts.map +1 -1
- package/dist/api/imessage-routes.d.ts +9 -21
- package/dist/api/imessage-routes.d.ts.map +1 -1
- package/dist/api/imessage-routes.js +5 -7
- package/dist/api/imessage-routes.js.map +1 -1
- package/dist/chatdb-reader.d.ts +1 -1
- package/dist/chatdb-reader.d.ts.map +1 -1
- package/dist/chatdb-reader.js +21 -1
- package/dist/chatdb-reader.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/connector-account-provider.d.ts +1 -1
- package/dist/connector-account-provider.js +5 -5
- package/dist/connector-account-provider.js.map +1 -1
- package/dist/contacts-reader.d.ts +23 -29
- package/dist/contacts-reader.d.ts.map +1 -1
- package/dist/contacts-reader.js +250 -372
- package/dist/contacts-reader.js.map +1 -1
- package/dist/data-routes.d.ts +21 -0
- package/dist/data-routes.d.ts.map +1 -0
- package/dist/data-routes.js +280 -0
- package/dist/data-routes.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/rpc.js +5 -5
- package/dist/rpc.js.map +1 -1
- package/dist/service.d.ts +20 -22
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +65 -54
- package/dist/service.js.map +1 -1
- package/dist/setup-routes.d.ts +15 -27
- package/dist/setup-routes.d.ts.map +1 -1
- package/dist/setup-routes.js +101 -284
- package/dist/setup-routes.js.map +1 -1
- package/package.json +22 -4
- package/registry-entry.json +103 -0
package/dist/contacts-reader.js
CHANGED
|
@@ -10,28 +10,134 @@
|
|
|
10
10
|
*
|
|
11
11
|
* ---
|
|
12
12
|
*
|
|
13
|
-
* Backend: **
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
22
|
-
* rarely change mid-session, so
|
|
23
|
-
*
|
|
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
|
|
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 {
|
|
31
|
-
import
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
32
28
|
import { logger } from "@elizaos/core";
|
|
33
|
-
const
|
|
34
|
-
|
|
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
|
-
*
|
|
55
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
logger.
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
*
|
|
272
|
+
* native bridge error, etc.) with a warning log.
|
|
323
273
|
*/
|
|
324
274
|
export async function listAllContacts() {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
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
|
|
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
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
|
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
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|