@elizaos/plugin-imessage 2.0.0-beta.1 → 2.0.11-beta.7

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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -18
  3. package/auto-enable.ts +1 -1
  4. package/package.json +21 -4
  5. package/dist/accounts.d.ts +0 -135
  6. package/dist/accounts.d.ts.map +0 -1
  7. package/dist/accounts.js +0 -209
  8. package/dist/accounts.js.map +0 -1
  9. package/dist/api/bluebubbles-routes.d.ts +0 -10
  10. package/dist/api/bluebubbles-routes.d.ts.map +0 -1
  11. package/dist/api/bluebubbles-routes.js +0 -132
  12. package/dist/api/bluebubbles-routes.js.map +0 -1
  13. package/dist/api/imessage-routes.d.ts +0 -80
  14. package/dist/api/imessage-routes.d.ts.map +0 -1
  15. package/dist/api/imessage-routes.js +0 -230
  16. package/dist/api/imessage-routes.js.map +0 -1
  17. package/dist/chatdb-reader.d.ts +0 -240
  18. package/dist/chatdb-reader.d.ts.map +0 -1
  19. package/dist/chatdb-reader.js +0 -647
  20. package/dist/chatdb-reader.js.map +0 -1
  21. package/dist/config.d.ts +0 -60
  22. package/dist/config.d.ts.map +0 -1
  23. package/dist/config.js +0 -8
  24. package/dist/config.js.map +0 -1
  25. package/dist/connector-account-provider.d.ts +0 -18
  26. package/dist/connector-account-provider.d.ts.map +0 -1
  27. package/dist/connector-account-provider.js +0 -83
  28. package/dist/connector-account-provider.js.map +0 -1
  29. package/dist/contacts-reader.d.ts +0 -147
  30. package/dist/contacts-reader.d.ts.map +0 -1
  31. package/dist/contacts-reader.js +0 -481
  32. package/dist/contacts-reader.js.map +0 -1
  33. package/dist/index.d.ts +0 -23
  34. package/dist/index.d.ts.map +0 -1
  35. package/dist/index.js +0 -78
  36. package/dist/index.js.map +0 -1
  37. package/dist/providers/index.d.ts +0 -4
  38. package/dist/providers/index.d.ts.map +0 -1
  39. package/dist/providers/index.js +0 -5
  40. package/dist/providers/index.js.map +0 -1
  41. package/dist/rpc.d.ts +0 -206
  42. package/dist/rpc.d.ts.map +0 -1
  43. package/dist/rpc.js +0 -393
  44. package/dist/rpc.js.map +0 -1
  45. package/dist/service.d.ts +0 -266
  46. package/dist/service.d.ts.map +0 -1
  47. package/dist/service.js +0 -1694
  48. package/dist/service.js.map +0 -1
  49. package/dist/setup-routes.d.ts +0 -38
  50. package/dist/setup-routes.d.ts.map +0 -1
  51. package/dist/setup-routes.js +0 -322
  52. package/dist/setup-routes.js.map +0 -1
  53. package/dist/types.d.ts +0 -192
  54. package/dist/types.d.ts.map +0 -1
  55. package/dist/types.js +0 -138
  56. package/dist/types.js.map +0 -1
package/dist/service.js DELETED
@@ -1,1694 +0,0 @@
1
- /**
2
- * iMessage service implementation for elizaOS.
3
- */
4
- import { execFile } from "node:child_process";
5
- import { existsSync } from "node:fs";
6
- import { platform } from "node:os";
7
- import { promisify } from "node:util";
8
- import { ChannelType, ContentType, createUniqueUuid, EventType, lifeOpsPassiveConnectorsEnabled, logger, MemoryType, Service, } from "@elizaos/core";
9
- import { DEFAULT_ACCOUNT_ID as IMESSAGE_LOCAL_ACCOUNT_ID, normalizeAccountId as normalizeIMessageAccountId, } from "./accounts.js";
10
- import { DEFAULT_CHAT_DB_PATH, getLastChatDbAccessIssue, openChatDb, } from "./chatdb-reader.js";
11
- import { addContact, deleteContact, listAllContacts, loadContacts, normalizeContactHandle, updateContact, } from "./contacts-reader.js";
12
- import { DEFAULT_POLL_INTERVAL_MS, formatPhoneNumber, IMESSAGE_SERVICE_NAME, IMessageCliError, IMessageConfigurationError, IMessageEventTypes, IMessageNotSupportedError, isEmail, isPhoneNumber, isValidIMessageTarget, normalizeIMessageTarget, splitMessageForIMessage, } from "./types.js";
13
- const execFileAsync = promisify(execFile);
14
- function appleScriptStringLiteral(value) {
15
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
16
- }
17
- function registerMessageConnectorIfAvailable(runtime, registration) {
18
- const withRegistry = runtime;
19
- if (typeof withRegistry.registerMessageConnector === "function") {
20
- withRegistry.registerMessageConnector(registration);
21
- return;
22
- }
23
- if (!registration.sendHandler) {
24
- throw new Error("iMessage connector registration requires a send handler");
25
- }
26
- runtime.registerSendHandler(registration.source, registration.sendHandler);
27
- }
28
- function hasTaskLookup(runtime) {
29
- return "getTasksByName" in runtime && typeof runtime.getTasksByName === "function";
30
- }
31
- function readTargetAccountId(target) {
32
- return target?.accountId;
33
- }
34
- function readContextAccountId(context) {
35
- return context?.accountId;
36
- }
37
- function targetWithAccount(target, accountId) {
38
- return { ...target, accountId };
39
- }
40
- function normalizeIMessageConnectorHandle(value) {
41
- const stripped = value
42
- .trim()
43
- .replace(/^(?:messages?|sms|text):/i, "")
44
- .trim();
45
- const normalizedTarget = normalizeIMessageTarget(stripped) ?? stripped;
46
- if (!normalizedTarget)
47
- return "";
48
- if (normalizedTarget.startsWith("chat_id:"))
49
- return normalizedTarget;
50
- if (/^(?:imessage|sms|rcs);/i.test(normalizedTarget)) {
51
- return `chat_id:${normalizedTarget}`;
52
- }
53
- if (isEmail(normalizedTarget))
54
- return normalizedTarget.toLowerCase();
55
- if (isPhoneNumber(normalizedTarget))
56
- return formatPhoneNumber(normalizedTarget);
57
- return normalizeContactHandle(normalizedTarget) || normalizedTarget;
58
- }
59
- function firstAttachmentUrl(content) {
60
- const attachment = content.attachments?.find((item) => typeof item?.url === "string" && item.url.trim().length > 0);
61
- return attachment?.url?.trim();
62
- }
63
- function statusMetadata(status) {
64
- return {
65
- available: status.available,
66
- connected: status.connected,
67
- chatDbAvailable: status.chatDbAvailable,
68
- sendOnly: status.sendOnly,
69
- chatDbPath: status.chatDbPath,
70
- reason: status.reason,
71
- permissionAction: status.permissionAction?.label ?? null,
72
- };
73
- }
74
- function normalizedSearchText(value) {
75
- return (value ?? "")
76
- .toLowerCase()
77
- .replace(/[^a-z0-9@+._-]+/g, " ")
78
- .trim();
79
- }
80
- function matchesQuery(query, ...values) {
81
- const normalizedQuery = normalizedSearchText(query);
82
- if (!normalizedQuery)
83
- return true;
84
- const normalizedHandleQuery = normalizedSearchText(normalizeIMessageConnectorHandle(query));
85
- return values.some((value) => {
86
- const normalizedValue = normalizedSearchText(value);
87
- return (normalizedValue.includes(normalizedQuery) ||
88
- (normalizedHandleQuery.length > 0 && normalizedValue.includes(normalizedHandleQuery)));
89
- });
90
- }
91
- function resolveLocalIMessageAccountId(accountId) {
92
- return normalizeIMessageAccountId(accountId ?? IMESSAGE_LOCAL_ACCOUNT_ID);
93
- }
94
- function assertLocalIMessageAccount(accountId) {
95
- const normalized = resolveLocalIMessageAccountId(accountId);
96
- if (normalized !== IMESSAGE_LOCAL_ACCOUNT_ID) {
97
- throw new Error(`iMessage uses the single local macOS Messages account; unsupported accountId: ${normalized}`);
98
- }
99
- return normalized;
100
- }
101
- function normalizeConnectorLimit(limit, fallback = 50) {
102
- if (!Number.isFinite(limit) || !limit || limit <= 0) {
103
- return fallback;
104
- }
105
- return Math.min(Math.floor(limit), 200);
106
- }
107
- function filterMemoriesByQuery(memories, query, limit) {
108
- const normalized = query.trim().toLowerCase();
109
- if (!normalized) {
110
- return memories.slice(0, limit);
111
- }
112
- return memories
113
- .filter((memory) => {
114
- const text = typeof memory.content?.text === "string" ? memory.content.text : "";
115
- return text.toLowerCase().includes(normalized);
116
- })
117
- .slice(0, limit);
118
- }
119
- function publicIMessageToMemory(runtime, message, roomId, accountId = IMESSAGE_LOCAL_ACCOUNT_ID) {
120
- const normalizedAccountId = assertLocalIMessageAccount(accountId);
121
- const handle = normalizeIMessageConnectorHandle(message.handle ?? "");
122
- const entityId = message.isFromMe
123
- ? runtime.agentId
124
- : createUniqueUuid(runtime, handle || message.chatId || message.id);
125
- const channelType = message.chatId?.includes(";+;") ? ChannelType.GROUP : ChannelType.DM;
126
- return {
127
- id: createUniqueUuid(runtime, `imessage-public:${message.id}`),
128
- entityId,
129
- agentId: runtime.agentId,
130
- roomId,
131
- createdAt: message.timestamp,
132
- content: {
133
- text: message.text,
134
- source: "imessage",
135
- channelType,
136
- ...(message.attachmentPaths?.length
137
- ? {
138
- attachments: message.attachmentPaths.map((path) => ({
139
- id: path,
140
- url: path,
141
- title: path.split("/").pop() ?? path,
142
- source: "imessage",
143
- description: "iMessage attachment",
144
- })),
145
- }
146
- : {}),
147
- },
148
- metadata: {
149
- type: MemoryType.MESSAGE,
150
- source: "imessage",
151
- provider: "imessage",
152
- accountId: normalizedAccountId,
153
- timestamp: message.timestamp,
154
- entityUserName: handle || undefined,
155
- fromBot: message.isFromMe,
156
- fromId: message.isFromMe ? runtime.agentId : handle,
157
- sourceId: entityId,
158
- chatType: channelType,
159
- messageIdFull: message.id,
160
- sender: {
161
- id: message.isFromMe ? runtime.agentId : handle,
162
- username: handle || undefined,
163
- },
164
- imessage: {
165
- accountId: normalizedAccountId,
166
- id: handle,
167
- userId: handle,
168
- username: handle,
169
- chatId: message.chatId,
170
- rowId: message.id,
171
- },
172
- },
173
- };
174
- }
175
- function contactKind(handle) {
176
- if (isEmail(handle))
177
- return "email";
178
- if (isPhoneNumber(handle))
179
- return "phone";
180
- return "contact";
181
- }
182
- function contactTarget(handle, label, score) {
183
- const normalized = normalizeIMessageConnectorHandle(handle);
184
- const displayLabel = label ? `${label} (${normalized})` : normalized;
185
- return {
186
- target: targetWithAccount({
187
- source: "imessage",
188
- channelId: normalized,
189
- entityId: normalized,
190
- }, IMESSAGE_LOCAL_ACCOUNT_ID),
191
- label: displayLabel,
192
- kind: contactKind(normalized),
193
- score,
194
- metadata: {
195
- accountId: IMESSAGE_LOCAL_ACCOUNT_ID,
196
- handle: normalized,
197
- contactName: label,
198
- },
199
- };
200
- }
201
- function chatTarget(chat, contacts) {
202
- const participants = chat.participants.map((participant) => normalizeIMessageConnectorHandle(participant.handle));
203
- const primaryHandle = participants[0];
204
- const isGroup = chat.chatType === "group";
205
- const contactName = primaryHandle
206
- ? contacts.get(normalizeContactHandle(primaryHandle))?.name
207
- : undefined;
208
- const label = chat.displayName ??
209
- contactName ??
210
- (isGroup ? participants.filter(Boolean).join(", ") : primaryHandle) ??
211
- chat.chatId;
212
- const target = targetWithAccount({
213
- source: "imessage",
214
- channelId: isGroup ? `chat_id:${chat.chatId}` : (primaryHandle ?? `chat_id:${chat.chatId}`),
215
- }, IMESSAGE_LOCAL_ACCOUNT_ID);
216
- if (!isGroup && primaryHandle) {
217
- target.entityId = primaryHandle;
218
- }
219
- return {
220
- target,
221
- label,
222
- kind: isGroup ? "group" : contactKind(primaryHandle ?? chat.chatId),
223
- description: isGroup ? "iMessage group chat" : "iMessage direct chat",
224
- score: isGroup ? 0.76 : 0.72,
225
- metadata: {
226
- accountId: IMESSAGE_LOCAL_ACCOUNT_ID,
227
- chatId: chat.chatId,
228
- chatType: chat.chatType,
229
- participants: participants.filter(Boolean).join(", "),
230
- },
231
- };
232
- }
233
- async function resolveIMessageSendTarget(runtime, target) {
234
- assertLocalIMessageAccount(readTargetAccountId(target));
235
- if (target.channelId?.trim()) {
236
- return normalizeIMessageConnectorHandle(target.channelId);
237
- }
238
- if (target.entityId?.trim()) {
239
- return normalizeIMessageConnectorHandle(target.entityId);
240
- }
241
- if (target.roomId) {
242
- const room = await runtime.getRoom(target.roomId);
243
- if (room?.channelId) {
244
- return normalizeIMessageConnectorHandle(room.channelId);
245
- }
246
- }
247
- return null;
248
- }
249
- async function resolveIMessageChatId(runtime, target) {
250
- assertLocalIMessageAccount(readTargetAccountId(target));
251
- const channelId = target.channelId ??
252
- (target.roomId ? (await runtime.getRoom(target.roomId))?.channelId : undefined);
253
- if (!channelId)
254
- return null;
255
- return channelId.startsWith("chat_id:") ? channelId.slice("chat_id:".length) : channelId;
256
- }
257
- /**
258
- * iMessage service for Eliza agents.
259
- * Note: This only works on macOS.
260
- */
261
- export class IMessageService extends Service {
262
- static serviceType = IMESSAGE_SERVICE_NAME;
263
- capabilityDescription = "iMessage service for sending and receiving messages on macOS";
264
- settings = null;
265
- connected = false;
266
- pollInterval = null;
267
- /**
268
- * Highest `message.ROWID` we've already dispatched to the agent. The
269
- * polling loop asks chat.db for rows strictly greater than this value,
270
- * then advances the cursor to the largest row it actually processed.
271
- * Initialized to 0 on start; bumped on every successful dispatch so
272
- * we skip backlog on a fresh launch without ever double-delivering.
273
- */
274
- lastRowId = 0;
275
- /**
276
- * Reentrancy gate for the polling loop. Dispatch calls
277
- * `messageService.handleMessage` which invokes the LLM and typically
278
- * takes several seconds per message. With a 2-second poll interval
279
- * that means multiple ticks can land on top of each other, each
280
- * seeing the same stale cursor and re-dispatching the same rows.
281
- * This flag ensures at most one poll is in flight at a time; ticks
282
- * that arrive while a poll is active are dropped (the next scheduled
283
- * tick will pick up where the current one left off).
284
- */
285
- pollInFlight = false;
286
- /**
287
- * Room keys we've already emitted a WORLD_JOINED event for in this
288
- * service lifetime. Not persisted — on restart every room is
289
- * re-greeted, which is correct: a fresh Eliza process doesn't know
290
- * what previous processes already synced, and the bootstrap plugin's
291
- * WORLD_JOINED handler is idempotent (handleServerSync tolerates
292
- * already-known rooms via upsert semantics).
293
- */
294
- seenWorlds = new Set();
295
- /**
296
- * Entity keys we've already emitted an ENTITY_JOINED event for.
297
- * Same lifetime + rationale as seenWorlds.
298
- */
299
- seenEntities = new Set();
300
- /**
301
- * Live chat.db handle, bound to the lifetime of this service. Non-null
302
- * means inbound polling is active. Null means either (a) not running
303
- * under Bun, or (b) chat.db couldn't be opened — in both cases the
304
- * service remains send-only and logs a one-time warning on start.
305
- */
306
- chatDb = null;
307
- chatDbPath = DEFAULT_CHAT_DB_PATH;
308
- /**
309
- * Cached handle → display name map from the user's Apple Contacts.
310
- * Populated lazily on first inbound message via AppleScript against
311
- * Contacts.app, NOT at service start. Loading at boot would trigger
312
- * the macOS Contacts TCC dialog at app launch, even though the user
313
- * may never receive an inbound iMessage. We defer the AppleScript
314
- * call (and its TCC prompt) until the first message that actually
315
- * needs handle→name resolution. Empty map means either the user
316
- * hasn't authorized Contacts access yet, the address book is empty,
317
- * or no inbound message has triggered the lazy load yet.
318
- */
319
- contacts = new Map();
320
- /** Whether the lazy contact load has been attempted this session. */
321
- contactsLoadAttempted = false;
322
- /**
323
- * Start the iMessage service.
324
- */
325
- static async start(runtime) {
326
- logger.info("Starting iMessage service...");
327
- const service = new IMessageService(runtime);
328
- // Check if running on macOS
329
- if (!service.isMacOS()) {
330
- throw new IMessageNotSupportedError();
331
- }
332
- // Load settings
333
- service.settings = service.loadSettings();
334
- await service.validateSettings();
335
- // Open chat.db for inbound polling. A null return here is non-fatal —
336
- // the service degrades to send-only and logs its own warning. We seed
337
- // the polling cursor from the current tip of the database so a freshly
338
- // started agent doesn't re-process its entire message backlog.
339
- service.chatDbPath = service.settings.dbPath || DEFAULT_CHAT_DB_PATH;
340
- service.chatDb = await openChatDb(service.chatDbPath);
341
- if (service.chatDb) {
342
- const tip = service.chatDb.getLatestRowId();
343
- // Resolve IMESSAGE_BACKFILL from every plausible source — character
344
- // settings (runtime.getSetting), the raw process env, and the
345
- // character's settings object. Whichever arrives first wins.
346
- const settingFromRuntime = typeof service.runtime?.getSetting === "function"
347
- ? service.runtime.getSetting("IMESSAGE_BACKFILL")
348
- : undefined;
349
- const settingFromEnv = process.env.IMESSAGE_BACKFILL;
350
- const resolvedRaw = (typeof settingFromRuntime === "string" && settingFromRuntime) || settingFromEnv || "";
351
- const backfill = Math.max(0, Number(resolvedRaw) || 0);
352
- service.lastRowId = Math.max(0, tip - backfill);
353
- logger.debug(`[imessage][boot] dbPath=${service.chatDbPath} tip=${tip} backfillRaw=${JSON.stringify(resolvedRaw)} backfillResolved=${backfill} lastRowId=${service.lastRowId}`);
354
- logger.info(`[imessage] chat.db opened, inbound polling ready (cursor starts at ROWID ${service.lastRowId}, backfilled ${backfill} from tip ${tip})`);
355
- }
356
- // NOTE: We intentionally do NOT call loadContacts() here. Loading
357
- // contacts at service start runs an AppleScript against Contacts.app,
358
- // which triggers the macOS Contacts TCC dialog the first time. App
359
- // launch must not trigger TCC dialogs implicitly — the dialog is
360
- // deferred to the first inbound message that actually needs name
361
- // resolution (see ensureContactsLoaded() below). Outbound-only users
362
- // never see the prompt.
363
- // Start polling only when chat.db is available. When the database cannot
364
- // be opened, the service is intentionally send-only until the next start.
365
- if (service.chatDb && service.settings.pollIntervalMs > 0) {
366
- service.startPolling();
367
- }
368
- else if (!service.chatDb && service.settings.pollIntervalMs > 0) {
369
- logger.debug("[imessage] inbound polling not started because chat.db is unavailable");
370
- }
371
- // Register the heartbeat task worker + create a recurring task.
372
- // See registerHeartbeat for what it actually does. We gate on the
373
- // existence of runtime.registerTaskWorker to stay compatible with
374
- // older cores that predate the task system.
375
- await service.registerHeartbeat();
376
- service.connected = true;
377
- logger.info("iMessage service started");
378
- // Emit connection ready event
379
- runtime.emitEvent(IMessageEventTypes.CONNECTION_READY, {
380
- runtime,
381
- service,
382
- });
383
- return service;
384
- }
385
- static registerSendHandlers(runtime, service) {
386
- const registration = {
387
- source: IMESSAGE_SERVICE_NAME,
388
- label: "iMessage",
389
- capabilities: ["send_message", "attachments", "contact_resolution", "chat_context"],
390
- supportedTargetKinds: ["phone", "email", "contact", "user", "group", "room"],
391
- contexts: ["phone", "social", "connectors"],
392
- description: "Send SMS/iMessage through macOS Messages using phone numbers, emails, contacts, or chat ids.",
393
- metadata: {
394
- aliases: ["imessage", "sms", "text", "messages"],
395
- accountId: IMESSAGE_LOCAL_ACCOUNT_ID,
396
- bridge: "macos-messages",
397
- accountSemantics: "local-macos-messages-single-account",
398
- status: statusMetadata(service.getStatus()),
399
- },
400
- sendHandler: async (_runtime, target, content) => {
401
- const accountId = assertLocalIMessageAccount(readTargetAccountId(target));
402
- const text = typeof content.text === "string" ? content.text : "";
403
- const mediaUrl = firstAttachmentUrl(content);
404
- if (!text.trim() && !mediaUrl) {
405
- return;
406
- }
407
- const resolvedTarget = await resolveIMessageSendTarget(runtime, target);
408
- if (!resolvedTarget) {
409
- throw new Error("iMessage target is missing a phone, email, or chat id");
410
- }
411
- const result = await service.sendMessage(resolvedTarget, text, mediaUrl ? { mediaUrl, accountId } : { accountId });
412
- if (!result.success) {
413
- throw new Error(result.error ?? "iMessage send failed");
414
- }
415
- },
416
- resolveTargets: async (query) => {
417
- const candidates = [];
418
- const contacts = service.getContacts();
419
- for (const [handle, contact] of contacts) {
420
- if (matchesQuery(query, contact.name, handle)) {
421
- candidates.push(contactTarget(handle, contact.name, contact.name.toLowerCase() === query.toLowerCase() ? 0.9 : 0.82));
422
- }
423
- }
424
- const normalized = normalizeIMessageConnectorHandle(query);
425
- if (normalized && (isValidIMessageTarget(normalized) || isEmail(normalized))) {
426
- candidates.push(contactTarget(normalized, contacts.get(normalizeContactHandle(normalized))?.name, 0.8));
427
- }
428
- const chats = await service.getChats();
429
- for (const chat of chats) {
430
- const candidate = chatTarget(chat, contacts);
431
- if (matchesQuery(query, candidate.label, chat.chatId, chat.displayName, ...chat.participants.map((participant) => participant.handle))) {
432
- candidates.push({ ...candidate, score: Math.max(candidate.score ?? 0, 0.78) });
433
- }
434
- }
435
- return candidates;
436
- },
437
- listRecentTargets: async () => {
438
- const contacts = service.getContacts();
439
- const byKey = new Map();
440
- for (const message of await service.getRecentMessages(50)) {
441
- const handle = normalizeIMessageConnectorHandle(message.handle);
442
- const target = message.chatId
443
- ? {
444
- target: targetWithAccount({
445
- source: "imessage",
446
- channelId: message.chatId.startsWith("chat_id:")
447
- ? message.chatId
448
- : `chat_id:${message.chatId}`,
449
- entityId: handle ? handle : undefined,
450
- }, IMESSAGE_LOCAL_ACCOUNT_ID),
451
- label: contacts.get(normalizeContactHandle(handle))?.name ??
452
- message.handle ??
453
- message.chatId,
454
- kind: message.chatId?.includes(";+;") ? "group" : contactKind(handle),
455
- score: 0.68,
456
- metadata: {
457
- accountId: IMESSAGE_LOCAL_ACCOUNT_ID,
458
- handle,
459
- chatId: message.chatId,
460
- lastMessageAt: message.timestamp,
461
- },
462
- }
463
- : contactTarget(handle, contacts.get(normalizeContactHandle(handle))?.name, 0.66);
464
- byKey.set(`${target.target.channelId ?? ""}|${target.target.entityId ?? ""}`, target);
465
- }
466
- return Array.from(byKey.values());
467
- },
468
- listRooms: async () => {
469
- const contacts = service.getContacts();
470
- return (await service.getChats()).map((chat) => chatTarget(chat, contacts));
471
- },
472
- fetchMessages: async (context, params) => {
473
- const limit = normalizeConnectorLimit(params?.limit);
474
- const target = params?.target ?? context.target;
475
- const accountId = assertLocalIMessageAccount(readTargetAccountId(target) ?? readContextAccountId(context));
476
- const chatId = target ? await resolveIMessageChatId(context.runtime, target) : null;
477
- const platformMessages = await service
478
- .getMessages({ ...(chatId ? { chatId } : {}), limit })
479
- .catch(() => []);
480
- if (platformMessages.length > 0) {
481
- const roomId = target?.roomId ??
482
- createUniqueUuid(context.runtime, `imessage-read:${chatId ?? "recent"}`);
483
- return platformMessages
484
- .map((message) => publicIMessageToMemory(context.runtime, message, roomId, accountId))
485
- .sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0))
486
- .slice(0, limit);
487
- }
488
- if (target?.roomId) {
489
- return context.runtime.getMemories({
490
- tableName: "messages",
491
- roomId: target.roomId,
492
- limit,
493
- orderBy: "createdAt",
494
- orderDirection: "desc",
495
- });
496
- }
497
- return [];
498
- },
499
- searchMessages: async (context, params) => {
500
- const limit = normalizeConnectorLimit(params?.limit);
501
- const target = params?.target ?? context.target;
502
- const accountId = assertLocalIMessageAccount(readTargetAccountId(target) ?? readContextAccountId(context));
503
- const chatId = target ? await resolveIMessageChatId(context.runtime, target) : null;
504
- const platformMessages = await service
505
- .getMessages({ ...(chatId ? { chatId } : {}), limit: Math.max(limit, 100) })
506
- .catch(() => []);
507
- const roomId = target?.roomId ??
508
- createUniqueUuid(context.runtime, `imessage-read:${chatId ?? "recent"}`);
509
- const memories = platformMessages.length > 0
510
- ? platformMessages.map((message) => publicIMessageToMemory(context.runtime, message, roomId, accountId))
511
- : target?.roomId
512
- ? await context.runtime.getMemories({
513
- tableName: "messages",
514
- roomId: target.roomId,
515
- limit: Math.max(limit, 100),
516
- orderBy: "createdAt",
517
- orderDirection: "desc",
518
- })
519
- : [];
520
- return filterMemoriesByQuery(memories, params.query, limit);
521
- },
522
- getChatContext: async (target, context) => {
523
- const accountId = assertLocalIMessageAccount(readTargetAccountId(target) ?? readContextAccountId(context));
524
- const chatId = await resolveIMessageChatId(context.runtime, target);
525
- const messages = chatId ? await service.getMessages({ chatId, limit: 10 }) : [];
526
- return {
527
- target: targetWithAccount(target, accountId),
528
- label: chatId ?? target.channelId ?? target.entityId ?? "iMessage target",
529
- summary: service.getStatus().chatDbAvailable
530
- ? "iMessage chat context from local Messages database."
531
- : "iMessage is available in send-only mode; chat database context is unavailable.",
532
- recentMessages: messages.map((message) => ({
533
- name: service.getContacts().get(normalizeContactHandle(message.handle))?.name ??
534
- message.handle,
535
- text: message.text,
536
- timestamp: message.timestamp,
537
- metadata: {
538
- accountId,
539
- messageId: message.id,
540
- handle: normalizeIMessageConnectorHandle(message.handle),
541
- isFromMe: message.isFromMe,
542
- },
543
- })),
544
- metadata: {
545
- accountId,
546
- chatId,
547
- status: statusMetadata(service.getStatus()),
548
- },
549
- };
550
- },
551
- getUserContext: async (entityId) => {
552
- const handle = normalizeIMessageConnectorHandle(String(entityId));
553
- if (!handle)
554
- return null;
555
- const contact = service.getContacts().get(normalizeContactHandle(handle));
556
- return {
557
- entityId,
558
- label: contact?.name ?? handle,
559
- aliases: contact?.name ? [contact.name, handle] : [handle],
560
- handles: {
561
- imessage: handle,
562
- ...(isEmail(handle) ? { email: handle } : { phone: handle }),
563
- },
564
- metadata: {
565
- accountId: IMESSAGE_LOCAL_ACCOUNT_ID,
566
- normalizedHandle: handle,
567
- },
568
- };
569
- },
570
- getUser: async (_handlerRuntime, params) => {
571
- const lookupParams = params;
572
- const handle = normalizeIMessageConnectorHandle(String(lookupParams.entityId ??
573
- lookupParams.userId ??
574
- lookupParams.username ??
575
- lookupParams.handle ??
576
- ""));
577
- if (!handle)
578
- return null;
579
- const contact = service.getContacts().get(normalizeContactHandle(handle));
580
- return {
581
- id: createUniqueUuid(_handlerRuntime, `imessage:${handle}`),
582
- names: contact?.name ? [contact.name, handle] : [handle],
583
- agentId: _handlerRuntime.agentId,
584
- metadata: {
585
- accountId: IMESSAGE_LOCAL_ACCOUNT_ID,
586
- normalizedHandle: handle,
587
- imessage: handle,
588
- ...(isEmail(handle) ? { email: handle } : { phone: handle }),
589
- },
590
- };
591
- },
592
- };
593
- registerMessageConnectorIfAvailable(runtime, registration);
594
- }
595
- /**
596
- * Stop the iMessage service.
597
- */
598
- async stop() {
599
- logger.info("Stopping iMessage service...");
600
- this.connected = false;
601
- if (this.pollInterval) {
602
- clearInterval(this.pollInterval);
603
- this.pollInterval = null;
604
- }
605
- if (this.chatDb) {
606
- this.chatDb.close();
607
- this.chatDb = null;
608
- }
609
- this.settings = null;
610
- this.lastRowId = 0;
611
- logger.info("iMessage service stopped");
612
- }
613
- /**
614
- * Check if the service is connected.
615
- */
616
- isConnected() {
617
- return this.connected;
618
- }
619
- getStatus() {
620
- const chatDbAvailable = this.chatDb !== null;
621
- const accessIssue = chatDbAvailable ? null : getLastChatDbAccessIssue(this.chatDbPath);
622
- return {
623
- available: true,
624
- connected: this.connected,
625
- chatDbAvailable,
626
- sendOnly: this.connected && !chatDbAvailable,
627
- chatDbPath: this.chatDbPath,
628
- reason: accessIssue?.reason ?? (chatDbAvailable ? null : "chat.db reader not available"),
629
- permissionAction: accessIssue?.permissionAction ?? null,
630
- };
631
- }
632
- /**
633
- * Check if running on macOS.
634
- */
635
- isMacOS() {
636
- return platform() === "darwin";
637
- }
638
- /**
639
- * Send a message via iMessage.
640
- */
641
- async sendMessage(to, text, options) {
642
- const accountId = assertLocalIMessageAccount(options?.accountId);
643
- if (!this.settings) {
644
- return { success: false, error: "Service not initialized" };
645
- }
646
- // Format phone number if needed
647
- const target = isPhoneNumber(to) ? formatPhoneNumber(to) : to;
648
- // Split message if too long
649
- const chunks = splitMessageForIMessage(text);
650
- for (const chunk of chunks) {
651
- const result = await this.sendSingleMessage(target, chunk, options);
652
- if (!result.success) {
653
- return result;
654
- }
655
- }
656
- // Emit sent events — both the plugin-namespaced form (for iMessage-
657
- // specific listeners) and the generic core EventType.MESSAGE_SENT
658
- // so trajectory loggers, analytics, and any plugin hooking into
659
- // the standard event bus see outbound iMessage sends the same way
660
- // they see Telegram / Discord / Slack sends.
661
- if (this.runtime) {
662
- this.runtime.emitEvent(IMessageEventTypes.MESSAGE_SENT, {
663
- runtime: this.runtime,
664
- source: "imessage",
665
- accountId,
666
- to: target,
667
- text,
668
- hasMedia: Boolean(options?.mediaUrl),
669
- });
670
- this.runtime.emitEvent(EventType.MESSAGE_SENT, {
671
- runtime: this.runtime,
672
- source: "imessage",
673
- message: {
674
- id: createUniqueUuid(this.runtime, `imessage-outbound-${Date.now()}`),
675
- entityId: this.runtime.agentId,
676
- agentId: this.runtime.agentId,
677
- roomId: createUniqueUuid(this.runtime, target),
678
- content: { text, source: "imessage" },
679
- metadata: {
680
- accountId,
681
- source: "imessage",
682
- provider: "imessage",
683
- imessage: {
684
- accountId,
685
- chatId: target,
686
- },
687
- },
688
- createdAt: Date.now(),
689
- },
690
- });
691
- }
692
- return {
693
- success: true,
694
- messageId: Date.now().toString(),
695
- chatId: target,
696
- };
697
- }
698
- /**
699
- * Get recent messages by reading chat.db. Returns the most recent
700
- * `limit` messages (any sender, any chat) in chronological order.
701
- *
702
- * Returns an empty array if the chat.db reader is unavailable (plugin
703
- * running under plain Node without bun:sqlite, or Full Disk Access not
704
- * granted, etc.).
705
- */
706
- async getRecentMessages(limit = 50) {
707
- return this.getMessages({ limit });
708
- }
709
- /**
710
- * Return the newest messages in chronological order, optionally scoped
711
- * to a single chat identifier. Returns an empty array if chat.db is not
712
- * available and the connector is currently running in send-only mode.
713
- */
714
- async getMessages(options = {}) {
715
- if (!this.chatDb) {
716
- return [];
717
- }
718
- const rows = this.chatDb.listMessages(options);
719
- return rows.map(chatDbMessageToPublicShape);
720
- }
721
- /**
722
- * List every chat the Messages.app database knows about, joined with
723
- * participant handles. Returns an empty list if the chat.db reader is
724
- * unavailable (Node runtime, missing FDA, etc.).
725
- *
726
- * Previously this method used an AppleScript stub against
727
- * Messages.app's `chats` collection. That verb works but is slow and
728
- * returns a coarser view (no participant handles, no style field), so
729
- * the chat.db path is strictly better when it's available.
730
- */
731
- async getChats() {
732
- if (!this.chatDb) {
733
- return [];
734
- }
735
- // Convert the reader's richer ChatDbChatSummary into the plugin's
736
- // public IMessageChat shape for backwards-compat with consumers of
737
- // the existing IIMessageService interface. Callers that want the
738
- // richer fields (serviceName, last read timestamp) can go straight
739
- // to the reader via `chatDb.listChats()`.
740
- return this.chatDb.listChats().map((c) => ({
741
- chatId: c.chatId,
742
- chatType: c.chatType,
743
- displayName: c.displayName ?? undefined,
744
- participants: c.participants.map((handle) => ({
745
- handle,
746
- isPhoneNumber: /^\+?\d{7,}$/.test(handle),
747
- })),
748
- }));
749
- }
750
- /**
751
- * Get current settings.
752
- */
753
- getSettings() {
754
- return this.settings;
755
- }
756
- /**
757
- * Get the cached Apple Contacts map loaded at service start.
758
- *
759
- * Keys are normalized handles (phones in digits-only + optional leading `+`,
760
- * emails lowercased). Values carry the contact's display name.
761
- *
762
- * Exposed for providers that want to inject contact lookups into agent
763
- * state so the LLM can resolve a person's name ("text Shaw") to a handle
764
- * it can pass to `sendMessage`.
765
- *
766
- * Returns an empty map if Contacts access was denied, failed to load,
767
- * or the service hasn't finished starting.
768
- */
769
- getContacts() {
770
- return this.contacts;
771
- }
772
- /**
773
- * Lazy-load the Apple Contacts map on first call. Subsequent calls
774
- * are no-ops. We split this out from `start()` so the macOS Contacts
775
- * TCC dialog only fires when the runtime actually needs handle→name
776
- * resolution — typically the first inbound message — instead of at
777
- * app launch. Failure is non-fatal; the cached map stays empty and
778
- * the service falls back to raw handles.
779
- */
780
- async ensureContactsLoaded() {
781
- if (this.contactsLoadAttempted) {
782
- return;
783
- }
784
- this.contactsLoadAttempted = true;
785
- try {
786
- this.contacts = await loadContacts();
787
- }
788
- catch (err) {
789
- logger.warn(`[imessage] Lazy contact load failed: ${err instanceof Error ? err.message : String(err)}`);
790
- }
791
- }
792
- /**
793
- * List every contact in the user's address book as a full record with
794
- * id, name, and all phones/emails. Delegates to contacts-reader's
795
- * `listAllContacts` which goes through Contacts.app's AppleScript
796
- * dump. Returns `[]` on failure (permission denied, etc.).
797
- */
798
- async listAllContacts() {
799
- return listAllContacts();
800
- }
801
- /**
802
- * Create a new contact in Contacts.app. Requires Contacts WRITE
803
- * permission (macOS prompts on first call). Returns the new person's
804
- * id on success, or null on failure.
805
- *
806
- * After a successful create we refresh the cached handle→name map so
807
- * inbound messages from the new contact resolve to their name on the
808
- * very next poll, without requiring a service restart.
809
- */
810
- async addContact(input) {
811
- const id = await addContact(input);
812
- if (id) {
813
- this.contacts = await loadContacts();
814
- }
815
- return id;
816
- }
817
- /**
818
- * Patch an existing contact (name fields, add/remove phones, add/remove
819
- * emails). Returns true on success, false on failure. Refreshes the
820
- * cached map on success so name resolution reflects the change.
821
- */
822
- async updateContact(personId, patch) {
823
- const ok = await updateContact(personId, patch);
824
- if (ok) {
825
- this.contacts = await loadContacts();
826
- }
827
- return ok;
828
- }
829
- /**
830
- * Delete a contact by Contacts.app id. Returns true on success,
831
- * false on failure. Refreshes the cached map on success.
832
- */
833
- async deleteContact(personId) {
834
- const ok = await deleteContact(personId);
835
- if (ok) {
836
- this.contacts = await loadContacts();
837
- }
838
- return ok;
839
- }
840
- // Private methods
841
- loadSettings() {
842
- if (!this.runtime) {
843
- throw new IMessageConfigurationError("Runtime not initialized");
844
- }
845
- const getStringSetting = (key, envKey, defaultValue = "") => {
846
- const value = this.runtime?.getSetting(key);
847
- if (typeof value === "string")
848
- return value;
849
- return process.env[envKey] || defaultValue;
850
- };
851
- const cliPath = getStringSetting("IMESSAGE_CLI_PATH", "IMESSAGE_CLI_PATH", "imsg");
852
- const dbPath = getStringSetting("IMESSAGE_DB_PATH", "IMESSAGE_DB_PATH") || undefined;
853
- const pollIntervalRaw = getStringSetting("IMESSAGE_POLL_INTERVAL_MS", "IMESSAGE_POLL_INTERVAL_MS");
854
- const parsedPollIntervalMs = Number(pollIntervalRaw);
855
- const pollIntervalMs = pollIntervalRaw.trim() === "" || !Number.isFinite(parsedPollIntervalMs)
856
- ? DEFAULT_POLL_INTERVAL_MS
857
- : Math.max(0, parsedPollIntervalMs);
858
- const dmPolicy = getStringSetting("IMESSAGE_DM_POLICY", "IMESSAGE_DM_POLICY", "pairing");
859
- const groupPolicy = getStringSetting("IMESSAGE_GROUP_POLICY", "IMESSAGE_GROUP_POLICY", "allowlist");
860
- const allowFromRaw = getStringSetting("IMESSAGE_ALLOW_FROM", "IMESSAGE_ALLOW_FROM");
861
- const allowFrom = allowFromRaw
862
- ? allowFromRaw
863
- .split(",")
864
- .map((s) => s.trim())
865
- .filter(Boolean)
866
- : [];
867
- const enabledRaw = getStringSetting("IMESSAGE_ENABLED", "IMESSAGE_ENABLED", "true");
868
- const enabled = enabledRaw !== "false";
869
- return {
870
- cliPath,
871
- dbPath,
872
- pollIntervalMs,
873
- dmPolicy,
874
- groupPolicy,
875
- allowFrom,
876
- enabled,
877
- };
878
- }
879
- async validateSettings() {
880
- if (!this.settings) {
881
- throw new IMessageConfigurationError("Settings not loaded");
882
- }
883
- // Check if CLI tool exists (if specified and not default)
884
- if (this.settings.cliPath !== "imsg") {
885
- if (!existsSync(this.settings.cliPath)) {
886
- logger.warn(`iMessage CLI not found at ${this.settings.cliPath}, will use AppleScript`);
887
- }
888
- }
889
- // Check if Messages app is accessible
890
- try {
891
- await this.runAppleScript('tell application "Messages" to return 1');
892
- }
893
- catch (_error) {
894
- throw new IMessageConfigurationError("Cannot access Messages app. Ensure Full Disk Access is granted.");
895
- }
896
- }
897
- async sendSingleMessage(to, text, options) {
898
- // Try CLI first if available
899
- if (this.settings?.cliPath && this.settings.cliPath !== "imsg") {
900
- try {
901
- return await this.sendViaCli(to, text, options);
902
- }
903
- catch (error) {
904
- logger.debug(`CLI send failed, falling back to AppleScript: ${error}`);
905
- }
906
- }
907
- // Fall back to AppleScript
908
- return await this.sendViaAppleScript(to, text, options);
909
- }
910
- async sendViaCli(to, text, options) {
911
- if (!this.settings) {
912
- return { success: false, error: "Service not initialized" };
913
- }
914
- const args = [to, text];
915
- if (options?.mediaUrl) {
916
- args.push("--attachment", options.mediaUrl);
917
- }
918
- try {
919
- await execFileAsync(this.settings.cliPath, args);
920
- return { success: true, messageId: Date.now().toString(), chatId: to };
921
- }
922
- catch (error) {
923
- const err = error;
924
- throw new IMessageCliError(err.message || "CLI command failed", err.code);
925
- }
926
- }
927
- async sendViaAppleScript(to, text, options) {
928
- const isChatTarget = to.startsWith("chat_id:");
929
- const chatId = isChatTarget ? to.slice(8) : null;
930
- const targetLiteral = appleScriptStringLiteral(chatId ?? to);
931
- // Build the `set target...` clause once — used by both the text
932
- // send and the (optional) attachment send below.
933
- const targetBlock = isChatTarget
934
- ? `set targetRef to chat id ${targetLiteral}`
935
- : `
936
- set targetService to 1st account whose service type = iMessage
937
- set targetRef to participant ${targetLiteral} of targetService
938
- `;
939
- // Text body (possibly empty if caller is attachment-only).
940
- if (text && text.length > 0) {
941
- const textScript = `
942
- tell application "Messages"
943
- ${targetBlock}
944
- send ${appleScriptStringLiteral(text)} to targetRef
945
- end tell
946
- `;
947
- try {
948
- await this.runAppleScript(textScript);
949
- }
950
- catch (error) {
951
- return { success: false, error: `AppleScript error: ${error}` };
952
- }
953
- }
954
- // Attachment, if any. Messages.app's scripting dictionary accepts a
955
- // file as the direct-parameter of `send`, so we resolve the media
956
- // path to a POSIX file and hand it to the same `send` verb. Works
957
- // for images, video, audio, PDFs, anything Messages.app would let
958
- // you drag into the compose area.
959
- if (options?.mediaUrl) {
960
- const mediaPath = options.mediaUrl.startsWith("file://")
961
- ? options.mediaUrl.slice(7)
962
- : options.mediaUrl;
963
- const attachmentScript = `
964
- tell application "Messages"
965
- ${targetBlock}
966
- send (POSIX file ${appleScriptStringLiteral(mediaPath)}) to targetRef
967
- end tell
968
- `;
969
- try {
970
- await this.runAppleScript(attachmentScript);
971
- }
972
- catch (error) {
973
- return {
974
- success: false,
975
- error: `AppleScript attachment error: ${error}`,
976
- };
977
- }
978
- }
979
- return { success: true, messageId: Date.now().toString(), chatId: to };
980
- }
981
- async runAppleScript(script) {
982
- try {
983
- const { stdout } = await execFileAsync("osascript", ["-e", script]);
984
- return stdout.trim();
985
- }
986
- catch (error) {
987
- const err = error;
988
- throw new Error(err.stderr || err.message || "AppleScript execution failed");
989
- }
990
- }
991
- startPolling() {
992
- if (!this.settings) {
993
- return;
994
- }
995
- this.pollInterval = setInterval(async () => {
996
- try {
997
- await this.pollForNewMessages();
998
- }
999
- catch (error) {
1000
- logger.debug(`Polling error: ${error}`);
1001
- }
1002
- }, this.settings.pollIntervalMs);
1003
- }
1004
- /**
1005
- * Poll chat.db for rows newer than the cursor and route each inbound
1006
- * message through the agent's message pipeline.
1007
- *
1008
- * Flow per message:
1009
- * 1. Read new rows from chat.db via the bun:sqlite reader.
1010
- * 2. Skip outbound (is_from_me=1), already-seen, and policy-denied rows.
1011
- * 3. Build a Memory object in the shape the bootstrap plugin expects.
1012
- * 4. Ensure the entity + room + world exist via ensureConnection.
1013
- * 5. Call runtime.messageService.handleMessage with a callback that
1014
- * sends the agent's reply back through sendViaAppleScript.
1015
- * 6. Also emit the plugin-namespaced IMESSAGE_MESSAGE_RECEIVED event
1016
- * and the core EventType.MESSAGE_RECEIVED event for any listeners.
1017
- *
1018
- * Advances this.lastRowId unconditionally to the max rowId we saw in
1019
- * this batch — even for skipped rows — so the cursor keeps moving
1020
- * forward and we never get stuck re-reading the same row on every poll.
1021
- */
1022
- async pollForNewMessages() {
1023
- if (!this.runtime || !this.chatDb) {
1024
- return;
1025
- }
1026
- // Reentrancy gate — see field-level comment on pollInFlight.
1027
- if (this.pollInFlight) {
1028
- return;
1029
- }
1030
- this.pollInFlight = true;
1031
- try {
1032
- await this.pollForNewMessagesInner();
1033
- }
1034
- finally {
1035
- this.pollInFlight = false;
1036
- }
1037
- }
1038
- async pollForNewMessagesInner() {
1039
- if (!this.runtime || !this.chatDb)
1040
- return;
1041
- // Cap batch size to 50 to keep per-tick latency bounded on busy
1042
- // inboxes. If traffic exceeds that, subsequent ticks catch up.
1043
- const batch = this.chatDb.fetchNewMessages(this.lastRowId, 50);
1044
- if (batch.length === 0) {
1045
- return;
1046
- }
1047
- // CRITICAL: advance the cursor synchronously, BEFORE any async
1048
- // dispatch work. If we advance after the for loop, a slow dispatch
1049
- // gives setInterval time to fire the next tick, which would re-read
1050
- // the stale cursor and re-dispatch the same rows. Since chat.db's
1051
- // ROWID is monotonic, we know every row in `batch` is unique and
1052
- // each has rowId > sinceRowId, so advancing to the max NOW is safe
1053
- // even if individual dispatches fail later.
1054
- const maxRowIdInBatch = batch.reduce((max, row) => (row.rowId > max ? row.rowId : max), this.lastRowId);
1055
- this.lastRowId = maxRowIdInBatch;
1056
- logger.debug(`[imessage][poll-start] fetched=${batch.length} cursor advanced to ${this.lastRowId}`);
1057
- for (const row of batch) {
1058
- // Skip outbound messages — the agent sent these itself via AppleScript,
1059
- // we don't want it reacting to its own output.
1060
- if (row.isFromMe) {
1061
- continue;
1062
- }
1063
- // Policy gate: DM allowlist, group allowlist, disabled, etc.
1064
- if (!this.isAllowed(row.handle)) {
1065
- continue;
1066
- }
1067
- // Skip non-text rows for the main dispatch path. Reactions and
1068
- // system events still emit their own plugin-namespaced events
1069
- // below so downstream listeners can react to them, but they
1070
- // don't flow through messageService.handleMessage (which is
1071
- // for conversational turns only).
1072
- if (row.kind !== "text") {
1073
- this.emitAuxiliaryEvent(row);
1074
- continue;
1075
- }
1076
- // Undecodable text (attributedBody decode miss) would produce an
1077
- // empty-string turn the agent has nothing to do with. Skip with a
1078
- // debug log so the cursor still advances.
1079
- if (row.text.trim().length === 0) {
1080
- logger.debug(`[imessage] skipping ROWID=${row.rowId} — text column and attributedBody both empty after decode`);
1081
- continue;
1082
- }
1083
- logger.debug(`[imessage][dispatch] ROWID=${row.rowId} handle=${row.handle} text="${row.text.slice(0, 40)}"`);
1084
- try {
1085
- await this.dispatchInboundMessage(row);
1086
- }
1087
- catch (error) {
1088
- logger.error(`[imessage] Failed to dispatch inbound message ROWID=${row.rowId}: ${error instanceof Error ? error.message : String(error)}`);
1089
- }
1090
- }
1091
- logger.debug(`[imessage][poll-end] done. lastRowId=${this.lastRowId}`);
1092
- }
1093
- /**
1094
- * Turn a single chat.db row into a `Memory`, wire up a reply callback,
1095
- * and hand the whole thing to `runtime.messageService.handleMessage`.
1096
- *
1097
- * Mirrors the shape used by @elizaos/plugin-telegram so the bootstrap
1098
- * plugin's message pipeline picks up inbound iMessages the same way it
1099
- * picks up Telegram messages — same entity/room/world creation, same
1100
- * `source: "imessage"` tag on content, same HandlerCallback signature
1101
- * for the reply path.
1102
- */
1103
- async dispatchInboundMessage(row) {
1104
- if (!this.runtime)
1105
- return;
1106
- const accountId = IMESSAGE_LOCAL_ACCOUNT_ID;
1107
- // chat_identifier is stable across messages in the same chat; use it
1108
- // as the room key. Fall back to the handle for the edge case where
1109
- // chat_message_join is empty (shouldn't happen on a healthy chat.db).
1110
- const roomKey = row.chatId || row.handle || `imessage-room-${row.rowId}`;
1111
- const entityKey = row.handle || roomKey;
1112
- const entityId = createUniqueUuid(this.runtime, entityKey);
1113
- const roomId = createUniqueUuid(this.runtime, roomKey);
1114
- const worldId = createUniqueUuid(this.runtime, `imessage-world-${roomKey}`);
1115
- // Key the Memory id by the chat.db message guid, not the ROWID —
1116
- // guids are stable across restarts and CloudKit syncs, so reply
1117
- // threading (which targets guids) can resolve via createUniqueUuid
1118
- // against the same key.
1119
- const messageId = createUniqueUuid(this.runtime, `imessage-guid-${row.guid}`);
1120
- const channelType = row.chatType === "group" ? ChannelType.GROUP : ChannelType.DM;
1121
- // Resolve the sender handle against the Apple Contacts map. The map
1122
- // is loaded lazily on first inbound message — see ensureContactsLoaded
1123
- // for the rationale (deferring the macOS Contacts TCC dialog away from
1124
- // app launch). On a miss we fall back to the raw handle, so the
1125
- // conversation still works — it just looks uglier in logs and state.
1126
- await this.ensureContactsLoaded();
1127
- const resolvedContact = this.contacts.get(normalizeContactHandle(row.handle)) ?? null;
1128
- const resolvedName = resolvedContact?.name ?? null;
1129
- // Make sure the agent's memory store knows about this entity + room +
1130
- // world before we try to persist a Memory into it — otherwise FK
1131
- // constraints fire downstream.
1132
- await this.runtime.ensureConnection({
1133
- entityId,
1134
- roomId,
1135
- worldId,
1136
- source: "imessage",
1137
- channelId: roomKey,
1138
- type: channelType,
1139
- metadata: {
1140
- accountId,
1141
- chatId: roomKey,
1142
- chatType: row.chatType,
1143
- },
1144
- name: resolvedName ?? row.displayName ?? row.handle ?? undefined,
1145
- ...(row.handle ? { userId: row.handle } : {}),
1146
- worldName: row.displayName ? `imessage-chat-${row.displayName}` : `imessage-chat-${roomKey}`,
1147
- userName: resolvedName ?? row.handle ?? undefined,
1148
- });
1149
- if (typeof this.runtime.ensureRoomExists === "function") {
1150
- await this.runtime.ensureRoomExists({
1151
- id: roomId,
1152
- name: row.displayName ?? roomKey,
1153
- agentId: this.runtime.agentId,
1154
- source: "imessage",
1155
- type: channelType,
1156
- channelId: roomKey,
1157
- worldId,
1158
- metadata: {
1159
- accountId,
1160
- chatId: roomKey,
1161
- chatType: row.chatType,
1162
- },
1163
- });
1164
- }
1165
- // Lifecycle events — WORLD_JOINED + ENTITY_JOINED fire ONCE per
1166
- // room/entity per service lifetime. These are the architectural
1167
- // signals that Eliza's bootstrap plugin and any other observer
1168
- // (trajectory logger, analytics, onboarding flows) subscribe to in
1169
- // order to sync data, update rosters, or trigger side effects.
1170
- // Without them, a plugin that writes Memories directly via
1171
- // createMemory leaves observers in the dark about new rooms.
1172
- if (!this.seenWorlds.has(roomKey)) {
1173
- this.seenWorlds.add(roomKey);
1174
- this.runtime.emitEvent(EventType.WORLD_JOINED, {
1175
- runtime: this.runtime,
1176
- source: "imessage",
1177
- world: {
1178
- id: worldId,
1179
- name: row.displayName ? `imessage-chat-${row.displayName}` : `imessage-chat-${roomKey}`,
1180
- agentId: this.runtime.agentId,
1181
- serverId: roomKey,
1182
- metadata: {
1183
- accountId,
1184
- type: channelType,
1185
- chatId: roomKey,
1186
- displayName: row.displayName ?? undefined,
1187
- },
1188
- },
1189
- rooms: [
1190
- {
1191
- id: roomId,
1192
- name: row.displayName ?? roomKey,
1193
- type: channelType,
1194
- source: "imessage",
1195
- worldId,
1196
- channelId: roomKey,
1197
- serverId: roomKey,
1198
- agentId: this.runtime.agentId,
1199
- metadata: { accountId, chatType: row.chatType },
1200
- },
1201
- ],
1202
- entities: [],
1203
- });
1204
- logger.debug(`[imessage][world-joined] roomKey=${roomKey}`);
1205
- }
1206
- if (!this.seenEntities.has(entityKey)) {
1207
- this.seenEntities.add(entityKey);
1208
- this.runtime.emitEvent(EventType.ENTITY_JOINED, {
1209
- runtime: this.runtime,
1210
- source: "imessage",
1211
- entityId,
1212
- worldId,
1213
- roomId,
1214
- metadata: {
1215
- accountId,
1216
- originalId: row.handle,
1217
- username: row.handle,
1218
- displayName: resolvedName ?? row.handle,
1219
- type: channelType,
1220
- },
1221
- });
1222
- logger.debug(`[imessage][entity-joined] entityKey=${entityKey} name=${resolvedName ?? row.handle}`);
1223
- }
1224
- // Resolve the in-reply-to link. If this message is an inline reply to
1225
- // an earlier one, we build the same UUID the earlier dispatch would
1226
- // have used (same rule: `imessage-${rowId}` keyed off the reader's
1227
- // stable guid), so the agent's threading actually resolves. We don't
1228
- // know the target's ROWID from the guid alone without an extra query,
1229
- // but createUniqueUuid is content-addressable — hashing the guid gives
1230
- // the same UUID the original dispatch did if we key both on guid.
1231
- const inReplyTo = row.replyToGuid
1232
- ? createUniqueUuid(this.runtime, `imessage-guid-${row.replyToGuid}`)
1233
- : undefined;
1234
- const memoryContent = {
1235
- text: row.text,
1236
- source: "imessage",
1237
- channelType,
1238
- ...(inReplyTo ? { inReplyTo } : {}),
1239
- ...(row.attachments.length > 0
1240
- ? {
1241
- attachments: row.attachments.map((a) => ({
1242
- id: a.guid,
1243
- url: "",
1244
- title: a.filename ?? a.guid,
1245
- contentType: mimeToContentType(a.mimeType, a.uti),
1246
- source: "imessage",
1247
- description: a.isSticker ? "sticker" : (a.filename ?? a.mimeType ?? a.uti ?? ""),
1248
- text: "",
1249
- })),
1250
- }
1251
- : {}),
1252
- };
1253
- const memory = {
1254
- id: messageId,
1255
- entityId,
1256
- agentId: this.runtime.agentId,
1257
- roomId,
1258
- content: memoryContent,
1259
- // The core `MemoryMetadata` type constrains its keys to a fixed set
1260
- // (`type`, `source`, etc.), so we stash plugin-specific fields by
1261
- // widening via an unknown cast. Downstream providers read these
1262
- // keys dynamically so the static shape doesn't matter at runtime.
1263
- metadata: {
1264
- type: MemoryType.MESSAGE,
1265
- source: "imessage",
1266
- provider: "imessage",
1267
- accountId,
1268
- timestamp: row.timestamp || Date.now(),
1269
- entityName: resolvedName ?? row.displayName ?? row.handle ?? undefined,
1270
- entityUserName: row.handle ?? undefined,
1271
- fromBot: row.isFromMe,
1272
- fromId: row.handle,
1273
- sourceId: entityId,
1274
- chatType: channelType,
1275
- messageIdFull: row.guid,
1276
- sender: {
1277
- id: row.handle,
1278
- name: resolvedName ?? row.displayName ?? row.handle ?? undefined,
1279
- username: row.handle ?? undefined,
1280
- },
1281
- imessage: {
1282
- accountId,
1283
- id: row.handle,
1284
- userId: row.handle,
1285
- username: row.handle,
1286
- userName: row.handle,
1287
- name: resolvedName ?? row.displayName ?? row.handle ?? undefined,
1288
- chatId: roomKey,
1289
- guid: row.guid,
1290
- rowId: row.rowId,
1291
- service: row.service,
1292
- },
1293
- // Raw handle + resolved contact name for connector target context.
1294
- ...(row.handle ? { imessageHandle: row.handle } : {}),
1295
- ...(resolvedName ? { imessageContactName: resolvedName } : {}),
1296
- // Delivery service: iMessage / SMS / RCS.
1297
- ...(row.service ? { imessageService: row.service } : {}),
1298
- // Stable correlation keys across restarts.
1299
- imessageGuid: row.guid,
1300
- imessageRowId: row.rowId,
1301
- // Editing / retraction state for downstream filters.
1302
- ...(row.dateEdited ? { imessageEditedAt: row.dateEdited } : {}),
1303
- ...(row.dateRetracted ? { imessageRetractedAt: row.dateRetracted } : {}),
1304
- },
1305
- createdAt: row.timestamp || Date.now(),
1306
- };
1307
- // Reply callback: when the agent produces a response, send it back out
1308
- // through the existing AppleScript send path. For groups we send to
1309
- // `chat_id:<identifier>` so AppleScript targets the whole chat; for
1310
- // DMs we target the sender's handle directly.
1311
- const replyTarget = row.chatType === "group" ? `chat_id:${row.chatId}` : row.handle;
1312
- const callback = async (content) => {
1313
- const replyText = content.text?.trim();
1314
- if (!replyText || !this.runtime) {
1315
- return [];
1316
- }
1317
- const sendResult = await this.sendViaAppleScript(replyTarget, replyText);
1318
- if (!sendResult.success) {
1319
- logger.error(`[imessage] Reply send failed for ROWID=${row.rowId}: ${sendResult.error}`);
1320
- return [];
1321
- }
1322
- const responseMemory = {
1323
- id: createUniqueUuid(this.runtime, `imessage-reply-${row.rowId}-${Date.now()}`),
1324
- entityId: this.runtime.agentId,
1325
- agentId: this.runtime.agentId,
1326
- roomId,
1327
- content: {
1328
- ...content,
1329
- source: "imessage",
1330
- channelType,
1331
- inReplyTo: messageId,
1332
- },
1333
- metadata: {
1334
- type: MemoryType.MESSAGE,
1335
- source: "imessage",
1336
- provider: "imessage",
1337
- accountId,
1338
- imessage: {
1339
- accountId,
1340
- chatId: roomKey,
1341
- },
1342
- },
1343
- createdAt: Date.now(),
1344
- };
1345
- await this.runtime.createMemory(responseMemory, "messages");
1346
- return [responseMemory];
1347
- };
1348
- // Emit the plugin-namespaced event for any iMessage-specific listeners
1349
- // (kept for backwards compatibility with pre-fix code) and the generic
1350
- // core event so anything subscribed to EventType.MESSAGE_RECEIVED sees it.
1351
- this.runtime.emitEvent(IMessageEventTypes.MESSAGE_RECEIVED, {
1352
- runtime: this.runtime,
1353
- message: memory,
1354
- source: "imessage",
1355
- accountId,
1356
- callback,
1357
- });
1358
- this.runtime.emitEvent(EventType.MESSAGE_RECEIVED, {
1359
- runtime: this.runtime,
1360
- message: memory,
1361
- source: "imessage",
1362
- accountId,
1363
- callback,
1364
- });
1365
- // Inbound messages are always ingested + emitted above. The agent only
1366
- // auto-generates a reply when IMESSAGE_AUTO_REPLY is explicitly enabled —
1367
- // default-off prevents the runtime from speaking on the user's behalf to
1368
- // real iMessage contacts.
1369
- const autoReplyRaw = this.runtime.getSetting("IMESSAGE_AUTO_REPLY");
1370
- const autoReply = !lifeOpsPassiveConnectorsEnabled(this.runtime) &&
1371
- (autoReplyRaw === true || autoReplyRaw === "true");
1372
- if (!autoReply) {
1373
- // Persist the inbound memory so LifeOps and history views still see it.
1374
- try {
1375
- await this.runtime.createMemory(memory, "messages");
1376
- }
1377
- catch (err) {
1378
- logger.warn(`[imessage] Failed to persist inbound memory for ROWID=${row.rowId}: ${err instanceof Error ? err.message : String(err)}`);
1379
- }
1380
- logger.debug("[imessage] Auto-reply disabled (IMESSAGE_AUTO_REPLY=false); message ingested without response");
1381
- return;
1382
- }
1383
- // Route through the message service — this is what actually triggers
1384
- // the agent's decision pipeline (shouldRespond → generate → callback).
1385
- if (!this.runtime.messageService) {
1386
- logger.error("[imessage] runtime.messageService is null; cannot route inbound message. " +
1387
- "Ensure the bootstrap plugin (or a custom IMessageService) is registered " +
1388
- "before the iMessage plugin starts.");
1389
- return;
1390
- }
1391
- await this.runtime.messageService.handleMessage(this.runtime, memory, callback);
1392
- }
1393
- /**
1394
- * Handle non-conversational chat.db rows — reactions, group events,
1395
- * anything that isn't a normal text turn. These shouldn't flow through
1396
- * `messageService.handleMessage` (which would try to generate a reply)
1397
- * but they're still useful to surface as plugin-namespaced events so
1398
- * listeners can react, log, or update state.
1399
- *
1400
- * Emits two events:
1401
- * - A plugin-namespaced event on the existing `IMessageEventTypes`
1402
- * enum (e.g. `IMESSAGE_REACTION_RECEIVED`).
1403
- * - The generic `EventType.REACTION_RECEIVED` from core when the
1404
- * row is a reaction, so core-level handlers see it.
1405
- */
1406
- emitAuxiliaryEvent(row) {
1407
- if (!this.runtime)
1408
- return;
1409
- const accountId = IMESSAGE_LOCAL_ACCOUNT_ID;
1410
- if (row.kind === "reaction" && row.reaction) {
1411
- logger.debug(`[imessage] reaction ${row.reaction.add ? "+" : "-"}${row.reaction.kind} on guid=${row.reaction.targetGuid} by ${row.handle}`);
1412
- this.runtime.emitEvent(IMessageEventTypes.REACTION_RECEIVED, {
1413
- runtime: this.runtime,
1414
- source: "imessage",
1415
- accountId,
1416
- chatId: row.chatId,
1417
- handle: row.handle,
1418
- targetGuid: row.reaction.targetGuid,
1419
- reactionKind: row.reaction.kind,
1420
- add: row.reaction.add,
1421
- emoji: row.reaction.emoji,
1422
- service: row.service,
1423
- });
1424
- this.runtime.emitEvent(EventType.REACTION_RECEIVED, {
1425
- runtime: this.runtime,
1426
- source: "imessage",
1427
- accountId,
1428
- targetGuid: row.reaction.targetGuid,
1429
- reactionKind: row.reaction.kind,
1430
- add: row.reaction.add,
1431
- emoji: row.reaction.emoji,
1432
- });
1433
- return;
1434
- }
1435
- if (row.kind === "system") {
1436
- logger.debug(`[imessage] system event ROWID=${row.rowId} handle=${row.handle} chat=${row.chatId}`);
1437
- // No core equivalent — plugin-namespaced only.
1438
- this.runtime.emitEvent("IMESSAGE_SYSTEM_EVENT", {
1439
- runtime: this.runtime,
1440
- source: "imessage",
1441
- accountId,
1442
- chatId: row.chatId,
1443
- handle: row.handle,
1444
- rowId: row.rowId,
1445
- guid: row.guid,
1446
- });
1447
- }
1448
- }
1449
- isAllowed(handle) {
1450
- if (!this.settings) {
1451
- return false;
1452
- }
1453
- if (this.settings.dmPolicy === "open") {
1454
- return true;
1455
- }
1456
- if (this.settings.dmPolicy === "disabled") {
1457
- return false;
1458
- }
1459
- if (this.settings.dmPolicy === "allowlist") {
1460
- return this.settings.allowFrom.some((allowed) => allowed.toLowerCase() === handle.toLowerCase());
1461
- }
1462
- // pairing - allow and track
1463
- return true;
1464
- }
1465
- /**
1466
- * Register a recurring heartbeat task with Eliza's task system and
1467
- * kick off one if it isn't already queued.
1468
- *
1469
- * The heartbeat runs once a minute (configurable via
1470
- * `IMESSAGE_HEARTBEAT_INTERVAL_MS`) and does a lightweight health
1471
- * probe: (a) chat.db reader is still open and responsive,
1472
- * (b) Contacts map still populated, (c) polling cursor is advancing
1473
- * when expected. On failure it logs + emits
1474
- * `IMESSAGE_HEARTBEAT_UNHEALTHY`; on success it emits
1475
- * `IMESSAGE_HEARTBEAT_OK`. Observers (Eliza's heartbeat UI, ops
1476
- * dashboards, trajectory logger) subscribe to these events.
1477
- *
1478
- * The task is tagged `["queue", "repeat", "imessage"]` so Eliza's
1479
- * built-in TaskService picks it up via its standard polling loop.
1480
- * Without `updateInterval` being set in metadata, the task fires
1481
- * once and then deletes; with it, the task service re-schedules.
1482
- */
1483
- async registerHeartbeat() {
1484
- if (!this.runtime)
1485
- return;
1486
- if (typeof this.runtime.registerTaskWorker !== "function") {
1487
- logger.debug("[imessage][heartbeat] runtime does not support registerTaskWorker — skipping");
1488
- return;
1489
- }
1490
- const heartbeatIntervalMs = Number(process.env.IMESSAGE_HEARTBEAT_INTERVAL_MS) || 60_000;
1491
- this.runtime.registerTaskWorker({
1492
- name: "IMESSAGE_HEARTBEAT",
1493
- execute: async (runtime, _options, _task) => {
1494
- let ok = true;
1495
- let reason = "";
1496
- let tip = 0;
1497
- let contactsCount = 0;
1498
- try {
1499
- if (!this.chatDb) {
1500
- ok = false;
1501
- reason = "chat.db reader not available (send-only mode)";
1502
- }
1503
- else {
1504
- tip = this.chatDb.getLatestRowId();
1505
- if (tip <= 0) {
1506
- ok = false;
1507
- reason = "chat.db getLatestRowId returned 0";
1508
- }
1509
- }
1510
- contactsCount = this.contacts.size;
1511
- }
1512
- catch (err) {
1513
- ok = false;
1514
- reason = err instanceof Error ? err.message : String(err);
1515
- }
1516
- logger.debug(`[imessage][heartbeat] ok=${ok} tip=${tip} cursor=${this.lastRowId} contacts=${contactsCount}${reason ? ` reason=${reason}` : ""}`);
1517
- runtime.emitEvent(ok ? "IMESSAGE_HEARTBEAT_OK" : "IMESSAGE_HEARTBEAT_UNHEALTHY", {
1518
- runtime,
1519
- source: "imessage",
1520
- ok,
1521
- reason,
1522
- tip,
1523
- cursor: this.lastRowId,
1524
- contactsCount,
1525
- connected: this.connected,
1526
- timestamp: Date.now(),
1527
- });
1528
- return { nextInterval: heartbeatIntervalMs };
1529
- },
1530
- shouldRun: async () => true,
1531
- });
1532
- // Only create the task if one doesn't already exist. This is safe
1533
- // across restarts — on a cold boot no task exists yet, on a warm
1534
- // restart the previous one is still in the queue and we skip.
1535
- if (hasTaskLookup(this.runtime)) {
1536
- try {
1537
- const existing = await this.runtime.getTasksByName("IMESSAGE_HEARTBEAT");
1538
- if (Array.isArray(existing) && existing.length > 0) {
1539
- logger.debug(`[imessage][heartbeat] task already registered (${existing.length} existing) — skipping createTask`);
1540
- return;
1541
- }
1542
- }
1543
- catch {
1544
- // If the query fails, fall through and try createTask anyway.
1545
- }
1546
- }
1547
- if (typeof this.runtime.createTask === "function") {
1548
- try {
1549
- await this.runtime.createTask({
1550
- name: "IMESSAGE_HEARTBEAT",
1551
- description: "Periodic health probe for the iMessage connector (chat.db reader, contacts, polling cursor).",
1552
- metadata: {
1553
- updatedAt: Date.now(),
1554
- updateInterval: heartbeatIntervalMs,
1555
- blocking: true,
1556
- },
1557
- tags: ["queue", "repeat", "imessage"],
1558
- });
1559
- logger.debug(`[imessage][heartbeat] task registered, interval ${heartbeatIntervalMs}ms`);
1560
- }
1561
- catch (err) {
1562
- logger.warn(`[imessage][heartbeat] createTask failed: ${err instanceof Error ? err.message : String(err)}`);
1563
- }
1564
- }
1565
- }
1566
- }
1567
- /**
1568
- * Resolve a chat.db attachment's mime/UTI to one of the five ContentType
1569
- * buckets the core Memory.content type accepts. Anything we can't bucket
1570
- * falls back to `"document"` which is the catch-all.
1571
- */
1572
- function mimeToContentType(mimeType, uti) {
1573
- const m = (mimeType ?? "").toLowerCase();
1574
- const u = (uti ?? "").toLowerCase();
1575
- if (m.startsWith("image/") || u.includes("image"))
1576
- return ContentType.IMAGE;
1577
- if (m.startsWith("video/") || u.includes("movie") || u.includes("video")) {
1578
- return ContentType.VIDEO;
1579
- }
1580
- if (m.startsWith("audio/") || u.includes("audio"))
1581
- return ContentType.AUDIO;
1582
- return ContentType.DOCUMENT;
1583
- }
1584
- /**
1585
- * Convert a `ChatDbMessage` (the shape the bun:sqlite reader returns)
1586
- * into the public `IMessageMessage` shape exposed by this plugin's API.
1587
- * Exported so the test suite can exercise it in isolation without
1588
- * spinning up a full runtime + service instance.
1589
- */
1590
- export function chatDbMessageToPublicShape(row) {
1591
- return {
1592
- id: String(row.rowId),
1593
- text: row.text,
1594
- handle: row.handle,
1595
- chatId: row.chatId,
1596
- timestamp: row.timestamp,
1597
- isFromMe: row.isFromMe,
1598
- hasAttachments: row.attachments.length > 0,
1599
- ...(row.attachments.length > 0
1600
- ? {
1601
- attachmentPaths: row.attachments
1602
- .map((attachment) => attachment.filename)
1603
- .filter((filename) => Boolean(filename)),
1604
- }
1605
- : {}),
1606
- };
1607
- }
1608
- /**
1609
- * Parse tab-delimited AppleScript messages output.
1610
- * Expected format per line: "id\ttext\tdate_sent\tis_from_me\tchat_identifier\tsender"
1611
- */
1612
- export function parseMessagesFromAppleScript(result) {
1613
- const messages = [];
1614
- if (!result?.trim()) {
1615
- return messages;
1616
- }
1617
- for (const line of result.split("\n")) {
1618
- const trimmed = line.trim();
1619
- if (!trimmed) {
1620
- continue;
1621
- }
1622
- const fields = trimmed.split("\t");
1623
- if (fields.length < 6) {
1624
- continue;
1625
- }
1626
- const [id, text, dateSent, isFromMeStr, chatIdentifier, sender] = fields;
1627
- const isFromMe = isFromMeStr === "1" || isFromMeStr.toLowerCase() === "true";
1628
- let timestamp;
1629
- const parsed = Number(dateSent);
1630
- if (!Number.isNaN(parsed) && parsed > 0) {
1631
- timestamp = parsed;
1632
- }
1633
- else {
1634
- const dateObj = new Date(dateSent);
1635
- timestamp = Number.isNaN(dateObj.getTime()) ? 0 : dateObj.getTime();
1636
- }
1637
- messages.push({
1638
- id: id || "",
1639
- text: text || "",
1640
- handle: sender || "",
1641
- chatId: chatIdentifier || "",
1642
- timestamp,
1643
- isFromMe,
1644
- hasAttachments: false,
1645
- });
1646
- }
1647
- return messages;
1648
- }
1649
- /**
1650
- * Parse tab-delimited AppleScript chats output.
1651
- * Expected format per line: "chat_identifier\tdisplay_name\tparticipant_count\tlast_message_date"
1652
- */
1653
- export function parseChatsFromAppleScript(result) {
1654
- const chats = [];
1655
- if (!result?.trim()) {
1656
- return chats;
1657
- }
1658
- const listStyleEntryPattern = /\{\s*"([^"]*)"\s*,\s*(?:"([^"]*)"|(missing value))\s*\}/g;
1659
- const listStyleMatches = Array.from(result.matchAll(listStyleEntryPattern));
1660
- if (listStyleMatches.length > 0) {
1661
- for (const match of listStyleMatches) {
1662
- const chatIdentifier = match[1] ?? "";
1663
- const displayName = match[2];
1664
- chats.push({
1665
- chatId: chatIdentifier,
1666
- chatType: chatIdentifier.includes(";+;") ? "group" : "direct",
1667
- displayName: displayName || undefined,
1668
- participants: [],
1669
- });
1670
- }
1671
- return chats;
1672
- }
1673
- for (const line of result.split("\n")) {
1674
- const trimmed = line.trim();
1675
- if (!trimmed) {
1676
- continue;
1677
- }
1678
- const fields = trimmed.split("\t");
1679
- if (fields.length < 4) {
1680
- continue;
1681
- }
1682
- const [chatIdentifier, displayName, participantCountStr] = fields;
1683
- const participantCount = Number(participantCountStr) || 0;
1684
- const chatType = participantCount > 1 ? "group" : "direct";
1685
- chats.push({
1686
- chatId: chatIdentifier || "",
1687
- chatType,
1688
- displayName: displayName || undefined,
1689
- participants: [],
1690
- });
1691
- }
1692
- return chats;
1693
- }
1694
- //# sourceMappingURL=service.js.map