@openclaw/bluebubbles 2026.2.12 → 2026.2.13

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.
@@ -0,0 +1,979 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import {
3
+ createReplyPrefixOptions,
4
+ logAckFailure,
5
+ logInboundDrop,
6
+ logTypingFailure,
7
+ resolveAckReaction,
8
+ resolveControlCommandGate,
9
+ } from "openclaw/plugin-sdk";
10
+ import type {
11
+ BlueBubblesCoreRuntime,
12
+ BlueBubblesRuntimeEnv,
13
+ WebhookTarget,
14
+ } from "./monitor-shared.js";
15
+ import { downloadBlueBubblesAttachment } from "./attachments.js";
16
+ import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
17
+ import { sendBlueBubblesMedia } from "./media-send.js";
18
+ import {
19
+ buildMessagePlaceholder,
20
+ formatGroupAllowlistEntry,
21
+ formatGroupMembers,
22
+ formatReplyTag,
23
+ parseTapbackText,
24
+ resolveGroupFlagFromChatGuid,
25
+ resolveTapbackContext,
26
+ type NormalizedWebhookMessage,
27
+ type NormalizedWebhookReaction,
28
+ } from "./monitor-normalize.js";
29
+ import {
30
+ getShortIdForUuid,
31
+ rememberBlueBubblesReplyCache,
32
+ resolveBlueBubblesMessageId,
33
+ resolveReplyContextFromCache,
34
+ } from "./monitor-reply-cache.js";
35
+ import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
36
+ import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
37
+ import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
38
+
39
+ const DEFAULT_TEXT_LIMIT = 4000;
40
+ const invalidAckReactions = new Set<string>();
41
+
42
+ export function logVerbose(
43
+ core: BlueBubblesCoreRuntime,
44
+ runtime: BlueBubblesRuntimeEnv,
45
+ message: string,
46
+ ): void {
47
+ if (core.logging.shouldLogVerbose()) {
48
+ runtime.log?.(`[bluebubbles] ${message}`);
49
+ }
50
+ }
51
+
52
+ function logGroupAllowlistHint(params: {
53
+ runtime: BlueBubblesRuntimeEnv;
54
+ reason: string;
55
+ entry: string | null;
56
+ chatName?: string;
57
+ accountId?: string;
58
+ }): void {
59
+ const log = params.runtime.log ?? console.log;
60
+ const nameHint = params.chatName ? ` (group name: ${params.chatName})` : "";
61
+ const accountHint = params.accountId
62
+ ? ` (or channels.bluebubbles.accounts.${params.accountId}.groupAllowFrom)`
63
+ : "";
64
+ if (params.entry) {
65
+ log(
66
+ `[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` +
67
+ `"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`,
68
+ );
69
+ log(
70
+ `[bluebubbles] add to config: channels.bluebubbles.groupAllowFrom=["${params.entry}"]${accountHint}.`,
71
+ );
72
+ return;
73
+ }
74
+ log(
75
+ `[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` +
76
+ `channels.bluebubbles.groupPolicy="open" or adding a group id to ` +
77
+ `channels.bluebubbles.groupAllowFrom${accountHint}${nameHint}.`,
78
+ );
79
+ }
80
+
81
+ function resolveBlueBubblesAckReaction(params: {
82
+ cfg: OpenClawConfig;
83
+ agentId: string;
84
+ core: BlueBubblesCoreRuntime;
85
+ runtime: BlueBubblesRuntimeEnv;
86
+ }): string | null {
87
+ const raw = resolveAckReaction(params.cfg, params.agentId).trim();
88
+ if (!raw) {
89
+ return null;
90
+ }
91
+ try {
92
+ normalizeBlueBubblesReactionInput(raw);
93
+ return raw;
94
+ } catch {
95
+ const key = raw.toLowerCase();
96
+ if (!invalidAckReactions.has(key)) {
97
+ invalidAckReactions.add(key);
98
+ logVerbose(
99
+ params.core,
100
+ params.runtime,
101
+ `ack reaction skipped (unsupported for BlueBubbles): ${raw}`,
102
+ );
103
+ }
104
+ return null;
105
+ }
106
+ }
107
+
108
+ export async function processMessage(
109
+ message: NormalizedWebhookMessage,
110
+ target: WebhookTarget,
111
+ ): Promise<void> {
112
+ const { account, config, runtime, core, statusSink } = target;
113
+
114
+ const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
115
+ const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
116
+
117
+ const text = message.text.trim();
118
+ const attachments = message.attachments ?? [];
119
+ const placeholder = buildMessagePlaceholder(message);
120
+ // Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
121
+ // For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
122
+ const tapbackContext = resolveTapbackContext(message);
123
+ const tapbackParsed = parseTapbackText({
124
+ text,
125
+ emojiHint: tapbackContext?.emojiHint,
126
+ actionHint: tapbackContext?.actionHint,
127
+ requireQuoted: !tapbackContext,
128
+ });
129
+ const isTapbackMessage = Boolean(tapbackParsed);
130
+ const rawBody = tapbackParsed
131
+ ? tapbackParsed.action === "removed"
132
+ ? `removed ${tapbackParsed.emoji} reaction`
133
+ : `reacted with ${tapbackParsed.emoji}`
134
+ : text || placeholder;
135
+
136
+ const cacheMessageId = message.messageId?.trim();
137
+ let messageShortId: string | undefined;
138
+ const cacheInboundMessage = () => {
139
+ if (!cacheMessageId) {
140
+ return;
141
+ }
142
+ const cacheEntry = rememberBlueBubblesReplyCache({
143
+ accountId: account.accountId,
144
+ messageId: cacheMessageId,
145
+ chatGuid: message.chatGuid,
146
+ chatIdentifier: message.chatIdentifier,
147
+ chatId: message.chatId,
148
+ senderLabel: message.fromMe ? "me" : message.senderId,
149
+ body: rawBody,
150
+ timestamp: message.timestamp ?? Date.now(),
151
+ });
152
+ messageShortId = cacheEntry.shortId;
153
+ };
154
+
155
+ if (message.fromMe) {
156
+ // Cache from-me messages so reply context can resolve sender/body.
157
+ cacheInboundMessage();
158
+ return;
159
+ }
160
+
161
+ if (!rawBody) {
162
+ logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
163
+ return;
164
+ }
165
+ logVerbose(
166
+ core,
167
+ runtime,
168
+ `msg sender=${message.senderId} group=${isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
169
+ );
170
+
171
+ const dmPolicy = account.config.dmPolicy ?? "pairing";
172
+ const groupPolicy = account.config.groupPolicy ?? "allowlist";
173
+ const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
174
+ const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
175
+ const storeAllowFrom = await core.channel.pairing
176
+ .readAllowFromStore("bluebubbles")
177
+ .catch(() => []);
178
+ const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
179
+ .map((entry) => String(entry).trim())
180
+ .filter(Boolean);
181
+ const effectiveGroupAllowFrom = [
182
+ ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
183
+ ...storeAllowFrom,
184
+ ]
185
+ .map((entry) => String(entry).trim())
186
+ .filter(Boolean);
187
+ const groupAllowEntry = formatGroupAllowlistEntry({
188
+ chatGuid: message.chatGuid,
189
+ chatId: message.chatId ?? undefined,
190
+ chatIdentifier: message.chatIdentifier ?? undefined,
191
+ });
192
+ const groupName = message.chatName?.trim() || undefined;
193
+
194
+ if (isGroup) {
195
+ if (groupPolicy === "disabled") {
196
+ logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
197
+ logGroupAllowlistHint({
198
+ runtime,
199
+ reason: "groupPolicy=disabled",
200
+ entry: groupAllowEntry,
201
+ chatName: groupName,
202
+ accountId: account.accountId,
203
+ });
204
+ return;
205
+ }
206
+ if (groupPolicy === "allowlist") {
207
+ if (effectiveGroupAllowFrom.length === 0) {
208
+ logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
209
+ logGroupAllowlistHint({
210
+ runtime,
211
+ reason: "groupPolicy=allowlist (empty allowlist)",
212
+ entry: groupAllowEntry,
213
+ chatName: groupName,
214
+ accountId: account.accountId,
215
+ });
216
+ return;
217
+ }
218
+ const allowed = isAllowedBlueBubblesSender({
219
+ allowFrom: effectiveGroupAllowFrom,
220
+ sender: message.senderId,
221
+ chatId: message.chatId ?? undefined,
222
+ chatGuid: message.chatGuid ?? undefined,
223
+ chatIdentifier: message.chatIdentifier ?? undefined,
224
+ });
225
+ if (!allowed) {
226
+ logVerbose(
227
+ core,
228
+ runtime,
229
+ `Blocked BlueBubbles sender ${message.senderId} (not in groupAllowFrom)`,
230
+ );
231
+ logVerbose(
232
+ core,
233
+ runtime,
234
+ `drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`,
235
+ );
236
+ logGroupAllowlistHint({
237
+ runtime,
238
+ reason: "groupPolicy=allowlist (not allowlisted)",
239
+ entry: groupAllowEntry,
240
+ chatName: groupName,
241
+ accountId: account.accountId,
242
+ });
243
+ return;
244
+ }
245
+ }
246
+ } else {
247
+ if (dmPolicy === "disabled") {
248
+ logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`);
249
+ logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`);
250
+ return;
251
+ }
252
+ if (dmPolicy !== "open") {
253
+ const allowed = isAllowedBlueBubblesSender({
254
+ allowFrom: effectiveAllowFrom,
255
+ sender: message.senderId,
256
+ chatId: message.chatId ?? undefined,
257
+ chatGuid: message.chatGuid ?? undefined,
258
+ chatIdentifier: message.chatIdentifier ?? undefined,
259
+ });
260
+ if (!allowed) {
261
+ if (dmPolicy === "pairing") {
262
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
263
+ channel: "bluebubbles",
264
+ id: message.senderId,
265
+ meta: { name: message.senderName },
266
+ });
267
+ runtime.log?.(
268
+ `[bluebubbles] pairing request sender=${message.senderId} created=${created}`,
269
+ );
270
+ if (created) {
271
+ logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
272
+ try {
273
+ await sendMessageBlueBubbles(
274
+ message.senderId,
275
+ core.channel.pairing.buildPairingReply({
276
+ channel: "bluebubbles",
277
+ idLine: `Your BlueBubbles sender id: ${message.senderId}`,
278
+ code,
279
+ }),
280
+ { cfg: config, accountId: account.accountId },
281
+ );
282
+ statusSink?.({ lastOutboundAt: Date.now() });
283
+ } catch (err) {
284
+ logVerbose(
285
+ core,
286
+ runtime,
287
+ `bluebubbles pairing reply failed for ${message.senderId}: ${String(err)}`,
288
+ );
289
+ runtime.error?.(
290
+ `[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`,
291
+ );
292
+ }
293
+ }
294
+ } else {
295
+ logVerbose(
296
+ core,
297
+ runtime,
298
+ `Blocked unauthorized BlueBubbles sender ${message.senderId} (dmPolicy=${dmPolicy})`,
299
+ );
300
+ logVerbose(
301
+ core,
302
+ runtime,
303
+ `drop: dm sender not allowed sender=${message.senderId} allowFrom=${effectiveAllowFrom.join(",")}`,
304
+ );
305
+ }
306
+ return;
307
+ }
308
+ }
309
+ }
310
+
311
+ const chatId = message.chatId ?? undefined;
312
+ const chatGuid = message.chatGuid ?? undefined;
313
+ const chatIdentifier = message.chatIdentifier ?? undefined;
314
+ const peerId = isGroup
315
+ ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
316
+ : message.senderId;
317
+
318
+ const route = core.channel.routing.resolveAgentRoute({
319
+ cfg: config,
320
+ channel: "bluebubbles",
321
+ accountId: account.accountId,
322
+ peer: {
323
+ kind: isGroup ? "group" : "direct",
324
+ id: peerId,
325
+ },
326
+ });
327
+
328
+ // Mention gating for group chats (parity with iMessage/WhatsApp)
329
+ const messageText = text;
330
+ const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
331
+ const wasMentioned = isGroup
332
+ ? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes)
333
+ : true;
334
+ const canDetectMention = mentionRegexes.length > 0;
335
+ const requireMention = core.channel.groups.resolveRequireMention({
336
+ cfg: config,
337
+ channel: "bluebubbles",
338
+ groupId: peerId,
339
+ accountId: account.accountId,
340
+ });
341
+
342
+ // Command gating (parity with iMessage/WhatsApp)
343
+ const useAccessGroups = config.commands?.useAccessGroups !== false;
344
+ const hasControlCmd = core.channel.text.hasControlCommand(messageText, config);
345
+ const ownerAllowedForCommands =
346
+ effectiveAllowFrom.length > 0
347
+ ? isAllowedBlueBubblesSender({
348
+ allowFrom: effectiveAllowFrom,
349
+ sender: message.senderId,
350
+ chatId: message.chatId ?? undefined,
351
+ chatGuid: message.chatGuid ?? undefined,
352
+ chatIdentifier: message.chatIdentifier ?? undefined,
353
+ })
354
+ : false;
355
+ const groupAllowedForCommands =
356
+ effectiveGroupAllowFrom.length > 0
357
+ ? isAllowedBlueBubblesSender({
358
+ allowFrom: effectiveGroupAllowFrom,
359
+ sender: message.senderId,
360
+ chatId: message.chatId ?? undefined,
361
+ chatGuid: message.chatGuid ?? undefined,
362
+ chatIdentifier: message.chatIdentifier ?? undefined,
363
+ })
364
+ : false;
365
+ const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
366
+ const commandGate = resolveControlCommandGate({
367
+ useAccessGroups,
368
+ authorizers: [
369
+ { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
370
+ { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
371
+ ],
372
+ allowTextCommands: true,
373
+ hasControlCommand: hasControlCmd,
374
+ });
375
+ const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
376
+
377
+ // Block control commands from unauthorized senders in groups
378
+ if (isGroup && commandGate.shouldBlock) {
379
+ logInboundDrop({
380
+ log: (msg) => logVerbose(core, runtime, msg),
381
+ channel: "bluebubbles",
382
+ reason: "control command (unauthorized)",
383
+ target: message.senderId,
384
+ });
385
+ return;
386
+ }
387
+
388
+ // Allow control commands to bypass mention gating when authorized (parity with iMessage)
389
+ const shouldBypassMention =
390
+ isGroup && requireMention && !wasMentioned && commandAuthorized && hasControlCmd;
391
+ const effectiveWasMentioned = wasMentioned || shouldBypassMention;
392
+
393
+ // Skip group messages that require mention but weren't mentioned
394
+ if (isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
395
+ logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`);
396
+ return;
397
+ }
398
+
399
+ // Cache allowed inbound messages so later replies can resolve sender/body without
400
+ // surfacing dropped content (allowlist/mention/command gating).
401
+ cacheInboundMessage();
402
+
403
+ const baseUrl = account.config.serverUrl?.trim();
404
+ const password = account.config.password?.trim();
405
+ const maxBytes =
406
+ account.config.mediaMaxMb && account.config.mediaMaxMb > 0
407
+ ? account.config.mediaMaxMb * 1024 * 1024
408
+ : 8 * 1024 * 1024;
409
+
410
+ let mediaUrls: string[] = [];
411
+ let mediaPaths: string[] = [];
412
+ let mediaTypes: string[] = [];
413
+ if (attachments.length > 0) {
414
+ if (!baseUrl || !password) {
415
+ logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)");
416
+ } else {
417
+ for (const attachment of attachments) {
418
+ if (!attachment.guid) {
419
+ continue;
420
+ }
421
+ if (attachment.totalBytes && attachment.totalBytes > maxBytes) {
422
+ logVerbose(
423
+ core,
424
+ runtime,
425
+ `attachment too large guid=${attachment.guid} bytes=${attachment.totalBytes}`,
426
+ );
427
+ continue;
428
+ }
429
+ try {
430
+ const downloaded = await downloadBlueBubblesAttachment(attachment, {
431
+ cfg: config,
432
+ accountId: account.accountId,
433
+ maxBytes,
434
+ });
435
+ const saved = await core.channel.media.saveMediaBuffer(
436
+ Buffer.from(downloaded.buffer),
437
+ downloaded.contentType,
438
+ "inbound",
439
+ maxBytes,
440
+ );
441
+ mediaPaths.push(saved.path);
442
+ mediaUrls.push(saved.path);
443
+ if (saved.contentType) {
444
+ mediaTypes.push(saved.contentType);
445
+ }
446
+ } catch (err) {
447
+ logVerbose(
448
+ core,
449
+ runtime,
450
+ `attachment download failed guid=${attachment.guid} err=${String(err)}`,
451
+ );
452
+ }
453
+ }
454
+ }
455
+ }
456
+ let replyToId = message.replyToId;
457
+ let replyToBody = message.replyToBody;
458
+ let replyToSender = message.replyToSender;
459
+ let replyToShortId: string | undefined;
460
+
461
+ if (isTapbackMessage && tapbackContext?.replyToId) {
462
+ replyToId = tapbackContext.replyToId;
463
+ }
464
+
465
+ if (replyToId) {
466
+ const cached = resolveReplyContextFromCache({
467
+ accountId: account.accountId,
468
+ replyToId,
469
+ chatGuid: message.chatGuid,
470
+ chatIdentifier: message.chatIdentifier,
471
+ chatId: message.chatId,
472
+ });
473
+ if (cached) {
474
+ if (!replyToBody && cached.body) {
475
+ replyToBody = cached.body;
476
+ }
477
+ if (!replyToSender && cached.senderLabel) {
478
+ replyToSender = cached.senderLabel;
479
+ }
480
+ replyToShortId = cached.shortId;
481
+ if (core.logging.shouldLogVerbose()) {
482
+ const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
483
+ logVerbose(
484
+ core,
485
+ runtime,
486
+ `reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`,
487
+ );
488
+ }
489
+ }
490
+ }
491
+
492
+ // If no cached short ID, try to get one from the UUID directly
493
+ if (replyToId && !replyToShortId) {
494
+ replyToShortId = getShortIdForUuid(replyToId);
495
+ }
496
+
497
+ // Use inline [[reply_to:N]] tag format
498
+ // For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
499
+ // For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
500
+ const replyTag = formatReplyTag({ replyToId, replyToShortId });
501
+ const baseBody = replyTag
502
+ ? isTapbackMessage
503
+ ? `${rawBody} ${replyTag}`
504
+ : `${replyTag} ${rawBody}`
505
+ : rawBody;
506
+ const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
507
+ const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
508
+ const groupMembers = isGroup
509
+ ? formatGroupMembers({
510
+ participants: message.participants,
511
+ fallback: message.senderId ? { id: message.senderId, name: message.senderName } : undefined,
512
+ })
513
+ : undefined;
514
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
515
+ agentId: route.agentId,
516
+ });
517
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
518
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
519
+ storePath,
520
+ sessionKey: route.sessionKey,
521
+ });
522
+ const body = core.channel.reply.formatAgentEnvelope({
523
+ channel: "BlueBubbles",
524
+ from: fromLabel,
525
+ timestamp: message.timestamp,
526
+ previousTimestamp,
527
+ envelope: envelopeOptions,
528
+ body: baseBody,
529
+ });
530
+ let chatGuidForActions = chatGuid;
531
+ if (!chatGuidForActions && baseUrl && password) {
532
+ const resolveTarget =
533
+ isGroup && (chatId || chatIdentifier)
534
+ ? chatId
535
+ ? ({ kind: "chat_id", chatId } as const)
536
+ : ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const)
537
+ : ({ kind: "handle", address: message.senderId } as const);
538
+ if (resolveTarget.kind !== "chat_identifier" || resolveTarget.chatIdentifier) {
539
+ chatGuidForActions =
540
+ (await resolveChatGuidForTarget({
541
+ baseUrl,
542
+ password,
543
+ target: resolveTarget,
544
+ })) ?? undefined;
545
+ }
546
+ }
547
+
548
+ const ackReactionScope = config.messages?.ackReactionScope ?? "group-mentions";
549
+ const removeAckAfterReply = config.messages?.removeAckAfterReply ?? false;
550
+ const ackReactionValue = resolveBlueBubblesAckReaction({
551
+ cfg: config,
552
+ agentId: route.agentId,
553
+ core,
554
+ runtime,
555
+ });
556
+ const shouldAckReaction = () =>
557
+ Boolean(
558
+ ackReactionValue &&
559
+ core.channel.reactions.shouldAckReaction({
560
+ scope: ackReactionScope,
561
+ isDirect: !isGroup,
562
+ isGroup,
563
+ isMentionableGroup: isGroup,
564
+ requireMention: Boolean(requireMention),
565
+ canDetectMention,
566
+ effectiveWasMentioned,
567
+ shouldBypassMention,
568
+ }),
569
+ );
570
+ const ackMessageId = message.messageId?.trim() || "";
571
+ const ackReactionPromise =
572
+ shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
573
+ ? sendBlueBubblesReaction({
574
+ chatGuid: chatGuidForActions,
575
+ messageGuid: ackMessageId,
576
+ emoji: ackReactionValue,
577
+ opts: { cfg: config, accountId: account.accountId },
578
+ }).then(
579
+ () => true,
580
+ (err) => {
581
+ logVerbose(
582
+ core,
583
+ runtime,
584
+ `ack reaction failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
585
+ );
586
+ return false;
587
+ },
588
+ )
589
+ : null;
590
+
591
+ // Respect sendReadReceipts config (parity with WhatsApp)
592
+ const sendReadReceipts = account.config.sendReadReceipts !== false;
593
+ if (chatGuidForActions && baseUrl && password && sendReadReceipts) {
594
+ try {
595
+ await markBlueBubblesChatRead(chatGuidForActions, {
596
+ cfg: config,
597
+ accountId: account.accountId,
598
+ });
599
+ logVerbose(core, runtime, `marked read chatGuid=${chatGuidForActions}`);
600
+ } catch (err) {
601
+ runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`);
602
+ }
603
+ } else if (!sendReadReceipts) {
604
+ logVerbose(core, runtime, "mark read skipped (sendReadReceipts=false)");
605
+ } else {
606
+ logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)");
607
+ }
608
+
609
+ const outboundTarget = isGroup
610
+ ? formatBlueBubblesChatTarget({
611
+ chatId,
612
+ chatGuid: chatGuidForActions ?? chatGuid,
613
+ chatIdentifier,
614
+ }) || peerId
615
+ : chatGuidForActions
616
+ ? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
617
+ : message.senderId;
618
+
619
+ const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
620
+ const trimmed = messageId?.trim();
621
+ if (!trimmed || trimmed === "ok" || trimmed === "unknown") {
622
+ return;
623
+ }
624
+ // Cache outbound message to get short ID
625
+ const cacheEntry = rememberBlueBubblesReplyCache({
626
+ accountId: account.accountId,
627
+ messageId: trimmed,
628
+ chatGuid: chatGuidForActions ?? chatGuid,
629
+ chatIdentifier,
630
+ chatId,
631
+ senderLabel: "me",
632
+ body: snippet ?? "",
633
+ timestamp: Date.now(),
634
+ });
635
+ const displayId = cacheEntry.shortId || trimmed;
636
+ const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : "";
637
+ core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, {
638
+ sessionKey: route.sessionKey,
639
+ contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
640
+ });
641
+ };
642
+
643
+ const ctxPayload = {
644
+ Body: body,
645
+ BodyForAgent: body,
646
+ RawBody: rawBody,
647
+ CommandBody: rawBody,
648
+ BodyForCommands: rawBody,
649
+ MediaUrl: mediaUrls[0],
650
+ MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
651
+ MediaPath: mediaPaths[0],
652
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
653
+ MediaType: mediaTypes[0],
654
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
655
+ From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
656
+ To: `bluebubbles:${outboundTarget}`,
657
+ SessionKey: route.sessionKey,
658
+ AccountId: route.accountId,
659
+ ChatType: isGroup ? "group" : "direct",
660
+ ConversationLabel: fromLabel,
661
+ // Use short ID for token savings (agent can use this to reference the message)
662
+ ReplyToId: replyToShortId || replyToId,
663
+ ReplyToIdFull: replyToId,
664
+ ReplyToBody: replyToBody,
665
+ ReplyToSender: replyToSender,
666
+ GroupSubject: groupSubject,
667
+ GroupMembers: groupMembers,
668
+ SenderName: message.senderName || undefined,
669
+ SenderId: message.senderId,
670
+ Provider: "bluebubbles",
671
+ Surface: "bluebubbles",
672
+ // Use short ID for token savings (agent can use this to reference the message)
673
+ MessageSid: messageShortId || message.messageId,
674
+ MessageSidFull: message.messageId,
675
+ Timestamp: message.timestamp,
676
+ OriginatingChannel: "bluebubbles",
677
+ OriginatingTo: `bluebubbles:${outboundTarget}`,
678
+ WasMentioned: effectiveWasMentioned,
679
+ CommandAuthorized: commandAuthorized,
680
+ };
681
+
682
+ let sentMessage = false;
683
+ let streamingActive = false;
684
+ let typingRestartTimer: NodeJS.Timeout | undefined;
685
+ const typingRestartDelayMs = 150;
686
+ const clearTypingRestartTimer = () => {
687
+ if (typingRestartTimer) {
688
+ clearTimeout(typingRestartTimer);
689
+ typingRestartTimer = undefined;
690
+ }
691
+ };
692
+ const restartTypingSoon = () => {
693
+ if (!streamingActive || !chatGuidForActions || !baseUrl || !password) {
694
+ return;
695
+ }
696
+ clearTypingRestartTimer();
697
+ typingRestartTimer = setTimeout(() => {
698
+ typingRestartTimer = undefined;
699
+ if (!streamingActive) {
700
+ return;
701
+ }
702
+ sendBlueBubblesTyping(chatGuidForActions, true, {
703
+ cfg: config,
704
+ accountId: account.accountId,
705
+ }).catch((err) => {
706
+ runtime.error?.(`[bluebubbles] typing restart failed: ${String(err)}`);
707
+ });
708
+ }, typingRestartDelayMs);
709
+ };
710
+ try {
711
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
712
+ cfg: config,
713
+ agentId: route.agentId,
714
+ channel: "bluebubbles",
715
+ accountId: account.accountId,
716
+ });
717
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
718
+ ctx: ctxPayload,
719
+ cfg: config,
720
+ dispatcherOptions: {
721
+ ...prefixOptions,
722
+ deliver: async (payload, info) => {
723
+ const rawReplyToId =
724
+ typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
725
+ // Resolve short ID (e.g., "5") to full UUID
726
+ const replyToMessageGuid = rawReplyToId
727
+ ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
728
+ : "";
729
+ const mediaList = payload.mediaUrls?.length
730
+ ? payload.mediaUrls
731
+ : payload.mediaUrl
732
+ ? [payload.mediaUrl]
733
+ : [];
734
+ if (mediaList.length > 0) {
735
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
736
+ cfg: config,
737
+ channel: "bluebubbles",
738
+ accountId: account.accountId,
739
+ });
740
+ const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
741
+ let first = true;
742
+ for (const mediaUrl of mediaList) {
743
+ const caption = first ? text : undefined;
744
+ first = false;
745
+ const result = await sendBlueBubblesMedia({
746
+ cfg: config,
747
+ to: outboundTarget,
748
+ mediaUrl,
749
+ caption: caption ?? undefined,
750
+ replyToId: replyToMessageGuid || null,
751
+ accountId: account.accountId,
752
+ });
753
+ const cachedBody = (caption ?? "").trim() || "<media:attachment>";
754
+ maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
755
+ sentMessage = true;
756
+ statusSink?.({ lastOutboundAt: Date.now() });
757
+ if (info.kind === "block") {
758
+ restartTypingSoon();
759
+ }
760
+ }
761
+ return;
762
+ }
763
+
764
+ const textLimit =
765
+ account.config.textChunkLimit && account.config.textChunkLimit > 0
766
+ ? account.config.textChunkLimit
767
+ : DEFAULT_TEXT_LIMIT;
768
+ const chunkMode = account.config.chunkMode ?? "length";
769
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
770
+ cfg: config,
771
+ channel: "bluebubbles",
772
+ accountId: account.accountId,
773
+ });
774
+ const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
775
+ const chunks =
776
+ chunkMode === "newline"
777
+ ? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
778
+ : core.channel.text.chunkMarkdownText(text, textLimit);
779
+ if (!chunks.length && text) {
780
+ chunks.push(text);
781
+ }
782
+ if (!chunks.length) {
783
+ return;
784
+ }
785
+ for (const chunk of chunks) {
786
+ const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
787
+ cfg: config,
788
+ accountId: account.accountId,
789
+ replyToMessageGuid: replyToMessageGuid || undefined,
790
+ });
791
+ maybeEnqueueOutboundMessageId(result.messageId, chunk);
792
+ sentMessage = true;
793
+ statusSink?.({ lastOutboundAt: Date.now() });
794
+ if (info.kind === "block") {
795
+ restartTypingSoon();
796
+ }
797
+ }
798
+ },
799
+ onReplyStart: async () => {
800
+ if (!chatGuidForActions) {
801
+ return;
802
+ }
803
+ if (!baseUrl || !password) {
804
+ return;
805
+ }
806
+ streamingActive = true;
807
+ clearTypingRestartTimer();
808
+ try {
809
+ await sendBlueBubblesTyping(chatGuidForActions, true, {
810
+ cfg: config,
811
+ accountId: account.accountId,
812
+ });
813
+ } catch (err) {
814
+ runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
815
+ }
816
+ },
817
+ onIdle: async () => {
818
+ if (!chatGuidForActions) {
819
+ return;
820
+ }
821
+ if (!baseUrl || !password) {
822
+ return;
823
+ }
824
+ // Intentionally no-op for block streaming. We stop typing in finally
825
+ // after the run completes to avoid flicker between paragraph blocks.
826
+ },
827
+ onError: (err, info) => {
828
+ runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
829
+ },
830
+ },
831
+ replyOptions: {
832
+ onModelSelected,
833
+ disableBlockStreaming:
834
+ typeof account.config.blockStreaming === "boolean"
835
+ ? !account.config.blockStreaming
836
+ : undefined,
837
+ },
838
+ });
839
+ } finally {
840
+ const shouldStopTyping =
841
+ Boolean(chatGuidForActions && baseUrl && password) && (streamingActive || !sentMessage);
842
+ streamingActive = false;
843
+ clearTypingRestartTimer();
844
+ if (sentMessage && chatGuidForActions && ackMessageId) {
845
+ core.channel.reactions.removeAckReactionAfterReply({
846
+ removeAfterReply: removeAckAfterReply,
847
+ ackReactionPromise,
848
+ ackReactionValue: ackReactionValue ?? null,
849
+ remove: () =>
850
+ sendBlueBubblesReaction({
851
+ chatGuid: chatGuidForActions,
852
+ messageGuid: ackMessageId,
853
+ emoji: ackReactionValue ?? "",
854
+ remove: true,
855
+ opts: { cfg: config, accountId: account.accountId },
856
+ }),
857
+ onError: (err) => {
858
+ logAckFailure({
859
+ log: (msg) => logVerbose(core, runtime, msg),
860
+ channel: "bluebubbles",
861
+ target: `${chatGuidForActions}/${ackMessageId}`,
862
+ error: err,
863
+ });
864
+ },
865
+ });
866
+ }
867
+ if (shouldStopTyping && chatGuidForActions) {
868
+ // Stop typing after streaming completes to avoid a stuck indicator.
869
+ sendBlueBubblesTyping(chatGuidForActions, false, {
870
+ cfg: config,
871
+ accountId: account.accountId,
872
+ }).catch((err) => {
873
+ logTypingFailure({
874
+ log: (msg) => logVerbose(core, runtime, msg),
875
+ channel: "bluebubbles",
876
+ action: "stop",
877
+ target: chatGuidForActions,
878
+ error: err,
879
+ });
880
+ });
881
+ }
882
+ }
883
+ }
884
+
885
+ export async function processReaction(
886
+ reaction: NormalizedWebhookReaction,
887
+ target: WebhookTarget,
888
+ ): Promise<void> {
889
+ const { account, config, runtime, core } = target;
890
+ if (reaction.fromMe) {
891
+ return;
892
+ }
893
+
894
+ const dmPolicy = account.config.dmPolicy ?? "pairing";
895
+ const groupPolicy = account.config.groupPolicy ?? "allowlist";
896
+ const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
897
+ const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
898
+ const storeAllowFrom = await core.channel.pairing
899
+ .readAllowFromStore("bluebubbles")
900
+ .catch(() => []);
901
+ const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
902
+ .map((entry) => String(entry).trim())
903
+ .filter(Boolean);
904
+ const effectiveGroupAllowFrom = [
905
+ ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
906
+ ...storeAllowFrom,
907
+ ]
908
+ .map((entry) => String(entry).trim())
909
+ .filter(Boolean);
910
+
911
+ if (reaction.isGroup) {
912
+ if (groupPolicy === "disabled") {
913
+ return;
914
+ }
915
+ if (groupPolicy === "allowlist") {
916
+ if (effectiveGroupAllowFrom.length === 0) {
917
+ return;
918
+ }
919
+ const allowed = isAllowedBlueBubblesSender({
920
+ allowFrom: effectiveGroupAllowFrom,
921
+ sender: reaction.senderId,
922
+ chatId: reaction.chatId ?? undefined,
923
+ chatGuid: reaction.chatGuid ?? undefined,
924
+ chatIdentifier: reaction.chatIdentifier ?? undefined,
925
+ });
926
+ if (!allowed) {
927
+ return;
928
+ }
929
+ }
930
+ } else {
931
+ if (dmPolicy === "disabled") {
932
+ return;
933
+ }
934
+ if (dmPolicy !== "open") {
935
+ const allowed = isAllowedBlueBubblesSender({
936
+ allowFrom: effectiveAllowFrom,
937
+ sender: reaction.senderId,
938
+ chatId: reaction.chatId ?? undefined,
939
+ chatGuid: reaction.chatGuid ?? undefined,
940
+ chatIdentifier: reaction.chatIdentifier ?? undefined,
941
+ });
942
+ if (!allowed) {
943
+ return;
944
+ }
945
+ }
946
+ }
947
+
948
+ const chatId = reaction.chatId ?? undefined;
949
+ const chatGuid = reaction.chatGuid ?? undefined;
950
+ const chatIdentifier = reaction.chatIdentifier ?? undefined;
951
+ const peerId = reaction.isGroup
952
+ ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
953
+ : reaction.senderId;
954
+
955
+ const route = core.channel.routing.resolveAgentRoute({
956
+ cfg: config,
957
+ channel: "bluebubbles",
958
+ accountId: account.accountId,
959
+ peer: {
960
+ kind: reaction.isGroup ? "group" : "direct",
961
+ id: peerId,
962
+ },
963
+ });
964
+
965
+ const senderLabel = reaction.senderName || reaction.senderId;
966
+ const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
967
+ // Use short ID for token savings
968
+ const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
969
+ // Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
970
+ const text =
971
+ reaction.action === "removed"
972
+ ? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}`
973
+ : `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`;
974
+ core.system.enqueueSystemEvent(text, {
975
+ sessionKey: route.sessionKey,
976
+ contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
977
+ });
978
+ logVerbose(core, runtime, `reaction event enqueued: ${text}`);
979
+ }