@elizaos/plugin-bluebubbles 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,502 @@
1
+ /**
2
+ * BlueBubbles service for ElizaOS
3
+ */
4
+ import {
5
+ ChannelType,
6
+ type Content,
7
+ type ContentType,
8
+ createUniqueUuid,
9
+ type Entity,
10
+ type EventPayload,
11
+ EventType,
12
+ type IAgentRuntime,
13
+ logger,
14
+ Service,
15
+ type UUID,
16
+ } from "@elizaos/core";
17
+ import { BlueBubblesClient } from "./client";
18
+ import { BLUEBUBBLES_SERVICE_NAME, DEFAULT_WEBHOOK_PATH } from "./constants";
19
+ import {
20
+ getConfigFromRuntime,
21
+ isHandleAllowed,
22
+ normalizeHandle,
23
+ } from "./environment";
24
+ import type {
25
+ BlueBubblesChat,
26
+ BlueBubblesChatState,
27
+ BlueBubblesConfig,
28
+ BlueBubblesIncomingEvent,
29
+ BlueBubblesMessage,
30
+ BlueBubblesWebhookPayload,
31
+ } from "./types";
32
+
33
+ export class BlueBubblesService extends Service {
34
+ static serviceType = BLUEBUBBLES_SERVICE_NAME;
35
+ capabilityDescription =
36
+ "The agent is able to send and receive iMessages via BlueBubbles";
37
+
38
+ private client: BlueBubblesClient | null = null;
39
+ private blueBubblesConfig: BlueBubblesConfig | null = null;
40
+ private knownChats: Map<string, BlueBubblesChat> = new Map();
41
+ private entityCache: Map<string, UUID> = new Map();
42
+ private roomCache: Map<string, UUID> = new Map();
43
+ private webhookPath: string = DEFAULT_WEBHOOK_PATH;
44
+ private isRunning = false;
45
+
46
+ constructor(runtime?: IAgentRuntime) {
47
+ super(runtime);
48
+ if (!runtime) return;
49
+ this.blueBubblesConfig = getConfigFromRuntime(runtime);
50
+
51
+ if (!this.blueBubblesConfig) {
52
+ logger.warn(
53
+ "BlueBubbles configuration not provided - BlueBubbles functionality will be unavailable",
54
+ );
55
+ return;
56
+ }
57
+
58
+ if (!this.blueBubblesConfig.enabled) {
59
+ logger.info("BlueBubbles plugin is disabled via configuration");
60
+ return;
61
+ }
62
+
63
+ this.webhookPath =
64
+ this.blueBubblesConfig.webhookPath ?? DEFAULT_WEBHOOK_PATH;
65
+ this.client = new BlueBubblesClient(this.blueBubblesConfig);
66
+ }
67
+
68
+ static async start(runtime: IAgentRuntime): Promise<BlueBubblesService> {
69
+ const service = new BlueBubblesService(runtime);
70
+
71
+ if (!service.client) {
72
+ logger.warn(
73
+ "BlueBubbles service started without client functionality - no configuration provided",
74
+ );
75
+ return service;
76
+ }
77
+
78
+ try {
79
+ // Probe the server to verify connectivity
80
+ const probeResult = await service.client.probe();
81
+
82
+ if (!probeResult.ok) {
83
+ logger.error(
84
+ `Failed to connect to BlueBubbles server: ${probeResult.error}`,
85
+ );
86
+ return service;
87
+ }
88
+
89
+ logger.success(
90
+ `Connected to BlueBubbles server v${probeResult.serverVersion} on macOS ${probeResult.osVersion}`,
91
+ );
92
+
93
+ if (probeResult.privateApiEnabled) {
94
+ logger.info(
95
+ "BlueBubbles Private API is enabled - edit and unsend features available",
96
+ );
97
+ }
98
+
99
+ // Initialize known chats
100
+ await service.initializeChats();
101
+
102
+ service.isRunning = true;
103
+ logger.success(
104
+ `BlueBubbles service started for ${runtime.character.name}`,
105
+ );
106
+ } catch (error) {
107
+ logger.error(
108
+ `Failed to start BlueBubbles service: ${error instanceof Error ? error.message : String(error)}`,
109
+ );
110
+ }
111
+
112
+ return service;
113
+ }
114
+
115
+ static async stopRuntime(runtime: IAgentRuntime): Promise<void> {
116
+ const service = runtime.getService<BlueBubblesService>(
117
+ BLUEBUBBLES_SERVICE_NAME,
118
+ );
119
+ if (service) {
120
+ await service.stop();
121
+ }
122
+ }
123
+
124
+ async stop(): Promise<void> {
125
+ this.isRunning = false;
126
+ logger.info("BlueBubbles service stopped");
127
+ }
128
+
129
+ /**
130
+ * Gets the BlueBubbles client
131
+ */
132
+ getClient(): BlueBubblesClient | null {
133
+ return this.client;
134
+ }
135
+
136
+ /**
137
+ * Gets the current configuration
138
+ */
139
+ getConfig(): BlueBubblesConfig | null {
140
+ return this.blueBubblesConfig;
141
+ }
142
+
143
+ /**
144
+ * Checks if the service is running
145
+ */
146
+ getIsRunning(): boolean {
147
+ return this.isRunning;
148
+ }
149
+
150
+ /**
151
+ * Gets the webhook path for receiving messages
152
+ */
153
+ getWebhookPath(): string {
154
+ return this.webhookPath;
155
+ }
156
+
157
+ /**
158
+ * Initializes known chats from the server
159
+ */
160
+ private async initializeChats(): Promise<void> {
161
+ if (!this.client) return;
162
+
163
+ try {
164
+ const chats = await this.client.listChats(100);
165
+ for (const chat of chats) {
166
+ this.knownChats.set(chat.guid, chat);
167
+ }
168
+ logger.info(`Loaded ${chats.length} BlueBubbles chats`);
169
+ } catch (error) {
170
+ logger.error(
171
+ `Failed to load BlueBubbles chats: ${error instanceof Error ? error.message : String(error)}`,
172
+ );
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Handles an incoming webhook payload
178
+ */
179
+ async handleWebhook(payload: BlueBubblesWebhookPayload): Promise<void> {
180
+ if (!this.blueBubblesConfig || !this.client) {
181
+ logger.warn("Received webhook but BlueBubbles service is not configured");
182
+ return;
183
+ }
184
+
185
+ const event: BlueBubblesIncomingEvent = {
186
+ type: payload.type as BlueBubblesIncomingEvent["type"],
187
+ data: payload.data,
188
+ };
189
+
190
+ switch (event.type) {
191
+ case "new-message":
192
+ await this.handleIncomingMessage(event.data as BlueBubblesMessage);
193
+ break;
194
+ case "updated-message":
195
+ await this.handleMessageUpdate(event.data as BlueBubblesMessage);
196
+ break;
197
+ case "chat-updated":
198
+ await this.handleChatUpdate(event.data as BlueBubblesChat);
199
+ break;
200
+ case "typing-indicator":
201
+ case "read-receipt":
202
+ // These events can be logged but don't require action
203
+ logger.debug(
204
+ `BlueBubbles ${event.type}: ${JSON.stringify(event.data)}`,
205
+ );
206
+ break;
207
+ default:
208
+ logger.debug(`Unhandled BlueBubbles event: ${event.type}`);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Handles an incoming message
214
+ */
215
+ private async handleIncomingMessage(
216
+ message: BlueBubblesMessage,
217
+ ): Promise<void> {
218
+ // Skip outgoing messages
219
+ if (message.isFromMe) {
220
+ return;
221
+ }
222
+
223
+ // Skip system messages
224
+ if (message.isSystemMessage) {
225
+ return;
226
+ }
227
+
228
+ if (!this.blueBubblesConfig) {
229
+ return;
230
+ }
231
+
232
+ const chat = message.chats[0];
233
+ if (!chat) {
234
+ logger.warn(`Received message without chat info: ${message.guid}`);
235
+ return;
236
+ }
237
+
238
+ const isGroup = chat.participants.length > 1;
239
+ const senderHandle = message.handle?.address ?? "";
240
+
241
+ // Check access policies
242
+ if (isGroup) {
243
+ if (
244
+ !isHandleAllowed(
245
+ senderHandle,
246
+ this.blueBubblesConfig.groupAllowFrom ?? [],
247
+ this.blueBubblesConfig.groupPolicy ?? "allowlist",
248
+ )
249
+ ) {
250
+ logger.debug(
251
+ `Ignoring message from ${senderHandle} - not in group allowlist`,
252
+ );
253
+ return;
254
+ }
255
+ } else {
256
+ if (
257
+ !isHandleAllowed(
258
+ senderHandle,
259
+ this.blueBubblesConfig.allowFrom ?? [],
260
+ this.blueBubblesConfig.dmPolicy ?? "pairing",
261
+ )
262
+ ) {
263
+ logger.debug(
264
+ `Ignoring message from ${senderHandle} - not in DM allowlist`,
265
+ );
266
+ return;
267
+ }
268
+ }
269
+
270
+ // Mark as read if configured
271
+ if (this.blueBubblesConfig.sendReadReceipts && this.client) {
272
+ try {
273
+ await this.client.markChatRead(chat.guid);
274
+ } catch (error) {
275
+ logger.debug(`Failed to mark chat as read: ${error}`);
276
+ }
277
+ }
278
+
279
+ // Create or get entity for sender
280
+ const entityId = await this.getOrCreateEntity(
281
+ senderHandle,
282
+ message.handle?.address,
283
+ );
284
+
285
+ // Create or get room for chat
286
+ const roomId = await this.getOrCreateRoom(chat);
287
+
288
+ // Build content
289
+ const content: Content = {
290
+ text: message.text ?? "",
291
+ source: "bluebubbles",
292
+ inReplyTo: (message.threadOriginatorGuid ?? undefined) as
293
+ | UUID
294
+ | undefined,
295
+ attachments: message.attachments.map((att) => ({
296
+ id: att.guid,
297
+ url: `${this.blueBubblesConfig?.serverUrl}/api/v1/attachment/${encodeURIComponent(att.guid)}?password=${encodeURIComponent(this.blueBubblesConfig?.password ?? "")}`,
298
+ title: att.transferName,
299
+ description: att.mimeType ?? undefined,
300
+ contentType: (att.mimeType ??
301
+ "application/octet-stream") as ContentType,
302
+ })),
303
+ };
304
+
305
+ // Emit message event
306
+ if (this.runtime) {
307
+ this.runtime.emitEvent(EventType.MESSAGE_RECEIVED, {
308
+ runtime: this.runtime,
309
+ message: {
310
+ id: createUniqueUuid(this.runtime, message.guid) as UUID,
311
+ entityId,
312
+ roomId,
313
+ content,
314
+ createdAt: message.dateCreated,
315
+ },
316
+ source: "bluebubbles",
317
+ channelType: isGroup ? ChannelType.GROUP : ChannelType.DM,
318
+ } as EventPayload);
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Handles a message update (edit, unsend, etc.)
324
+ */
325
+ private async handleMessageUpdate(
326
+ message: BlueBubblesMessage,
327
+ ): Promise<void> {
328
+ // Handle edited or unsent messages
329
+ if (message.dateEdited) {
330
+ logger.debug(`Message ${message.guid} was edited`);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Handles a chat update
336
+ */
337
+ private async handleChatUpdate(chat: BlueBubblesChat): Promise<void> {
338
+ this.knownChats.set(chat.guid, chat);
339
+ logger.debug(
340
+ `Chat ${chat.guid} updated: ${chat.displayName ?? chat.chatIdentifier}`,
341
+ );
342
+ }
343
+
344
+ /**
345
+ * Gets or creates an entity for a BlueBubbles handle
346
+ */
347
+ private async getOrCreateEntity(
348
+ handle: string,
349
+ displayName?: string,
350
+ ): Promise<UUID> {
351
+ const normalized = normalizeHandle(handle);
352
+ const cached = this.entityCache.get(normalized);
353
+ if (cached) {
354
+ return cached;
355
+ }
356
+
357
+ const entityId = createUniqueUuid(
358
+ this.runtime,
359
+ `bluebubbles:${normalized}`,
360
+ ) as UUID;
361
+
362
+ // Check if entity exists
363
+ const existing = await this.runtime.getEntityById(entityId);
364
+ if (!existing) {
365
+ const entity: Entity = {
366
+ id: entityId,
367
+ agentId: this.runtime.agentId,
368
+ names: displayName ? [displayName, normalized] : [normalized],
369
+ metadata: {
370
+ bluebubbles: {
371
+ handle: normalized,
372
+ displayName: displayName ?? normalized,
373
+ },
374
+ },
375
+ };
376
+ await this.runtime.createEntity(entity);
377
+ }
378
+
379
+ this.entityCache.set(normalized, entityId);
380
+ return entityId;
381
+ }
382
+
383
+ /**
384
+ * Gets or creates a room for a BlueBubbles chat
385
+ */
386
+ private async getOrCreateRoom(chat: BlueBubblesChat): Promise<UUID> {
387
+ const cached = this.roomCache.get(chat.guid);
388
+ if (cached) {
389
+ return cached;
390
+ }
391
+
392
+ const roomId = createUniqueUuid(
393
+ this.runtime,
394
+ `bluebubbles:${chat.guid}`,
395
+ ) as UUID;
396
+
397
+ // Check if room exists
398
+ const existing = await this.runtime.getRoom(roomId);
399
+ if (!existing && this.runtime) {
400
+ const isGroup = chat.participants.length > 1;
401
+ await this.runtime.createRoom({
402
+ id: roomId,
403
+ name: chat.displayName ?? chat.chatIdentifier,
404
+ source: "bluebubbles",
405
+ type: isGroup ? ChannelType.GROUP : ChannelType.DM,
406
+ channelId: chat.guid,
407
+ worldId: this.runtime.agentId,
408
+ metadata: {
409
+ blueBubblesServerUrl: this.blueBubblesConfig?.serverUrl,
410
+ },
411
+ });
412
+ }
413
+
414
+ this.roomCache.set(chat.guid, roomId);
415
+ return roomId;
416
+ }
417
+
418
+ /**
419
+ * Sends a message to a target
420
+ */
421
+ async sendMessage(
422
+ target: string,
423
+ text: string,
424
+ _replyToId?: string,
425
+ ): Promise<{ guid: string }> {
426
+ if (!this.client) {
427
+ throw new Error("BlueBubbles client not initialized");
428
+ }
429
+
430
+ const chatGuid = await this.client.resolveTarget(target);
431
+ const result = await this.client.sendMessage(chatGuid, text, {
432
+ // If we have a replyToId, use it as threadOriginatorGuid
433
+ // BlueBubbles handles this through the message association
434
+ });
435
+
436
+ return { guid: result.guid };
437
+ }
438
+
439
+ /**
440
+ * Gets the state for a chat
441
+ */
442
+ async getChatState(chatGuid: string): Promise<BlueBubblesChatState | null> {
443
+ const chat = this.knownChats.get(chatGuid);
444
+ if (!chat && this.client) {
445
+ try {
446
+ const fetchedChat = await this.client.getChat(chatGuid);
447
+ this.knownChats.set(chatGuid, fetchedChat);
448
+ return this.chatToState(fetchedChat);
449
+ } catch {
450
+ return null;
451
+ }
452
+ }
453
+
454
+ if (!chat) {
455
+ return null;
456
+ }
457
+
458
+ return this.chatToState(chat);
459
+ }
460
+
461
+ private chatToState(chat: BlueBubblesChat): BlueBubblesChatState {
462
+ return {
463
+ chatGuid: chat.guid,
464
+ chatIdentifier: chat.chatIdentifier,
465
+ isGroup: chat.participants.length > 1,
466
+ participants: chat.participants.map((p) => p.address),
467
+ displayName: chat.displayName,
468
+ lastMessageAt: chat.lastMessage?.dateCreated ?? null,
469
+ hasUnread: chat.hasUnreadMessages,
470
+ };
471
+ }
472
+
473
+ /**
474
+ * Checks if the service is connected
475
+ */
476
+ isConnected(): boolean {
477
+ return this.isRunning && this.client !== null;
478
+ }
479
+
480
+ /**
481
+ * Sends a reaction to a message
482
+ */
483
+ async sendReaction(
484
+ chatGuid: string,
485
+ messageGuid: string,
486
+ reaction: string,
487
+ ): Promise<{ success: boolean }> {
488
+ if (!this.client) {
489
+ throw new Error("BlueBubbles client not initialized");
490
+ }
491
+
492
+ try {
493
+ await this.client.reactToMessage(chatGuid, messageGuid, reaction);
494
+ return { success: true };
495
+ } catch (error) {
496
+ logger.error(
497
+ `Failed to send reaction: ${error instanceof Error ? error.message : String(error)}`,
498
+ );
499
+ return { success: false };
500
+ }
501
+ }
502
+ }
package/src/types.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Type definitions for the BlueBubbles plugin
3
+ */
4
+
5
+ export type DmPolicy = "open" | "pairing" | "allowlist" | "disabled";
6
+ export type GroupPolicy = "open" | "allowlist" | "disabled";
7
+
8
+ export interface BlueBubblesConfig {
9
+ serverUrl: string;
10
+ password: string;
11
+ webhookPath?: string;
12
+ dmPolicy?: DmPolicy;
13
+ groupPolicy?: GroupPolicy;
14
+ allowFrom?: string[];
15
+ groupAllowFrom?: string[];
16
+ sendReadReceipts?: boolean;
17
+ enabled?: boolean;
18
+ }
19
+
20
+ export interface BlueBubblesMessage {
21
+ guid: string;
22
+ text: string | null;
23
+ subject: string | null;
24
+ country: string | null;
25
+ handle: BlueBubblesHandle | null;
26
+ handleId: number;
27
+ otherHandle: number;
28
+ chats: BlueBubblesChat[];
29
+ attachments: BlueBubblesAttachment[];
30
+ expressiveSendStyleId: string | null;
31
+ dateCreated: number;
32
+ dateRead: number | null;
33
+ dateDelivered: number | null;
34
+ isFromMe: boolean;
35
+ isDelayed: boolean;
36
+ isAutoReply: boolean;
37
+ isSystemMessage: boolean;
38
+ isServiceMessage: boolean;
39
+ isForward: boolean;
40
+ isArchived: boolean;
41
+ hasDdResults: boolean;
42
+ hasPayloadData: boolean;
43
+ threadOriginatorGuid: string | null;
44
+ threadOriginatorPart: string | null;
45
+ associatedMessageGuid: string | null;
46
+ associatedMessageType: string | null;
47
+ balloonBundleId: string | null;
48
+ dateEdited: number | null;
49
+ error: number;
50
+ itemType: number;
51
+ groupTitle: string | null;
52
+ groupActionType: number;
53
+ payloadData: Record<string, unknown> | null;
54
+ }
55
+
56
+ export interface BlueBubblesHandle {
57
+ address: string;
58
+ service: string;
59
+ country: string | null;
60
+ originalROWID: number;
61
+ uncanonicalizedId: string | null;
62
+ }
63
+
64
+ export interface BlueBubblesChat {
65
+ guid: string;
66
+ chatIdentifier: string;
67
+ displayName: string | null;
68
+ participants: BlueBubblesHandle[];
69
+ lastMessage: BlueBubblesMessage | null;
70
+ style: number;
71
+ isArchived: boolean;
72
+ isFiltered: boolean;
73
+ isPinned: boolean;
74
+ hasUnreadMessages: boolean;
75
+ }
76
+
77
+ export interface BlueBubblesAttachment {
78
+ guid: string;
79
+ originalROWID: number;
80
+ uti: string;
81
+ mimeType: string | null;
82
+ transferName: string;
83
+ totalBytes: number;
84
+ isOutgoing: boolean;
85
+ hideAttachment: boolean;
86
+ isSticker: boolean;
87
+ hasLivePhoto: boolean;
88
+ height: number | null;
89
+ width: number | null;
90
+ metadata: Record<string, unknown> | null;
91
+ }
92
+
93
+ export interface BlueBubblesServerInfo {
94
+ os_version: string;
95
+ server_version: string;
96
+ private_api: boolean;
97
+ proxy_service: string | null;
98
+ helper_connected: boolean;
99
+ detected_icloud: string | null;
100
+ }
101
+
102
+ export interface BlueBubblesWebhookPayload {
103
+ type: string;
104
+ data: BlueBubblesMessage | BlueBubblesChat | Record<string, unknown>;
105
+ }
106
+
107
+ export interface SendMessageOptions {
108
+ tempGuid?: string;
109
+ method?: "apple-script" | "private-api";
110
+ subject?: string;
111
+ effectId?: string;
112
+ partIndex?: number;
113
+ ddScan?: boolean;
114
+ }
115
+
116
+ export interface SendMessageResult {
117
+ guid: string;
118
+ tempGuid?: string;
119
+ status: "sent" | "delivered" | "failed";
120
+ dateCreated: number;
121
+ text: string;
122
+ error?: string;
123
+ }
124
+
125
+ export interface SendAttachmentOptions extends SendMessageOptions {
126
+ name?: string;
127
+ isAudioMessage?: boolean;
128
+ }
129
+
130
+ export interface BlueBubblesProbeResult {
131
+ ok: boolean;
132
+ serverVersion?: string;
133
+ osVersion?: string;
134
+ privateApiEnabled?: boolean;
135
+ helperConnected?: boolean;
136
+ error?: string;
137
+ }
138
+
139
+ export interface BlueBubblesChatState {
140
+ chatGuid: string;
141
+ chatIdentifier: string;
142
+ isGroup: boolean;
143
+ participants: string[];
144
+ displayName: string | null;
145
+ lastMessageAt: number | null;
146
+ hasUnread: boolean;
147
+ }
148
+
149
+ // Event types for webhook processing
150
+ export type BlueBubblesEventType =
151
+ | "new-message"
152
+ | "updated-message"
153
+ | "typing-indicator"
154
+ | "read-receipt"
155
+ | "chat-updated"
156
+ | "participant-added"
157
+ | "participant-removed"
158
+ | "group-name-changed"
159
+ | "group-icon-changed"
160
+ | "group-icon-removed";
161
+
162
+ export interface BlueBubblesIncomingEvent {
163
+ type: BlueBubblesEventType;
164
+ data: BlueBubblesMessage | BlueBubblesChat | Record<string, unknown>;
165
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "outDir": "./dist",
4
+ "rootDir": "./src",
5
+ "declaration": true,
6
+ "declarationMap": true,
7
+ "sourceMap": true,
8
+ "target": "ES2022",
9
+ "module": "ESNext",
10
+ "moduleResolution": "Bundler",
11
+ "lib": ["ES2022"],
12
+ "types": ["node", "bun"],
13
+ "strict": true,
14
+ "esModuleInterop": true,
15
+ "skipLibCheck": true,
16
+ "forceConsistentCasingInFileNames": true,
17
+ "resolveJsonModule": true,
18
+ "isolatedModules": true
19
+ },
20
+ "include": ["src/**/*"],
21
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
22
+ }