@elizaos/plugin-imessage 2.0.3-beta.2 → 2.0.3-beta.3

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