@openclaw/bluebubbles 2026.1.29 → 2026.2.1

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/monitor.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
-
3
2
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
3
  import {
5
4
  logAckFailure,
@@ -8,16 +7,20 @@ import {
8
7
  resolveAckReaction,
9
8
  resolveControlCommandGate,
10
9
  } from "openclaw/plugin-sdk";
11
- import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
12
- import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
10
+ import type { ResolvedBlueBubblesAccount } from "./accounts.js";
11
+ import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
13
12
  import { downloadBlueBubblesAttachment } from "./attachments.js";
14
- import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js";
13
+ import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
15
14
  import { sendBlueBubblesMedia } from "./media-send.js";
16
- import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
17
- import type { ResolvedBlueBubblesAccount } from "./accounts.js";
18
- import { getBlueBubblesRuntime } from "./runtime.js";
19
- import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
20
15
  import { fetchBlueBubblesServerInfo } from "./probe.js";
16
+ import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
17
+ import { getBlueBubblesRuntime } from "./runtime.js";
18
+ import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
19
+ import {
20
+ formatBlueBubblesChatTarget,
21
+ isAllowedBlueBubblesSender,
22
+ normalizeBlueBubblesHandle,
23
+ } from "./targets.js";
21
24
 
22
25
  export type BlueBubblesRuntimeEnv = {
23
26
  log?: (message: string) => void;
@@ -108,7 +111,9 @@ function rememberBlueBubblesReplyCache(
108
111
  }
109
112
  while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
110
113
  const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
111
- if (!oldest) break;
114
+ if (!oldest) {
115
+ break;
116
+ }
112
117
  const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
113
118
  blueBubblesReplyCacheByMessageId.delete(oldest);
114
119
  // Clean up short ID mappings for evicted entries
@@ -130,12 +135,16 @@ export function resolveBlueBubblesMessageId(
130
135
  opts?: { requireKnownShortId?: boolean },
131
136
  ): string {
132
137
  const trimmed = shortOrUuid.trim();
133
- if (!trimmed) return trimmed;
138
+ if (!trimmed) {
139
+ return trimmed;
140
+ }
134
141
 
135
142
  // If it looks like a short ID (numeric), try to resolve it
136
143
  if (/^\d+$/.test(trimmed)) {
137
144
  const uuid = blueBubblesShortIdToUuid.get(trimmed);
138
- if (uuid) return uuid;
145
+ if (uuid) {
146
+ return uuid;
147
+ }
139
148
  if (opts?.requireKnownShortId) {
140
149
  throw new Error(
141
150
  `BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
@@ -173,11 +182,17 @@ function resolveReplyContextFromCache(params: {
173
182
  chatId?: number;
174
183
  }): BlueBubblesReplyCacheEntry | null {
175
184
  const replyToId = params.replyToId.trim();
176
- if (!replyToId) return null;
185
+ if (!replyToId) {
186
+ return null;
187
+ }
177
188
 
178
189
  const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
179
- if (!cached) return null;
180
- if (cached.accountId !== params.accountId) return null;
190
+ if (!cached) {
191
+ return null;
192
+ }
193
+ if (cached.accountId !== params.accountId) {
194
+ return null;
195
+ }
181
196
 
182
197
  const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
183
198
  if (cached.timestamp < cutoff) {
@@ -193,8 +208,15 @@ function resolveReplyContextFromCache(params: {
193
208
  const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
194
209
 
195
210
  // Avoid cross-chat collisions if we have identifiers.
196
- if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) return null;
197
- if (!chatGuid && chatIdentifier && cachedChatIdentifier && chatIdentifier !== cachedChatIdentifier) {
211
+ if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) {
212
+ return null;
213
+ }
214
+ if (
215
+ !chatGuid &&
216
+ chatIdentifier &&
217
+ cachedChatIdentifier &&
218
+ chatIdentifier !== cachedChatIdentifier
219
+ ) {
198
220
  return null;
199
221
  }
200
222
  if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) {
@@ -206,7 +228,11 @@ function resolveReplyContextFromCache(params: {
206
228
 
207
229
  type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
208
230
 
209
- function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string): void {
231
+ function logVerbose(
232
+ core: BlueBubblesCoreRuntime,
233
+ runtime: BlueBubblesRuntimeEnv,
234
+ message: string,
235
+ ): void {
210
236
  if (core.logging.shouldLogVerbose()) {
211
237
  runtime.log?.(`[bluebubbles] ${message}`);
212
238
  }
@@ -264,7 +290,7 @@ type BlueBubblesDebounceEntry = {
264
290
  * This helps combine URL text + link preview balloon messages that BlueBubbles
265
291
  * sends as separate webhook events when no explicit inbound debounce config exists.
266
292
  */
267
- const DEFAULT_INBOUND_DEBOUNCE_MS = 350;
293
+ const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
268
294
 
269
295
  /**
270
296
  * Combines multiple debounced messages into a single message for processing.
@@ -284,13 +310,17 @@ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): Normalized
284
310
  // Combine text from all entries, filtering out duplicates and empty strings
285
311
  const seenTexts = new Set<string>();
286
312
  const textParts: string[] = [];
287
-
313
+
288
314
  for (const entry of entries) {
289
315
  const text = entry.message.text.trim();
290
- if (!text) continue;
316
+ if (!text) {
317
+ continue;
318
+ }
291
319
  // Skip duplicate text (URL might be in both text message and balloon)
292
320
  const normalizedText = text.toLowerCase();
293
- if (seenTexts.has(normalizedText)) continue;
321
+ if (seenTexts.has(normalizedText)) {
322
+ continue;
323
+ }
294
324
  seenTexts.add(normalizedText);
295
325
  textParts.push(text);
296
326
  }
@@ -346,7 +376,9 @@ function resolveBlueBubblesDebounceMs(
346
376
  const inbound = config.messages?.inbound;
347
377
  const hasExplicitDebounce =
348
378
  typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
349
- if (!hasExplicitDebounce) return DEFAULT_INBOUND_DEBOUNCE_MS;
379
+ if (!hasExplicitDebounce) {
380
+ return DEFAULT_INBOUND_DEBOUNCE_MS;
381
+ }
350
382
  return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
351
383
  }
352
384
 
@@ -355,7 +387,9 @@ function resolveBlueBubblesDebounceMs(
355
387
  */
356
388
  function getOrCreateDebouncer(target: WebhookTarget) {
357
389
  const existing = targetDebouncers.get(target);
358
- if (existing) return existing;
390
+ if (existing) {
391
+ return existing;
392
+ }
359
393
 
360
394
  const { account, config, runtime, core } = target;
361
395
 
@@ -363,7 +397,23 @@ function getOrCreateDebouncer(target: WebhookTarget) {
363
397
  debounceMs: resolveBlueBubblesDebounceMs(config, core),
364
398
  buildKey: (entry) => {
365
399
  const msg = entry.message;
366
- // Build key from account + chat + sender to coalesce messages from same source
400
+ // Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
401
+ // same message (e.g., text-only then text+attachment).
402
+ //
403
+ // For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
404
+ // messageId than the originating text. When present, key by associatedMessageGuid
405
+ // to keep text + balloon coalescing working.
406
+ const balloonBundleId = msg.balloonBundleId?.trim();
407
+ const associatedMessageGuid = msg.associatedMessageGuid?.trim();
408
+ if (balloonBundleId && associatedMessageGuid) {
409
+ return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
410
+ }
411
+
412
+ const messageId = msg.messageId?.trim();
413
+ if (messageId) {
414
+ return `bluebubbles:${account.accountId}:msg:${messageId}`;
415
+ }
416
+
367
417
  const chatKey =
368
418
  msg.chatGuid?.trim() ??
369
419
  msg.chatIdentifier?.trim() ??
@@ -372,21 +422,26 @@ function getOrCreateDebouncer(target: WebhookTarget) {
372
422
  },
373
423
  shouldDebounce: (entry) => {
374
424
  const msg = entry.message;
375
- // Skip debouncing for messages with attachments - process immediately
376
- if (msg.attachments && msg.attachments.length > 0) return false;
377
425
  // Skip debouncing for from-me messages (they're just cached, not processed)
378
- if (msg.fromMe) return false;
426
+ if (msg.fromMe) {
427
+ return false;
428
+ }
379
429
  // Skip debouncing for control commands - process immediately
380
- if (core.channel.text.hasControlCommand(msg.text, config)) return false;
381
- // Debounce normal text messages and URL balloon messages
430
+ if (core.channel.text.hasControlCommand(msg.text, config)) {
431
+ return false;
432
+ }
433
+ // Debounce all other messages to coalesce rapid-fire webhook events
434
+ // (e.g., text+image arriving as separate webhooks for the same messageId)
382
435
  return true;
383
436
  },
384
437
  onFlush: async (entries) => {
385
- if (entries.length === 0) return;
386
-
438
+ if (entries.length === 0) {
439
+ return;
440
+ }
441
+
387
442
  // Use target from first entry (all entries have same target due to key structure)
388
443
  const flushTarget = entries[0].target;
389
-
444
+
390
445
  if (entries.length === 1) {
391
446
  // Single message - process normally
392
447
  await processMessage(entries[0].message, flushTarget);
@@ -395,7 +450,7 @@ function getOrCreateDebouncer(target: WebhookTarget) {
395
450
 
396
451
  // Multiple messages - combine and process
397
452
  const combined = combineDebounceEntries(entries);
398
-
453
+
399
454
  if (core.logging.shouldLogVerbose()) {
400
455
  const count = entries.length;
401
456
  const preview = combined.text.slice(0, 50);
@@ -403,7 +458,7 @@ function getOrCreateDebouncer(target: WebhookTarget) {
403
458
  `[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
404
459
  );
405
460
  }
406
-
461
+
407
462
  await processMessage(combined, flushTarget);
408
463
  },
409
464
  onError: (err) => {
@@ -424,7 +479,9 @@ function removeDebouncer(target: WebhookTarget): void {
424
479
 
425
480
  function normalizeWebhookPath(raw: string): string {
426
481
  const trimmed = raw.trim();
427
- if (!trimmed) return "/";
482
+ if (!trimmed) {
483
+ return "/";
484
+ }
428
485
  const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
429
486
  if (withSlash.length > 1 && withSlash.endsWith("/")) {
430
487
  return withSlash.slice(0, -1);
@@ -499,30 +556,40 @@ function asRecord(value: unknown): Record<string, unknown> | null {
499
556
  }
500
557
 
501
558
  function readString(record: Record<string, unknown> | null, key: string): string | undefined {
502
- if (!record) return undefined;
559
+ if (!record) {
560
+ return undefined;
561
+ }
503
562
  const value = record[key];
504
563
  return typeof value === "string" ? value : undefined;
505
564
  }
506
565
 
507
566
  function readNumber(record: Record<string, unknown> | null, key: string): number | undefined {
508
- if (!record) return undefined;
567
+ if (!record) {
568
+ return undefined;
569
+ }
509
570
  const value = record[key];
510
571
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
511
572
  }
512
573
 
513
574
  function readBoolean(record: Record<string, unknown> | null, key: string): boolean | undefined {
514
- if (!record) return undefined;
575
+ if (!record) {
576
+ return undefined;
577
+ }
515
578
  const value = record[key];
516
579
  return typeof value === "boolean" ? value : undefined;
517
580
  }
518
581
 
519
582
  function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
520
583
  const raw = message["attachments"];
521
- if (!Array.isArray(raw)) return [];
584
+ if (!Array.isArray(raw)) {
585
+ return [];
586
+ }
522
587
  const out: BlueBubblesAttachment[] = [];
523
588
  for (const entry of raw) {
524
589
  const record = asRecord(entry);
525
- if (!record) continue;
590
+ if (!record) {
591
+ continue;
592
+ }
526
593
  out.push({
527
594
  guid: readString(record, "guid"),
528
595
  uti: readString(record, "uti"),
@@ -538,7 +605,9 @@ function extractAttachments(message: Record<string, unknown>): BlueBubblesAttach
538
605
  }
539
606
 
540
607
  function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string {
541
- if (attachments.length === 0) return "";
608
+ if (attachments.length === 0) {
609
+ return "";
610
+ }
542
611
  const mimeTypes = attachments.map((entry) => entry.mimeType ?? "");
543
612
  const allImages = mimeTypes.every((entry) => entry.startsWith("image/"));
544
613
  const allVideos = mimeTypes.every((entry) => entry.startsWith("video/"));
@@ -557,29 +626,38 @@ function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): strin
557
626
 
558
627
  function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
559
628
  const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []);
560
- if (attachmentPlaceholder) return attachmentPlaceholder;
561
- if (message.balloonBundleId) return "<media:sticker>";
629
+ if (attachmentPlaceholder) {
630
+ return attachmentPlaceholder;
631
+ }
632
+ if (message.balloonBundleId) {
633
+ return "<media:sticker>";
634
+ }
562
635
  return "";
563
636
  }
564
637
 
565
638
  // Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
566
- function formatReplyTag(message: {
567
- replyToId?: string;
568
- replyToShortId?: string;
569
- }): string | null {
639
+ function formatReplyTag(message: { replyToId?: string; replyToShortId?: string }): string | null {
570
640
  // Prefer short ID
571
641
  const rawId = message.replyToShortId || message.replyToId;
572
- if (!rawId) return null;
642
+ if (!rawId) {
643
+ return null;
644
+ }
573
645
  return `[[reply_to:${rawId}]]`;
574
646
  }
575
647
 
576
648
  function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
577
- if (!record) return undefined;
649
+ if (!record) {
650
+ return undefined;
651
+ }
578
652
  const value = record[key];
579
- if (typeof value === "number" && Number.isFinite(value)) return value;
653
+ if (typeof value === "number" && Number.isFinite(value)) {
654
+ return value;
655
+ }
580
656
  if (typeof value === "string") {
581
657
  const parsed = Number.parseFloat(value);
582
- if (Number.isFinite(parsed)) return parsed;
658
+ if (Number.isFinite(parsed)) {
659
+ return parsed;
660
+ }
583
661
  }
584
662
  return undefined;
585
663
  }
@@ -599,7 +677,8 @@ function extractReplyMetadata(message: Record<string, unknown>): {
599
677
  message["associatedMessage"] ??
600
678
  message["reply"];
601
679
  const replyRecord = asRecord(replyRaw);
602
- const replyHandle = asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null;
680
+ const replyHandle =
681
+ asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null;
603
682
  const replySenderRaw =
604
683
  readString(replyHandle, "address") ??
605
684
  readString(replyHandle, "handle") ??
@@ -657,7 +736,9 @@ function extractReplyMetadata(message: Record<string, unknown>): {
657
736
 
658
737
  function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
659
738
  const chats = message["chats"];
660
- if (!Array.isArray(chats) || chats.length === 0) return null;
739
+ if (!Array.isArray(chats) || chats.length === 0) {
740
+ return null;
741
+ }
661
742
  const first = chats[0];
662
743
  return asRecord(first);
663
744
  }
@@ -665,12 +746,16 @@ function readFirstChatRecord(message: Record<string, unknown>): Record<string, u
665
746
  function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
666
747
  if (typeof entry === "string" || typeof entry === "number") {
667
748
  const raw = String(entry).trim();
668
- if (!raw) return null;
749
+ if (!raw) {
750
+ return null;
751
+ }
669
752
  const normalized = normalizeBlueBubblesHandle(raw) || raw;
670
753
  return normalized ? { id: normalized } : null;
671
754
  }
672
755
  const record = asRecord(entry);
673
- if (!record) return null;
756
+ if (!record) {
757
+ return null;
758
+ }
674
759
  const nestedHandle =
675
760
  asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
676
761
  const idRaw =
@@ -690,20 +775,28 @@ function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | nul
690
775
  readString(nestedHandle, "displayName") ??
691
776
  readString(nestedHandle, "name");
692
777
  const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
693
- if (!normalizedId) return null;
778
+ if (!normalizedId) {
779
+ return null;
780
+ }
694
781
  const name = nameRaw?.trim() || undefined;
695
782
  return { id: normalizedId, name };
696
783
  }
697
784
 
698
785
  function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
699
- if (!Array.isArray(raw) || raw.length === 0) return [];
786
+ if (!Array.isArray(raw) || raw.length === 0) {
787
+ return [];
788
+ }
700
789
  const seen = new Set<string>();
701
790
  const output: BlueBubblesParticipant[] = [];
702
791
  for (const entry of raw) {
703
792
  const normalized = normalizeParticipantEntry(entry);
704
- if (!normalized?.id) continue;
793
+ if (!normalized?.id) {
794
+ continue;
795
+ }
705
796
  const key = normalized.id.toLowerCase();
706
- if (seen.has(key)) continue;
797
+ if (seen.has(key)) {
798
+ continue;
799
+ }
707
800
  seen.add(key);
708
801
  output.push(normalized);
709
802
  }
@@ -717,39 +810,57 @@ function formatGroupMembers(params: {
717
810
  const seen = new Set<string>();
718
811
  const ordered: BlueBubblesParticipant[] = [];
719
812
  for (const entry of params.participants ?? []) {
720
- if (!entry?.id) continue;
813
+ if (!entry?.id) {
814
+ continue;
815
+ }
721
816
  const key = entry.id.toLowerCase();
722
- if (seen.has(key)) continue;
817
+ if (seen.has(key)) {
818
+ continue;
819
+ }
723
820
  seen.add(key);
724
821
  ordered.push(entry);
725
822
  }
726
823
  if (ordered.length === 0 && params.fallback?.id) {
727
824
  ordered.push(params.fallback);
728
825
  }
729
- if (ordered.length === 0) return undefined;
730
- return ordered
731
- .map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id))
732
- .join(", ");
826
+ if (ordered.length === 0) {
827
+ return undefined;
828
+ }
829
+ return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", ");
733
830
  }
734
831
 
735
832
  function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
736
833
  const guid = chatGuid?.trim();
737
- if (!guid) return undefined;
834
+ if (!guid) {
835
+ return undefined;
836
+ }
738
837
  const parts = guid.split(";");
739
838
  if (parts.length >= 3) {
740
- if (parts[1] === "+") return true;
741
- if (parts[1] === "-") return false;
839
+ if (parts[1] === "+") {
840
+ return true;
841
+ }
842
+ if (parts[1] === "-") {
843
+ return false;
844
+ }
845
+ }
846
+ if (guid.includes(";+;")) {
847
+ return true;
848
+ }
849
+ if (guid.includes(";-;")) {
850
+ return false;
742
851
  }
743
- if (guid.includes(";+;")) return true;
744
- if (guid.includes(";-;")) return false;
745
852
  return undefined;
746
853
  }
747
854
 
748
855
  function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
749
856
  const guid = chatGuid?.trim();
750
- if (!guid) return undefined;
857
+ if (!guid) {
858
+ return undefined;
859
+ }
751
860
  const parts = guid.split(";");
752
- if (parts.length < 3) return undefined;
861
+ if (parts.length < 3) {
862
+ return undefined;
863
+ }
753
864
  const identifier = parts[2]?.trim();
754
865
  return identifier || undefined;
755
866
  }
@@ -760,11 +871,17 @@ function formatGroupAllowlistEntry(params: {
760
871
  chatIdentifier?: string;
761
872
  }): string | null {
762
873
  const guid = params.chatGuid?.trim();
763
- if (guid) return `chat_guid:${guid}`;
874
+ if (guid) {
875
+ return `chat_guid:${guid}`;
876
+ }
764
877
  const chatId = params.chatId;
765
- if (typeof chatId === "number" && Number.isFinite(chatId)) return `chat_id:${chatId}`;
878
+ if (typeof chatId === "number" && Number.isFinite(chatId)) {
879
+ return `chat_id:${chatId}`;
880
+ }
766
881
  const identifier = params.chatIdentifier?.trim();
767
- if (identifier) return `chat_identifier:${identifier}`;
882
+ if (identifier) {
883
+ return `chat_identifier:${identifier}`;
884
+ }
768
885
  return null;
769
886
  }
770
887
 
@@ -862,9 +979,15 @@ function isTapbackAssociatedType(type: number | undefined): boolean {
862
979
  }
863
980
 
864
981
  function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
865
- if (typeof type !== "number" || !Number.isFinite(type)) return undefined;
866
- if (type >= 3000 && type < 4000) return "removed";
867
- if (type >= 2000 && type < 3000) return "added";
982
+ if (typeof type !== "number" || !Number.isFinite(type)) {
983
+ return undefined;
984
+ }
985
+ if (type >= 3000 && type < 4000) {
986
+ return "removed";
987
+ }
988
+ if (type >= 2000 && type < 3000) {
989
+ return "added";
990
+ }
868
991
  return undefined;
869
992
  }
870
993
 
@@ -876,7 +999,9 @@ function resolveTapbackContext(message: NormalizedWebhookMessage): {
876
999
  const associatedType = message.associatedMessageType;
877
1000
  const hasTapbackType = isTapbackAssociatedType(associatedType);
878
1001
  const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
879
- if (!hasTapbackType && !hasTapbackMarker) return null;
1002
+ if (!hasTapbackType && !hasTapbackMarker) {
1003
+ return null;
1004
+ }
880
1005
  const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
881
1006
  const actionHint = resolveTapbackActionHint(associatedType);
882
1007
  const emojiHint =
@@ -897,7 +1022,9 @@ function parseTapbackText(params: {
897
1022
  } | null {
898
1023
  const trimmed = params.text.trim();
899
1024
  const lower = trimmed.toLowerCase();
900
- if (!trimmed) return null;
1025
+ if (!trimmed) {
1026
+ return null;
1027
+ }
901
1028
 
902
1029
  for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
903
1030
  if (lower.startsWith(pattern)) {
@@ -905,7 +1032,9 @@ function parseTapbackText(params: {
905
1032
  const afterPattern = trimmed.slice(pattern.length).trim();
906
1033
  if (params.requireQuoted) {
907
1034
  const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
908
- if (!strictMatch) return null;
1035
+ if (!strictMatch) {
1036
+ return null;
1037
+ }
909
1038
  return { emoji, action, quotedText: strictMatch[1] };
910
1039
  }
911
1040
  const quotedText =
@@ -916,18 +1045,26 @@ function parseTapbackText(params: {
916
1045
 
917
1046
  if (lower.startsWith("reacted")) {
918
1047
  const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
919
- if (!emoji) return null;
1048
+ if (!emoji) {
1049
+ return null;
1050
+ }
920
1051
  const quotedText = extractQuotedTapbackText(trimmed);
921
- if (params.requireQuoted && !quotedText) return null;
1052
+ if (params.requireQuoted && !quotedText) {
1053
+ return null;
1054
+ }
922
1055
  const fallback = trimmed.slice("reacted".length).trim();
923
1056
  return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
924
1057
  }
925
1058
 
926
1059
  if (lower.startsWith("removed")) {
927
1060
  const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
928
- if (!emoji) return null;
1061
+ if (!emoji) {
1062
+ return null;
1063
+ }
929
1064
  const quotedText = extractQuotedTapbackText(trimmed);
930
- if (params.requireQuoted && !quotedText) return null;
1065
+ if (params.requireQuoted && !quotedText) {
1066
+ return null;
1067
+ }
931
1068
  const fallback = trimmed.slice("removed".length).trim();
932
1069
  return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
933
1070
  }
@@ -935,7 +1072,9 @@ function parseTapbackText(params: {
935
1072
  }
936
1073
 
937
1074
  function maskSecret(value: string): string {
938
- if (value.length <= 6) return "***";
1075
+ if (value.length <= 6) {
1076
+ return "***";
1077
+ }
939
1078
  return `${value.slice(0, 2)}***${value.slice(-2)}`;
940
1079
  }
941
1080
 
@@ -946,7 +1085,9 @@ function resolveBlueBubblesAckReaction(params: {
946
1085
  runtime: BlueBubblesRuntimeEnv;
947
1086
  }): string | null {
948
1087
  const raw = resolveAckReaction(params.cfg, params.agentId).trim();
949
- if (!raw) return null;
1088
+ if (!raw) {
1089
+ return null;
1090
+ }
950
1091
  try {
951
1092
  normalizeBlueBubblesReactionInput(raw);
952
1093
  return raw;
@@ -973,13 +1114,19 @@ function extractMessagePayload(payload: Record<string, unknown>): Record<string,
973
1114
  const message =
974
1115
  asRecord(messageRaw) ??
975
1116
  (typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null);
976
- if (!message) return null;
1117
+ if (!message) {
1118
+ return null;
1119
+ }
977
1120
  return message;
978
1121
  }
979
1122
 
980
- function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWebhookMessage | null {
1123
+ function normalizeWebhookMessage(
1124
+ payload: Record<string, unknown>,
1125
+ ): NormalizedWebhookMessage | null {
981
1126
  const message = extractMessagePayload(payload);
982
- if (!message) return null;
1127
+ if (!message) {
1128
+ return null;
1129
+ }
983
1130
 
984
1131
  const text =
985
1132
  readString(message, "text") ??
@@ -989,8 +1136,7 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
989
1136
 
990
1137
  const handleValue = message.handle ?? message.sender;
991
1138
  const handle =
992
- asRecord(handleValue) ??
993
- (typeof handleValue === "string" ? { address: handleValue } : null);
1139
+ asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
994
1140
  const senderId =
995
1141
  readString(handle, "address") ??
996
1142
  readString(handle, "handle") ??
@@ -1065,7 +1211,7 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
1065
1211
  const isGroup =
1066
1212
  typeof groupFromChatGuid === "boolean"
1067
1213
  ? groupFromChatGuid
1068
- : explicitIsGroup ?? (participantsCount > 2 ? true : false);
1214
+ : (explicitIsGroup ?? participantsCount > 2);
1069
1215
 
1070
1216
  const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
1071
1217
  const messageId =
@@ -1106,7 +1252,9 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
1106
1252
  : undefined;
1107
1253
 
1108
1254
  const normalizedSender = normalizeBlueBubblesHandle(senderId);
1109
- if (!normalizedSender) return null;
1255
+ if (!normalizedSender) {
1256
+ return null;
1257
+ }
1110
1258
  const replyMetadata = extractReplyMetadata(message);
1111
1259
 
1112
1260
  return {
@@ -1134,9 +1282,13 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
1134
1282
  };
1135
1283
  }
1136
1284
 
1137
- function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedWebhookReaction | null {
1285
+ function normalizeWebhookReaction(
1286
+ payload: Record<string, unknown>,
1287
+ ): NormalizedWebhookReaction | null {
1138
1288
  const message = extractMessagePayload(payload);
1139
- if (!message) return null;
1289
+ if (!message) {
1290
+ return null;
1291
+ }
1140
1292
 
1141
1293
  const associatedGuid =
1142
1294
  readString(message, "associatedMessageGuid") ??
@@ -1145,7 +1297,9 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
1145
1297
  const associatedType =
1146
1298
  readNumberLike(message, "associatedMessageType") ??
1147
1299
  readNumberLike(message, "associated_message_type");
1148
- if (!associatedGuid || associatedType === undefined) return null;
1300
+ if (!associatedGuid || associatedType === undefined) {
1301
+ return null;
1302
+ }
1149
1303
 
1150
1304
  const mapping = REACTION_TYPE_MAP.get(associatedType);
1151
1305
  const associatedEmoji =
@@ -1158,8 +1312,7 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
1158
1312
 
1159
1313
  const handleValue = message.handle ?? message.sender;
1160
1314
  const handle =
1161
- asRecord(handleValue) ??
1162
- (typeof handleValue === "string" ? { address: handleValue } : null);
1315
+ asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
1163
1316
  const senderId =
1164
1317
  readString(handle, "address") ??
1165
1318
  readString(handle, "handle") ??
@@ -1232,7 +1385,7 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
1232
1385
  const isGroup =
1233
1386
  typeof groupFromChatGuid === "boolean"
1234
1387
  ? groupFromChatGuid
1235
- : explicitIsGroup ?? (participantsCount > 2 ? true : false);
1388
+ : (explicitIsGroup ?? participantsCount > 2);
1236
1389
 
1237
1390
  const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
1238
1391
  const timestampRaw =
@@ -1247,7 +1400,9 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
1247
1400
  : undefined;
1248
1401
 
1249
1402
  const normalizedSender = normalizeBlueBubblesHandle(senderId);
1250
- if (!normalizedSender) return null;
1403
+ if (!normalizedSender) {
1404
+ return null;
1405
+ }
1251
1406
 
1252
1407
  return {
1253
1408
  action,
@@ -1272,7 +1427,9 @@ export async function handleBlueBubblesWebhookRequest(
1272
1427
  const url = new URL(req.url ?? "/", "http://localhost");
1273
1428
  const path = normalizeWebhookPath(url.pathname);
1274
1429
  const targets = webhookTargets.get(path);
1275
- if (!targets || targets.length === 0) return false;
1430
+ if (!targets || targets.length === 0) {
1431
+ return false;
1432
+ }
1276
1433
 
1277
1434
  if (req.method !== "POST") {
1278
1435
  res.statusCode = 405;
@@ -1342,16 +1499,19 @@ export async function handleBlueBubblesWebhookRequest(
1342
1499
 
1343
1500
  const matching = targets.filter((target) => {
1344
1501
  const token = target.account.config.password?.trim();
1345
- if (!token) return true;
1502
+ if (!token) {
1503
+ return true;
1504
+ }
1346
1505
  const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
1347
1506
  const headerToken =
1348
1507
  req.headers["x-guid"] ??
1349
1508
  req.headers["x-password"] ??
1350
1509
  req.headers["x-bluebubbles-guid"] ??
1351
1510
  req.headers["authorization"];
1352
- const guid =
1353
- (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
1354
- if (guid && guid.trim() === token) return true;
1511
+ const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
1512
+ if (guid && guid.trim() === token) {
1513
+ return true;
1514
+ }
1355
1515
  const remote = req.socket?.remoteAddress ?? "";
1356
1516
  if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") {
1357
1517
  return true;
@@ -1441,7 +1601,9 @@ async function processMessage(
1441
1601
  const cacheMessageId = message.messageId?.trim();
1442
1602
  let messageShortId: string | undefined;
1443
1603
  const cacheInboundMessage = () => {
1444
- if (!cacheMessageId) return;
1604
+ if (!cacheMessageId) {
1605
+ return;
1606
+ }
1445
1607
  const cacheEntry = rememberBlueBubblesReplyCache({
1446
1608
  accountId: account.accountId,
1447
1609
  messageId: cacheMessageId,
@@ -1615,7 +1777,7 @@ async function processMessage(
1615
1777
  const chatGuid = message.chatGuid ?? undefined;
1616
1778
  const chatIdentifier = message.chatIdentifier ?? undefined;
1617
1779
  const peerId = isGroup
1618
- ? chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group")
1780
+ ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
1619
1781
  : message.senderId;
1620
1782
 
1621
1783
  const route = core.channel.routing.resolveAgentRoute({
@@ -1690,11 +1852,7 @@ async function processMessage(
1690
1852
 
1691
1853
  // Allow control commands to bypass mention gating when authorized (parity with iMessage)
1692
1854
  const shouldBypassMention =
1693
- isGroup &&
1694
- requireMention &&
1695
- !wasMentioned &&
1696
- commandAuthorized &&
1697
- hasControlCmd;
1855
+ isGroup && requireMention && !wasMentioned && commandAuthorized && hasControlCmd;
1698
1856
  const effectiveWasMentioned = wasMentioned || shouldBypassMention;
1699
1857
 
1700
1858
  // Skip group messages that require mention but weren't mentioned
@@ -1722,7 +1880,9 @@ async function processMessage(
1722
1880
  logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)");
1723
1881
  } else {
1724
1882
  for (const attachment of attachments) {
1725
- if (!attachment.guid) continue;
1883
+ if (!attachment.guid) {
1884
+ continue;
1885
+ }
1726
1886
  if (attachment.totalBytes && attachment.totalBytes > maxBytes) {
1727
1887
  logVerbose(
1728
1888
  core,
@@ -1776,8 +1936,12 @@ async function processMessage(
1776
1936
  chatId: message.chatId,
1777
1937
  });
1778
1938
  if (cached) {
1779
- if (!replyToBody && cached.body) replyToBody = cached.body;
1780
- if (!replyToSender && cached.senderLabel) replyToSender = cached.senderLabel;
1939
+ if (!replyToBody && cached.body) {
1940
+ replyToBody = cached.body;
1941
+ }
1942
+ if (!replyToSender && cached.senderLabel) {
1943
+ replyToSender = cached.senderLabel;
1944
+ }
1781
1945
  replyToShortId = cached.shortId;
1782
1946
  if (core.logging.shouldLogVerbose()) {
1783
1947
  const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
@@ -1857,16 +2021,16 @@ async function processMessage(
1857
2021
  const shouldAckReaction = () =>
1858
2022
  Boolean(
1859
2023
  ackReactionValue &&
1860
- core.channel.reactions.shouldAckReaction({
1861
- scope: ackReactionScope,
1862
- isDirect: !isGroup,
1863
- isGroup,
1864
- isMentionableGroup: isGroup,
1865
- requireMention: Boolean(requireMention),
1866
- canDetectMention,
1867
- effectiveWasMentioned,
1868
- shouldBypassMention,
1869
- }),
2024
+ core.channel.reactions.shouldAckReaction({
2025
+ scope: ackReactionScope,
2026
+ isDirect: !isGroup,
2027
+ isGroup,
2028
+ isMentionableGroup: isGroup,
2029
+ requireMention: Boolean(requireMention),
2030
+ canDetectMention,
2031
+ effectiveWasMentioned,
2032
+ shouldBypassMention,
2033
+ }),
1870
2034
  );
1871
2035
  const ackMessageId = message.messageId?.trim() || "";
1872
2036
  const ackReactionPromise =
@@ -1919,7 +2083,9 @@ async function processMessage(
1919
2083
 
1920
2084
  const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
1921
2085
  const trimmed = messageId?.trim();
1922
- if (!trimmed || trimmed === "ok" || trimmed === "unknown") return;
2086
+ if (!trimmed || trimmed === "ok" || trimmed === "unknown") {
2087
+ return;
2088
+ }
1923
2089
  // Cache outbound message to get short ID
1924
2090
  const cacheEntry = rememberBlueBubblesReplyCache({
1925
2091
  accountId: account.accountId,
@@ -1979,13 +2145,41 @@ async function processMessage(
1979
2145
  };
1980
2146
 
1981
2147
  let sentMessage = false;
2148
+ let streamingActive = false;
2149
+ let typingRestartTimer: NodeJS.Timeout | undefined;
2150
+ const typingRestartDelayMs = 150;
2151
+ const clearTypingRestartTimer = () => {
2152
+ if (typingRestartTimer) {
2153
+ clearTimeout(typingRestartTimer);
2154
+ typingRestartTimer = undefined;
2155
+ }
2156
+ };
2157
+ const restartTypingSoon = () => {
2158
+ if (!streamingActive || !chatGuidForActions || !baseUrl || !password) {
2159
+ return;
2160
+ }
2161
+ clearTypingRestartTimer();
2162
+ typingRestartTimer = setTimeout(() => {
2163
+ typingRestartTimer = undefined;
2164
+ if (!streamingActive) {
2165
+ return;
2166
+ }
2167
+ sendBlueBubblesTyping(chatGuidForActions, true, {
2168
+ cfg: config,
2169
+ accountId: account.accountId,
2170
+ }).catch((err) => {
2171
+ runtime.error?.(`[bluebubbles] typing restart failed: ${String(err)}`);
2172
+ });
2173
+ }, typingRestartDelayMs);
2174
+ };
1982
2175
  try {
1983
2176
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1984
2177
  ctx: ctxPayload,
1985
2178
  cfg: config,
1986
2179
  dispatcherOptions: {
1987
- deliver: async (payload) => {
1988
- const rawReplyToId = typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
2180
+ deliver: async (payload, info) => {
2181
+ const rawReplyToId =
2182
+ typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
1989
2183
  // Resolve short ID (e.g., "5") to full UUID
1990
2184
  const replyToMessageGuid = rawReplyToId
1991
2185
  ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
@@ -2018,6 +2212,9 @@ async function processMessage(
2018
2212
  maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
2019
2213
  sentMessage = true;
2020
2214
  statusSink?.({ lastOutboundAt: Date.now() });
2215
+ if (info.kind === "block") {
2216
+ restartTypingSoon();
2217
+ }
2021
2218
  }
2022
2219
  return;
2023
2220
  }
@@ -2037,8 +2234,12 @@ async function processMessage(
2037
2234
  chunkMode === "newline"
2038
2235
  ? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
2039
2236
  : core.channel.text.chunkMarkdownText(text, textLimit);
2040
- if (!chunks.length && text) chunks.push(text);
2041
- if (!chunks.length) return;
2237
+ if (!chunks.length && text) {
2238
+ chunks.push(text);
2239
+ }
2240
+ if (!chunks.length) {
2241
+ return;
2242
+ }
2042
2243
  for (let i = 0; i < chunks.length; i++) {
2043
2244
  const chunk = chunks[i];
2044
2245
  const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
@@ -2049,23 +2250,20 @@ async function processMessage(
2049
2250
  maybeEnqueueOutboundMessageId(result.messageId, chunk);
2050
2251
  sentMessage = true;
2051
2252
  statusSink?.({ lastOutboundAt: Date.now() });
2052
- // In newline mode, restart typing after each chunk if more chunks remain
2053
- // Small delay allows the Apple API to finish clearing the typing state from message send
2054
- if (chunkMode === "newline" && i < chunks.length - 1 && chatGuidForActions) {
2055
- await new Promise((r) => setTimeout(r, 150));
2056
- sendBlueBubblesTyping(chatGuidForActions, true, {
2057
- cfg: config,
2058
- accountId: account.accountId,
2059
- }).catch(() => {
2060
- // Ignore typing errors
2061
- });
2253
+ if (info.kind === "block") {
2254
+ restartTypingSoon();
2062
2255
  }
2063
2256
  }
2064
2257
  },
2065
2258
  onReplyStart: async () => {
2066
- if (!chatGuidForActions) return;
2067
- if (!baseUrl || !password) return;
2068
- logVerbose(core, runtime, `typing start chatGuid=${chatGuidForActions}`);
2259
+ if (!chatGuidForActions) {
2260
+ return;
2261
+ }
2262
+ if (!baseUrl || !password) {
2263
+ return;
2264
+ }
2265
+ streamingActive = true;
2266
+ clearTypingRestartTimer();
2069
2267
  try {
2070
2268
  await sendBlueBubblesTyping(chatGuidForActions, true, {
2071
2269
  cfg: config,
@@ -2076,16 +2274,14 @@ async function processMessage(
2076
2274
  }
2077
2275
  },
2078
2276
  onIdle: async () => {
2079
- if (!chatGuidForActions) return;
2080
- if (!baseUrl || !password) return;
2081
- try {
2082
- await sendBlueBubblesTyping(chatGuidForActions, false, {
2083
- cfg: config,
2084
- accountId: account.accountId,
2085
- });
2086
- } catch (err) {
2087
- logVerbose(core, runtime, `typing stop failed: ${String(err)}`);
2277
+ if (!chatGuidForActions) {
2278
+ return;
2279
+ }
2280
+ if (!baseUrl || !password) {
2281
+ return;
2088
2282
  }
2283
+ // Intentionally no-op for block streaming. We stop typing in finally
2284
+ // after the run completes to avoid flicker between paragraph blocks.
2089
2285
  },
2090
2286
  onError: (err, info) => {
2091
2287
  runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
@@ -2099,6 +2295,10 @@ async function processMessage(
2099
2295
  },
2100
2296
  });
2101
2297
  } finally {
2298
+ const shouldStopTyping =
2299
+ Boolean(chatGuidForActions && baseUrl && password) && (streamingActive || !sentMessage);
2300
+ streamingActive = false;
2301
+ clearTypingRestartTimer();
2102
2302
  if (sentMessage && chatGuidForActions && ackMessageId) {
2103
2303
  core.channel.reactions.removeAckReactionAfterReply({
2104
2304
  removeAfterReply: removeAckAfterReply,
@@ -2122,8 +2322,8 @@ async function processMessage(
2122
2322
  },
2123
2323
  });
2124
2324
  }
2125
- if (chatGuidForActions && baseUrl && password && !sentMessage) {
2126
- // Stop typing indicator when no message was sent (e.g., NO_REPLY)
2325
+ if (shouldStopTyping) {
2326
+ // Stop typing after streaming completes to avoid a stuck indicator.
2127
2327
  sendBlueBubblesTyping(chatGuidForActions, false, {
2128
2328
  cfg: config,
2129
2329
  accountId: account.accountId,
@@ -2145,7 +2345,9 @@ async function processReaction(
2145
2345
  target: WebhookTarget,
2146
2346
  ): Promise<void> {
2147
2347
  const { account, config, runtime, core } = target;
2148
- if (reaction.fromMe) return;
2348
+ if (reaction.fromMe) {
2349
+ return;
2350
+ }
2149
2351
 
2150
2352
  const dmPolicy = account.config.dmPolicy ?? "pairing";
2151
2353
  const groupPolicy = account.config.groupPolicy ?? "allowlist";
@@ -2165,9 +2367,13 @@ async function processReaction(
2165
2367
  .filter(Boolean);
2166
2368
 
2167
2369
  if (reaction.isGroup) {
2168
- if (groupPolicy === "disabled") return;
2370
+ if (groupPolicy === "disabled") {
2371
+ return;
2372
+ }
2169
2373
  if (groupPolicy === "allowlist") {
2170
- if (effectiveGroupAllowFrom.length === 0) return;
2374
+ if (effectiveGroupAllowFrom.length === 0) {
2375
+ return;
2376
+ }
2171
2377
  const allowed = isAllowedBlueBubblesSender({
2172
2378
  allowFrom: effectiveGroupAllowFrom,
2173
2379
  sender: reaction.senderId,
@@ -2175,10 +2381,14 @@ async function processReaction(
2175
2381
  chatGuid: reaction.chatGuid ?? undefined,
2176
2382
  chatIdentifier: reaction.chatIdentifier ?? undefined,
2177
2383
  });
2178
- if (!allowed) return;
2384
+ if (!allowed) {
2385
+ return;
2386
+ }
2179
2387
  }
2180
2388
  } else {
2181
- if (dmPolicy === "disabled") return;
2389
+ if (dmPolicy === "disabled") {
2390
+ return;
2391
+ }
2182
2392
  if (dmPolicy !== "open") {
2183
2393
  const allowed = isAllowedBlueBubblesSender({
2184
2394
  allowFrom: effectiveAllowFrom,
@@ -2187,7 +2397,9 @@ async function processReaction(
2187
2397
  chatGuid: reaction.chatGuid ?? undefined,
2188
2398
  chatIdentifier: reaction.chatIdentifier ?? undefined,
2189
2399
  });
2190
- if (!allowed) return;
2400
+ if (!allowed) {
2401
+ return;
2402
+ }
2191
2403
  }
2192
2404
  }
2193
2405
 
@@ -2195,7 +2407,7 @@ async function processReaction(
2195
2407
  const chatGuid = reaction.chatGuid ?? undefined;
2196
2408
  const chatIdentifier = reaction.chatIdentifier ?? undefined;
2197
2409
  const peerId = reaction.isGroup
2198
- ? chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group")
2410
+ ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
2199
2411
  : reaction.senderId;
2200
2412
 
2201
2413
  const route = core.channel.routing.resolveAgentRoute({
@@ -2271,6 +2483,8 @@ export async function monitorBlueBubblesProvider(
2271
2483
 
2272
2484
  export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
2273
2485
  const raw = config?.webhookPath?.trim();
2274
- if (raw) return normalizeWebhookPath(raw);
2486
+ if (raw) {
2487
+ return normalizeWebhookPath(raw);
2488
+ }
2275
2489
  return DEFAULT_WEBHOOK_PATH;
2276
2490
  }