@elizaos/plugin-imessage 2.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/service.ts ADDED
@@ -0,0 +1,589 @@
1
+ /**
2
+ * iMessage service implementation for ElizaOS.
3
+ */
4
+
5
+ import { exec } from "node:child_process";
6
+ import { existsSync } from "node:fs";
7
+ import { platform } from "node:os";
8
+ import { promisify } from "node:util";
9
+ import {
10
+ type EventPayload,
11
+ type IAgentRuntime,
12
+ logger,
13
+ Service,
14
+ } from "@elizaos/core";
15
+ import {
16
+ DEFAULT_POLL_INTERVAL_MS,
17
+ formatPhoneNumber,
18
+ type IIMessageService,
19
+ IMESSAGE_SERVICE_NAME,
20
+ type IMessageChat,
21
+ type IMessageChatType,
22
+ IMessageCliError,
23
+ IMessageConfigurationError,
24
+ IMessageEventTypes,
25
+ type IMessageMessage,
26
+ IMessageNotSupportedError,
27
+ type IMessageSendOptions,
28
+ type IMessageSendResult,
29
+ type IMessageSettings,
30
+ isPhoneNumber,
31
+ splitMessageForIMessage,
32
+ } from "./types.js";
33
+
34
+ const execAsync = promisify(exec);
35
+
36
+ /**
37
+ * iMessage service for ElizaOS agents.
38
+ * Note: This only works on macOS.
39
+ */
40
+ export class IMessageService extends Service implements IIMessageService {
41
+ static serviceType: string = IMESSAGE_SERVICE_NAME;
42
+
43
+ capabilityDescription =
44
+ "iMessage service for sending and receiving messages on macOS";
45
+
46
+ private settings: IMessageSettings | null = null;
47
+ private connected: boolean = false;
48
+ private pollInterval: NodeJS.Timeout | null = null;
49
+ private lastMessageId: string | null = null;
50
+
51
+ /**
52
+ * Start the iMessage service.
53
+ */
54
+ static async start(runtime: IAgentRuntime): Promise<IMessageService> {
55
+ logger.info("Starting iMessage service...");
56
+
57
+ const service = new IMessageService(runtime);
58
+
59
+ // Check if running on macOS
60
+ if (!service.isMacOS()) {
61
+ throw new IMessageNotSupportedError();
62
+ }
63
+
64
+ // Load settings
65
+ service.settings = service.loadSettings();
66
+ await service.validateSettings();
67
+
68
+ // Start polling for new messages
69
+ if (service.settings.pollIntervalMs > 0) {
70
+ service.startPolling();
71
+ }
72
+
73
+ service.connected = true;
74
+ logger.info("iMessage service started");
75
+
76
+ // Emit connection ready event
77
+ runtime.emitEvent(IMessageEventTypes.CONNECTION_READY, {
78
+ runtime,
79
+ service,
80
+ } as EventPayload);
81
+
82
+ return service;
83
+ }
84
+
85
+ /**
86
+ * Stop the iMessage service.
87
+ */
88
+ async stop(): Promise<void> {
89
+ logger.info("Stopping iMessage service...");
90
+ this.connected = false;
91
+
92
+ if (this.pollInterval) {
93
+ clearInterval(this.pollInterval);
94
+ this.pollInterval = null;
95
+ }
96
+
97
+ this.settings = null;
98
+ this.lastMessageId = null;
99
+ logger.info("iMessage service stopped");
100
+ }
101
+
102
+ /**
103
+ * Check if the service is connected.
104
+ */
105
+ isConnected(): boolean {
106
+ return this.connected;
107
+ }
108
+
109
+ /**
110
+ * Check if running on macOS.
111
+ */
112
+ isMacOS(): boolean {
113
+ return platform() === "darwin";
114
+ }
115
+
116
+ /**
117
+ * Send a message via iMessage.
118
+ */
119
+ async sendMessage(
120
+ to: string,
121
+ text: string,
122
+ options?: IMessageSendOptions,
123
+ ): Promise<IMessageSendResult> {
124
+ if (!this.settings) {
125
+ return { success: false, error: "Service not initialized" };
126
+ }
127
+
128
+ // Format phone number if needed
129
+ const target = isPhoneNumber(to) ? formatPhoneNumber(to) : to;
130
+
131
+ // Split message if too long
132
+ const chunks = splitMessageForIMessage(text);
133
+
134
+ for (const chunk of chunks) {
135
+ const result = await this.sendSingleMessage(target, chunk, options);
136
+ if (!result.success) {
137
+ return result;
138
+ }
139
+ }
140
+
141
+ // Emit sent event
142
+ if (this.runtime) {
143
+ this.runtime.emitEvent(IMessageEventTypes.MESSAGE_SENT, {
144
+ runtime: this.runtime,
145
+ to: target,
146
+ text,
147
+ hasMedia: Boolean(options?.mediaUrl),
148
+ } as EventPayload);
149
+ }
150
+
151
+ return {
152
+ success: true,
153
+ messageId: Date.now().toString(),
154
+ chatId: target,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Get recent messages.
160
+ */
161
+ async getRecentMessages(limit: number = 50): Promise<IMessageMessage[]> {
162
+ if (!this.settings) {
163
+ return [];
164
+ }
165
+
166
+ // Use CLI or AppleScript to get recent messages
167
+ const script = `
168
+ tell application "Messages"
169
+ set AppleScript's text item delimiters to tab
170
+ set outputLines to {}
171
+ repeat with i from 1 to ${limit}
172
+ try
173
+ set msg to item i of (get messages)
174
+ set msgLine to (id of msg) & tab & (text of msg) & tab & ((date of msg) as string) & tab & (is_from_me of msg as string) & tab & (chat_identifier of msg as string) & tab & (handle of sender of msg)
175
+ set end of outputLines to msgLine
176
+ end try
177
+ end repeat
178
+ set AppleScript's text item delimiters to linefeed
179
+ set outputText to outputLines as string
180
+ set AppleScript's text item delimiters to ""
181
+ return outputText
182
+ end tell
183
+ `;
184
+
185
+ try {
186
+ const result = await this.runAppleScript(script);
187
+ // Parse result and return messages
188
+ return this.parseMessagesResult(result);
189
+ } catch (error) {
190
+ logger.warn(`Failed to get recent messages: ${error}`);
191
+ return [];
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Get chats.
197
+ */
198
+ async getChats(): Promise<IMessageChat[]> {
199
+ if (!this.settings) {
200
+ return [];
201
+ }
202
+
203
+ const script = `
204
+ tell application "Messages"
205
+ set chatList to {}
206
+ repeat with c in chats
207
+ set chatId to id of c
208
+ set chatName to name of c
209
+ set end of chatList to {chatId, chatName}
210
+ end repeat
211
+ return chatList
212
+ end tell
213
+ `;
214
+
215
+ try {
216
+ const result = await this.runAppleScript(script);
217
+ return this.parseChatsResult(result);
218
+ } catch (error) {
219
+ logger.warn(`Failed to get chats: ${error}`);
220
+ return [];
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Get current settings.
226
+ */
227
+ getSettings(): IMessageSettings | null {
228
+ return this.settings;
229
+ }
230
+
231
+ // Private methods
232
+
233
+ private loadSettings(): IMessageSettings {
234
+ if (!this.runtime) {
235
+ throw new IMessageConfigurationError("Runtime not initialized");
236
+ }
237
+
238
+ const getStringSetting = (
239
+ key: string,
240
+ envKey: string,
241
+ defaultValue = "",
242
+ ): string => {
243
+ const value = this.runtime?.getSetting(key);
244
+ if (typeof value === "string") return value;
245
+ return process.env[envKey] || defaultValue;
246
+ };
247
+
248
+ const cliPath = getStringSetting(
249
+ "IMESSAGE_CLI_PATH",
250
+ "IMESSAGE_CLI_PATH",
251
+ "imsg",
252
+ );
253
+ const dbPath =
254
+ getStringSetting("IMESSAGE_DB_PATH", "IMESSAGE_DB_PATH") || undefined;
255
+
256
+ const pollIntervalMs =
257
+ Number(
258
+ getStringSetting(
259
+ "IMESSAGE_POLL_INTERVAL_MS",
260
+ "IMESSAGE_POLL_INTERVAL_MS",
261
+ ),
262
+ ) || DEFAULT_POLL_INTERVAL_MS;
263
+
264
+ const dmPolicy = getStringSetting(
265
+ "IMESSAGE_DM_POLICY",
266
+ "IMESSAGE_DM_POLICY",
267
+ "pairing",
268
+ ) as IMessageSettings["dmPolicy"];
269
+
270
+ const groupPolicy = getStringSetting(
271
+ "IMESSAGE_GROUP_POLICY",
272
+ "IMESSAGE_GROUP_POLICY",
273
+ "allowlist",
274
+ ) as IMessageSettings["groupPolicy"];
275
+
276
+ const allowFromRaw = getStringSetting(
277
+ "IMESSAGE_ALLOW_FROM",
278
+ "IMESSAGE_ALLOW_FROM",
279
+ );
280
+ const allowFrom = allowFromRaw
281
+ ? allowFromRaw
282
+ .split(",")
283
+ .map((s: string) => s.trim())
284
+ .filter(Boolean)
285
+ : [];
286
+
287
+ const enabledRaw = getStringSetting(
288
+ "IMESSAGE_ENABLED",
289
+ "IMESSAGE_ENABLED",
290
+ "true",
291
+ );
292
+ const enabled = enabledRaw !== "false";
293
+
294
+ return {
295
+ cliPath,
296
+ dbPath,
297
+ pollIntervalMs,
298
+ dmPolicy,
299
+ groupPolicy,
300
+ allowFrom,
301
+ enabled,
302
+ };
303
+ }
304
+
305
+ private async validateSettings(): Promise<void> {
306
+ if (!this.settings) {
307
+ throw new IMessageConfigurationError("Settings not loaded");
308
+ }
309
+
310
+ // Check if CLI tool exists (if specified and not default)
311
+ if (this.settings.cliPath !== "imsg") {
312
+ if (!existsSync(this.settings.cliPath)) {
313
+ logger.warn(
314
+ `iMessage CLI not found at ${this.settings.cliPath}, will use AppleScript`,
315
+ );
316
+ }
317
+ }
318
+
319
+ // Check if Messages app is accessible
320
+ try {
321
+ await this.runAppleScript('tell application "Messages" to return 1');
322
+ } catch (_error) {
323
+ throw new IMessageConfigurationError(
324
+ "Cannot access Messages app. Ensure Full Disk Access is granted.",
325
+ );
326
+ }
327
+ }
328
+
329
+ private async sendSingleMessage(
330
+ to: string,
331
+ text: string,
332
+ options?: IMessageSendOptions,
333
+ ): Promise<IMessageSendResult> {
334
+ // Try CLI first if available
335
+ if (this.settings?.cliPath && this.settings.cliPath !== "imsg") {
336
+ try {
337
+ return await this.sendViaCli(to, text, options);
338
+ } catch (error) {
339
+ logger.debug(`CLI send failed, falling back to AppleScript: ${error}`);
340
+ }
341
+ }
342
+
343
+ // Fall back to AppleScript
344
+ return await this.sendViaAppleScript(to, text, options);
345
+ }
346
+
347
+ private async sendViaCli(
348
+ to: string,
349
+ text: string,
350
+ options?: IMessageSendOptions,
351
+ ): Promise<IMessageSendResult> {
352
+ if (!this.settings) {
353
+ return { success: false, error: "Service not initialized" };
354
+ }
355
+
356
+ const args = [to, text];
357
+ if (options?.mediaUrl) {
358
+ args.push("--attachment", options.mediaUrl);
359
+ }
360
+
361
+ try {
362
+ await execAsync(
363
+ `"${this.settings.cliPath}" ${args.map((a) => `"${a}"`).join(" ")}`,
364
+ );
365
+ return { success: true, messageId: Date.now().toString(), chatId: to };
366
+ } catch (error) {
367
+ const err = error as { code?: number; message?: string };
368
+ throw new IMessageCliError(err.message || "CLI command failed", err.code);
369
+ }
370
+ }
371
+
372
+ private async sendViaAppleScript(
373
+ to: string,
374
+ text: string,
375
+ _options?: IMessageSendOptions,
376
+ ): Promise<IMessageSendResult> {
377
+ // Escape text for AppleScript
378
+ const escapedText = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
379
+
380
+ let script: string;
381
+
382
+ if (to.startsWith("chat_id:")) {
383
+ // Send to existing chat
384
+ const chatId = to.slice(8);
385
+ script = `
386
+ tell application "Messages"
387
+ set targetChat to chat id "${chatId}"
388
+ send "${escapedText}" to targetChat
389
+ end tell
390
+ `;
391
+ } else {
392
+ // Send to buddy (phone/email)
393
+ script = `
394
+ tell application "Messages"
395
+ set targetService to 1st account whose service type = iMessage
396
+ set targetBuddy to participant "${to}" of targetService
397
+ send "${escapedText}" to targetBuddy
398
+ end tell
399
+ `;
400
+ }
401
+
402
+ try {
403
+ await this.runAppleScript(script);
404
+ return { success: true, messageId: Date.now().toString(), chatId: to };
405
+ } catch (error) {
406
+ return { success: false, error: `AppleScript error: ${error}` };
407
+ }
408
+ }
409
+
410
+ private async runAppleScript(script: string): Promise<string> {
411
+ try {
412
+ const { stdout } = await execAsync(
413
+ `osascript -e '${script.replace(/'/g, "'\"'\"'")}'`,
414
+ );
415
+ return stdout.trim();
416
+ } catch (error) {
417
+ const err = error as { stderr?: string; message?: string };
418
+ throw new Error(
419
+ err.stderr || err.message || "AppleScript execution failed",
420
+ );
421
+ }
422
+ }
423
+
424
+ private startPolling(): void {
425
+ if (!this.settings) {
426
+ return;
427
+ }
428
+
429
+ this.pollInterval = setInterval(async () => {
430
+ try {
431
+ await this.pollForNewMessages();
432
+ } catch (error) {
433
+ logger.debug(`Polling error: ${error}`);
434
+ }
435
+ }, this.settings.pollIntervalMs);
436
+ }
437
+
438
+ private async pollForNewMessages(): Promise<void> {
439
+ if (!this.runtime) {
440
+ return;
441
+ }
442
+
443
+ const messages = await this.getRecentMessages(10);
444
+
445
+ for (const msg of messages) {
446
+ // Skip if we've already seen this message
447
+ if (this.lastMessageId && msg.id <= this.lastMessageId) {
448
+ continue;
449
+ }
450
+
451
+ // Skip messages from self
452
+ if (msg.isFromMe) {
453
+ continue;
454
+ }
455
+
456
+ // Check DM policy
457
+ if (!this.isAllowed(msg.handle)) {
458
+ continue;
459
+ }
460
+
461
+ // Emit message received event
462
+ this.runtime.emitEvent(IMessageEventTypes.MESSAGE_RECEIVED, {
463
+ runtime: this.runtime,
464
+ message: msg,
465
+ } as EventPayload);
466
+
467
+ this.lastMessageId = msg.id;
468
+ }
469
+ }
470
+
471
+ private isAllowed(handle: string): boolean {
472
+ if (!this.settings) {
473
+ return false;
474
+ }
475
+
476
+ if (this.settings.dmPolicy === "open") {
477
+ return true;
478
+ }
479
+
480
+ if (this.settings.dmPolicy === "disabled") {
481
+ return false;
482
+ }
483
+
484
+ if (this.settings.dmPolicy === "allowlist") {
485
+ return this.settings.allowFrom.some(
486
+ (allowed) => allowed.toLowerCase() === handle.toLowerCase(),
487
+ );
488
+ }
489
+
490
+ // pairing - allow and track
491
+ return true;
492
+ }
493
+
494
+ private parseMessagesResult(result: string): IMessageMessage[] {
495
+ return parseMessagesFromAppleScript(result);
496
+ }
497
+
498
+ private parseChatsResult(result: string): IMessageChat[] {
499
+ return parseChatsFromAppleScript(result);
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Parse tab-delimited AppleScript messages output.
505
+ * Expected format per line: "id\ttext\tdate_sent\tis_from_me\tchat_identifier\tsender"
506
+ */
507
+ export function parseMessagesFromAppleScript(
508
+ result: string,
509
+ ): IMessageMessage[] {
510
+ const messages: IMessageMessage[] = [];
511
+ if (!result || !result.trim()) {
512
+ return messages;
513
+ }
514
+
515
+ for (const line of result.split("\n")) {
516
+ const trimmed = line.trim();
517
+ if (!trimmed) {
518
+ continue;
519
+ }
520
+
521
+ const fields = trimmed.split("\t");
522
+ if (fields.length < 6) {
523
+ continue;
524
+ }
525
+
526
+ const [id, text, dateSent, isFromMeStr, chatIdentifier, sender] = fields;
527
+
528
+ const isFromMe =
529
+ isFromMeStr === "1" || isFromMeStr.toLowerCase() === "true";
530
+
531
+ let timestamp: number;
532
+ const parsed = Number(dateSent);
533
+ if (!Number.isNaN(parsed) && parsed > 0) {
534
+ timestamp = parsed;
535
+ } else {
536
+ const dateObj = new Date(dateSent);
537
+ timestamp = Number.isNaN(dateObj.getTime()) ? 0 : dateObj.getTime();
538
+ }
539
+
540
+ messages.push({
541
+ id: id || "",
542
+ text: text || "",
543
+ handle: sender || "",
544
+ chatId: chatIdentifier || "",
545
+ timestamp,
546
+ isFromMe,
547
+ hasAttachments: false,
548
+ });
549
+ }
550
+
551
+ return messages;
552
+ }
553
+
554
+ /**
555
+ * Parse tab-delimited AppleScript chats output.
556
+ * Expected format per line: "chat_identifier\tdisplay_name\tparticipant_count\tlast_message_date"
557
+ */
558
+ export function parseChatsFromAppleScript(result: string): IMessageChat[] {
559
+ const chats: IMessageChat[] = [];
560
+ if (!result || !result.trim()) {
561
+ return chats;
562
+ }
563
+
564
+ for (const line of result.split("\n")) {
565
+ const trimmed = line.trim();
566
+ if (!trimmed) {
567
+ continue;
568
+ }
569
+
570
+ const fields = trimmed.split("\t");
571
+ if (fields.length < 4) {
572
+ continue;
573
+ }
574
+
575
+ const [chatIdentifier, displayName, participantCountStr] = fields;
576
+
577
+ const participantCount = Number(participantCountStr) || 0;
578
+ const chatType: IMessageChatType = participantCount > 1 ? "group" : "direct";
579
+
580
+ chats.push({
581
+ chatId: chatIdentifier || "",
582
+ chatType,
583
+ displayName: displayName || undefined,
584
+ participants: [],
585
+ });
586
+ }
587
+
588
+ return chats;
589
+ }