@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.
- package/LICENSE +21 -0
- package/README.md +13 -18
- package/auto-enable.ts +1 -1
- package/package.json +21 -4
- package/dist/accounts.d.ts +0 -135
- package/dist/accounts.d.ts.map +0 -1
- package/dist/accounts.js +0 -209
- package/dist/accounts.js.map +0 -1
- package/dist/api/bluebubbles-routes.d.ts +0 -10
- package/dist/api/bluebubbles-routes.d.ts.map +0 -1
- package/dist/api/bluebubbles-routes.js +0 -132
- package/dist/api/bluebubbles-routes.js.map +0 -1
- package/dist/api/imessage-routes.d.ts +0 -80
- package/dist/api/imessage-routes.d.ts.map +0 -1
- package/dist/api/imessage-routes.js +0 -230
- package/dist/api/imessage-routes.js.map +0 -1
- package/dist/chatdb-reader.d.ts +0 -240
- package/dist/chatdb-reader.d.ts.map +0 -1
- package/dist/chatdb-reader.js +0 -647
- package/dist/chatdb-reader.js.map +0 -1
- package/dist/config.d.ts +0 -60
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js +0 -8
- package/dist/config.js.map +0 -1
- package/dist/connector-account-provider.d.ts +0 -18
- package/dist/connector-account-provider.d.ts.map +0 -1
- package/dist/connector-account-provider.js +0 -83
- package/dist/connector-account-provider.js.map +0 -1
- package/dist/contacts-reader.d.ts +0 -147
- package/dist/contacts-reader.d.ts.map +0 -1
- package/dist/contacts-reader.js +0 -481
- package/dist/contacts-reader.js.map +0 -1
- package/dist/index.d.ts +0 -23
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -78
- package/dist/index.js.map +0 -1
- package/dist/providers/index.d.ts +0 -4
- package/dist/providers/index.d.ts.map +0 -1
- package/dist/providers/index.js +0 -5
- package/dist/providers/index.js.map +0 -1
- package/dist/rpc.d.ts +0 -206
- package/dist/rpc.d.ts.map +0 -1
- package/dist/rpc.js +0 -393
- package/dist/rpc.js.map +0 -1
- package/dist/service.d.ts +0 -266
- package/dist/service.d.ts.map +0 -1
- package/dist/service.js +0 -1694
- package/dist/service.js.map +0 -1
- package/dist/setup-routes.d.ts +0 -38
- package/dist/setup-routes.d.ts.map +0 -1
- package/dist/setup-routes.js +0 -322
- package/dist/setup-routes.js.map +0 -1
- package/dist/types.d.ts +0 -192
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -138
- 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
|