@openclaw/bluebubbles 2026.2.9 → 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.
package/src/monitor.ts CHANGED
@@ -1,281 +1,25 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
3
  import {
4
- createReplyPrefixOptions,
5
- logAckFailure,
6
- logInboundDrop,
7
- logTypingFailure,
8
- resolveAckReaction,
9
- resolveControlCommandGate,
10
- } from "openclaw/plugin-sdk";
11
- import type { ResolvedBlueBubblesAccount } from "./accounts.js";
12
- import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
13
- import { downloadBlueBubblesAttachment } from "./attachments.js";
14
- import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
15
- import { sendBlueBubblesMedia } from "./media-send.js";
4
+ normalizeWebhookMessage,
5
+ normalizeWebhookReaction,
6
+ type NormalizedWebhookMessage,
7
+ } from "./monitor-normalize.js";
8
+ import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
9
+ import {
10
+ _resetBlueBubblesShortIdState,
11
+ resolveBlueBubblesMessageId,
12
+ } from "./monitor-reply-cache.js";
13
+ import {
14
+ DEFAULT_WEBHOOK_PATH,
15
+ normalizeWebhookPath,
16
+ resolveWebhookPathFromConfig,
17
+ type BlueBubblesCoreRuntime,
18
+ type BlueBubblesMonitorOptions,
19
+ type WebhookTarget,
20
+ } from "./monitor-shared.js";
16
21
  import { fetchBlueBubblesServerInfo } from "./probe.js";
17
- import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
18
22
  import { getBlueBubblesRuntime } from "./runtime.js";
19
- import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
20
- import {
21
- formatBlueBubblesChatTarget,
22
- isAllowedBlueBubblesSender,
23
- normalizeBlueBubblesHandle,
24
- } from "./targets.js";
25
-
26
- export type BlueBubblesRuntimeEnv = {
27
- log?: (message: string) => void;
28
- error?: (message: string) => void;
29
- };
30
-
31
- export type BlueBubblesMonitorOptions = {
32
- account: ResolvedBlueBubblesAccount;
33
- config: OpenClawConfig;
34
- runtime: BlueBubblesRuntimeEnv;
35
- abortSignal: AbortSignal;
36
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
37
- webhookPath?: string;
38
- };
39
-
40
- const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
41
- const DEFAULT_TEXT_LIMIT = 4000;
42
- const invalidAckReactions = new Set<string>();
43
-
44
- const REPLY_CACHE_MAX = 2000;
45
- const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
46
-
47
- type BlueBubblesReplyCacheEntry = {
48
- accountId: string;
49
- messageId: string;
50
- shortId: string;
51
- chatGuid?: string;
52
- chatIdentifier?: string;
53
- chatId?: number;
54
- senderLabel?: string;
55
- body?: string;
56
- timestamp: number;
57
- };
58
-
59
- // Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
60
- const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
61
-
62
- // Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
63
- const blueBubblesShortIdToUuid = new Map<string, string>();
64
- const blueBubblesUuidToShortId = new Map<string, string>();
65
- let blueBubblesShortIdCounter = 0;
66
-
67
- function trimOrUndefined(value?: string | null): string | undefined {
68
- const trimmed = value?.trim();
69
- return trimmed ? trimmed : undefined;
70
- }
71
-
72
- function generateShortId(): string {
73
- blueBubblesShortIdCounter += 1;
74
- return String(blueBubblesShortIdCounter);
75
- }
76
-
77
- function rememberBlueBubblesReplyCache(
78
- entry: Omit<BlueBubblesReplyCacheEntry, "shortId">,
79
- ): BlueBubblesReplyCacheEntry {
80
- const messageId = entry.messageId.trim();
81
- if (!messageId) {
82
- return { ...entry, shortId: "" };
83
- }
84
-
85
- // Check if we already have a short ID for this GUID
86
- let shortId = blueBubblesUuidToShortId.get(messageId);
87
- if (!shortId) {
88
- shortId = generateShortId();
89
- blueBubblesShortIdToUuid.set(shortId, messageId);
90
- blueBubblesUuidToShortId.set(messageId, shortId);
91
- }
92
-
93
- const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
94
-
95
- // Refresh insertion order.
96
- blueBubblesReplyCacheByMessageId.delete(messageId);
97
- blueBubblesReplyCacheByMessageId.set(messageId, fullEntry);
98
-
99
- // Opportunistic prune.
100
- const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
101
- for (const [key, value] of blueBubblesReplyCacheByMessageId) {
102
- if (value.timestamp < cutoff) {
103
- blueBubblesReplyCacheByMessageId.delete(key);
104
- // Clean up short ID mappings for expired entries
105
- if (value.shortId) {
106
- blueBubblesShortIdToUuid.delete(value.shortId);
107
- blueBubblesUuidToShortId.delete(key);
108
- }
109
- continue;
110
- }
111
- break;
112
- }
113
- while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
114
- const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
115
- if (!oldest) {
116
- break;
117
- }
118
- const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
119
- blueBubblesReplyCacheByMessageId.delete(oldest);
120
- // Clean up short ID mappings for evicted entries
121
- if (oldEntry?.shortId) {
122
- blueBubblesShortIdToUuid.delete(oldEntry.shortId);
123
- blueBubblesUuidToShortId.delete(oldest);
124
- }
125
- }
126
-
127
- return fullEntry;
128
- }
129
-
130
- /**
131
- * Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
132
- * Returns the input unchanged if it's already a GUID or not found in the mapping.
133
- */
134
- export function resolveBlueBubblesMessageId(
135
- shortOrUuid: string,
136
- opts?: { requireKnownShortId?: boolean },
137
- ): string {
138
- const trimmed = shortOrUuid.trim();
139
- if (!trimmed) {
140
- return trimmed;
141
- }
142
-
143
- // If it looks like a short ID (numeric), try to resolve it
144
- if (/^\d+$/.test(trimmed)) {
145
- const uuid = blueBubblesShortIdToUuid.get(trimmed);
146
- if (uuid) {
147
- return uuid;
148
- }
149
- if (opts?.requireKnownShortId) {
150
- throw new Error(
151
- `BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
152
- );
153
- }
154
- }
155
-
156
- // Return as-is (either already a UUID or not found)
157
- return trimmed;
158
- }
159
-
160
- /**
161
- * Resets the short ID state. Only use in tests.
162
- * @internal
163
- */
164
- export function _resetBlueBubblesShortIdState(): void {
165
- blueBubblesShortIdToUuid.clear();
166
- blueBubblesUuidToShortId.clear();
167
- blueBubblesReplyCacheByMessageId.clear();
168
- blueBubblesShortIdCounter = 0;
169
- }
170
-
171
- /**
172
- * Gets the short ID for a message GUID, if one exists.
173
- */
174
- function getShortIdForUuid(uuid: string): string | undefined {
175
- return blueBubblesUuidToShortId.get(uuid.trim());
176
- }
177
-
178
- function resolveReplyContextFromCache(params: {
179
- accountId: string;
180
- replyToId: string;
181
- chatGuid?: string;
182
- chatIdentifier?: string;
183
- chatId?: number;
184
- }): BlueBubblesReplyCacheEntry | null {
185
- const replyToId = params.replyToId.trim();
186
- if (!replyToId) {
187
- return null;
188
- }
189
-
190
- const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
191
- if (!cached) {
192
- return null;
193
- }
194
- if (cached.accountId !== params.accountId) {
195
- return null;
196
- }
197
-
198
- const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
199
- if (cached.timestamp < cutoff) {
200
- blueBubblesReplyCacheByMessageId.delete(replyToId);
201
- return null;
202
- }
203
-
204
- const chatGuid = trimOrUndefined(params.chatGuid);
205
- const chatIdentifier = trimOrUndefined(params.chatIdentifier);
206
- const cachedChatGuid = trimOrUndefined(cached.chatGuid);
207
- const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier);
208
- const chatId = typeof params.chatId === "number" ? params.chatId : undefined;
209
- const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
210
-
211
- // Avoid cross-chat collisions if we have identifiers.
212
- if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) {
213
- return null;
214
- }
215
- if (
216
- !chatGuid &&
217
- chatIdentifier &&
218
- cachedChatIdentifier &&
219
- chatIdentifier !== cachedChatIdentifier
220
- ) {
221
- return null;
222
- }
223
- if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) {
224
- return null;
225
- }
226
-
227
- return cached;
228
- }
229
-
230
- type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
231
-
232
- function logVerbose(
233
- core: BlueBubblesCoreRuntime,
234
- runtime: BlueBubblesRuntimeEnv,
235
- message: string,
236
- ): void {
237
- if (core.logging.shouldLogVerbose()) {
238
- runtime.log?.(`[bluebubbles] ${message}`);
239
- }
240
- }
241
-
242
- function logGroupAllowlistHint(params: {
243
- runtime: BlueBubblesRuntimeEnv;
244
- reason: string;
245
- entry: string | null;
246
- chatName?: string;
247
- accountId?: string;
248
- }): void {
249
- const log = params.runtime.log ?? console.log;
250
- const nameHint = params.chatName ? ` (group name: ${params.chatName})` : "";
251
- const accountHint = params.accountId
252
- ? ` (or channels.bluebubbles.accounts.${params.accountId}.groupAllowFrom)`
253
- : "";
254
- if (params.entry) {
255
- log(
256
- `[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` +
257
- `"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`,
258
- );
259
- log(
260
- `[bluebubbles] add to config: channels.bluebubbles.groupAllowFrom=["${params.entry}"]${accountHint}.`,
261
- );
262
- return;
263
- }
264
- log(
265
- `[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` +
266
- `channels.bluebubbles.groupPolicy="open" or adding a group id to ` +
267
- `channels.bluebubbles.groupAllowFrom${accountHint}${nameHint}.`,
268
- );
269
- }
270
-
271
- type WebhookTarget = {
272
- account: ResolvedBlueBubblesAccount;
273
- config: OpenClawConfig;
274
- runtime: BlueBubblesRuntimeEnv;
275
- core: BlueBubblesCoreRuntime;
276
- path: string;
277
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
278
- };
279
23
 
280
24
  /**
281
25
  * Entry type for debouncing inbound messages.
@@ -480,18 +224,6 @@ function removeDebouncer(target: WebhookTarget): void {
480
224
  targetDebouncers.delete(target);
481
225
  }
482
226
 
483
- function normalizeWebhookPath(raw: string): string {
484
- const trimmed = raw.trim();
485
- if (!trimmed) {
486
- return "/";
487
- }
488
- const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
489
- if (withSlash.length > 1 && withSlash.endsWith("/")) {
490
- return withSlash.slice(0, -1);
491
- }
492
- return withSlash;
493
- }
494
-
495
227
  export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
496
228
  const key = normalizeWebhookPath(target.path);
497
229
  const normalizedTarget = { ...target, path: key };
@@ -576,522 +308,6 @@ function asRecord(value: unknown): Record<string, unknown> | null {
576
308
  : null;
577
309
  }
578
310
 
579
- function readString(record: Record<string, unknown> | null, key: string): string | undefined {
580
- if (!record) {
581
- return undefined;
582
- }
583
- const value = record[key];
584
- return typeof value === "string" ? value : undefined;
585
- }
586
-
587
- function readNumber(record: Record<string, unknown> | null, key: string): number | undefined {
588
- if (!record) {
589
- return undefined;
590
- }
591
- const value = record[key];
592
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
593
- }
594
-
595
- function readBoolean(record: Record<string, unknown> | null, key: string): boolean | undefined {
596
- if (!record) {
597
- return undefined;
598
- }
599
- const value = record[key];
600
- return typeof value === "boolean" ? value : undefined;
601
- }
602
-
603
- function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
604
- const raw = message["attachments"];
605
- if (!Array.isArray(raw)) {
606
- return [];
607
- }
608
- const out: BlueBubblesAttachment[] = [];
609
- for (const entry of raw) {
610
- const record = asRecord(entry);
611
- if (!record) {
612
- continue;
613
- }
614
- out.push({
615
- guid: readString(record, "guid"),
616
- uti: readString(record, "uti"),
617
- mimeType: readString(record, "mimeType") ?? readString(record, "mime_type"),
618
- transferName: readString(record, "transferName") ?? readString(record, "transfer_name"),
619
- totalBytes: readNumberLike(record, "totalBytes") ?? readNumberLike(record, "total_bytes"),
620
- height: readNumberLike(record, "height"),
621
- width: readNumberLike(record, "width"),
622
- originalROWID: readNumberLike(record, "originalROWID") ?? readNumberLike(record, "rowid"),
623
- });
624
- }
625
- return out;
626
- }
627
-
628
- function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string {
629
- if (attachments.length === 0) {
630
- return "";
631
- }
632
- const mimeTypes = attachments.map((entry) => entry.mimeType ?? "");
633
- const allImages = mimeTypes.every((entry) => entry.startsWith("image/"));
634
- const allVideos = mimeTypes.every((entry) => entry.startsWith("video/"));
635
- const allAudio = mimeTypes.every((entry) => entry.startsWith("audio/"));
636
- const tag = allImages
637
- ? "<media:image>"
638
- : allVideos
639
- ? "<media:video>"
640
- : allAudio
641
- ? "<media:audio>"
642
- : "<media:attachment>";
643
- const label = allImages ? "image" : allVideos ? "video" : allAudio ? "audio" : "file";
644
- const suffix = attachments.length === 1 ? label : `${label}s`;
645
- return `${tag} (${attachments.length} ${suffix})`;
646
- }
647
-
648
- function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
649
- const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []);
650
- if (attachmentPlaceholder) {
651
- return attachmentPlaceholder;
652
- }
653
- if (message.balloonBundleId) {
654
- return "<media:sticker>";
655
- }
656
- return "";
657
- }
658
-
659
- // Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
660
- function formatReplyTag(message: { replyToId?: string; replyToShortId?: string }): string | null {
661
- // Prefer short ID
662
- const rawId = message.replyToShortId || message.replyToId;
663
- if (!rawId) {
664
- return null;
665
- }
666
- return `[[reply_to:${rawId}]]`;
667
- }
668
-
669
- function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
670
- if (!record) {
671
- return undefined;
672
- }
673
- const value = record[key];
674
- if (typeof value === "number" && Number.isFinite(value)) {
675
- return value;
676
- }
677
- if (typeof value === "string") {
678
- const parsed = Number.parseFloat(value);
679
- if (Number.isFinite(parsed)) {
680
- return parsed;
681
- }
682
- }
683
- return undefined;
684
- }
685
-
686
- function extractReplyMetadata(message: Record<string, unknown>): {
687
- replyToId?: string;
688
- replyToBody?: string;
689
- replyToSender?: string;
690
- } {
691
- const replyRaw =
692
- message["replyTo"] ??
693
- message["reply_to"] ??
694
- message["replyToMessage"] ??
695
- message["reply_to_message"] ??
696
- message["repliedMessage"] ??
697
- message["quotedMessage"] ??
698
- message["associatedMessage"] ??
699
- message["reply"];
700
- const replyRecord = asRecord(replyRaw);
701
- const replyHandle =
702
- asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null;
703
- const replySenderRaw =
704
- readString(replyHandle, "address") ??
705
- readString(replyHandle, "handle") ??
706
- readString(replyHandle, "id") ??
707
- readString(replyRecord, "senderId") ??
708
- readString(replyRecord, "sender") ??
709
- readString(replyRecord, "from");
710
- const normalizedSender = replySenderRaw
711
- ? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim()
712
- : undefined;
713
-
714
- const replyToBody =
715
- readString(replyRecord, "text") ??
716
- readString(replyRecord, "body") ??
717
- readString(replyRecord, "message") ??
718
- readString(replyRecord, "subject") ??
719
- undefined;
720
-
721
- const directReplyId =
722
- readString(message, "replyToMessageGuid") ??
723
- readString(message, "replyToGuid") ??
724
- readString(message, "replyGuid") ??
725
- readString(message, "selectedMessageGuid") ??
726
- readString(message, "selectedMessageId") ??
727
- readString(message, "replyToMessageId") ??
728
- readString(message, "replyId") ??
729
- readString(replyRecord, "guid") ??
730
- readString(replyRecord, "id") ??
731
- readString(replyRecord, "messageId");
732
-
733
- const associatedType =
734
- readNumberLike(message, "associatedMessageType") ??
735
- readNumberLike(message, "associated_message_type");
736
- const associatedGuid =
737
- readString(message, "associatedMessageGuid") ??
738
- readString(message, "associated_message_guid") ??
739
- readString(message, "associatedMessageId");
740
- const isReactionAssociation =
741
- typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
742
-
743
- const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined);
744
- const threadOriginatorGuid = readString(message, "threadOriginatorGuid");
745
- const messageGuid = readString(message, "guid");
746
- const fallbackReplyId =
747
- !replyToId && threadOriginatorGuid && threadOriginatorGuid !== messageGuid
748
- ? threadOriginatorGuid
749
- : undefined;
750
-
751
- return {
752
- replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined,
753
- replyToBody: replyToBody?.trim() || undefined,
754
- replyToSender: normalizedSender || undefined,
755
- };
756
- }
757
-
758
- function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
759
- const chats = message["chats"];
760
- if (!Array.isArray(chats) || chats.length === 0) {
761
- return null;
762
- }
763
- const first = chats[0];
764
- return asRecord(first);
765
- }
766
-
767
- function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
768
- if (typeof entry === "string" || typeof entry === "number") {
769
- const raw = String(entry).trim();
770
- if (!raw) {
771
- return null;
772
- }
773
- const normalized = normalizeBlueBubblesHandle(raw) || raw;
774
- return normalized ? { id: normalized } : null;
775
- }
776
- const record = asRecord(entry);
777
- if (!record) {
778
- return null;
779
- }
780
- const nestedHandle =
781
- asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
782
- const idRaw =
783
- readString(record, "address") ??
784
- readString(record, "handle") ??
785
- readString(record, "id") ??
786
- readString(record, "phoneNumber") ??
787
- readString(record, "phone_number") ??
788
- readString(record, "email") ??
789
- readString(nestedHandle, "address") ??
790
- readString(nestedHandle, "handle") ??
791
- readString(nestedHandle, "id");
792
- const nameRaw =
793
- readString(record, "displayName") ??
794
- readString(record, "name") ??
795
- readString(record, "title") ??
796
- readString(nestedHandle, "displayName") ??
797
- readString(nestedHandle, "name");
798
- const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
799
- if (!normalizedId) {
800
- return null;
801
- }
802
- const name = nameRaw?.trim() || undefined;
803
- return { id: normalizedId, name };
804
- }
805
-
806
- function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
807
- if (!Array.isArray(raw) || raw.length === 0) {
808
- return [];
809
- }
810
- const seen = new Set<string>();
811
- const output: BlueBubblesParticipant[] = [];
812
- for (const entry of raw) {
813
- const normalized = normalizeParticipantEntry(entry);
814
- if (!normalized?.id) {
815
- continue;
816
- }
817
- const key = normalized.id.toLowerCase();
818
- if (seen.has(key)) {
819
- continue;
820
- }
821
- seen.add(key);
822
- output.push(normalized);
823
- }
824
- return output;
825
- }
826
-
827
- function formatGroupMembers(params: {
828
- participants?: BlueBubblesParticipant[];
829
- fallback?: BlueBubblesParticipant;
830
- }): string | undefined {
831
- const seen = new Set<string>();
832
- const ordered: BlueBubblesParticipant[] = [];
833
- for (const entry of params.participants ?? []) {
834
- if (!entry?.id) {
835
- continue;
836
- }
837
- const key = entry.id.toLowerCase();
838
- if (seen.has(key)) {
839
- continue;
840
- }
841
- seen.add(key);
842
- ordered.push(entry);
843
- }
844
- if (ordered.length === 0 && params.fallback?.id) {
845
- ordered.push(params.fallback);
846
- }
847
- if (ordered.length === 0) {
848
- return undefined;
849
- }
850
- return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", ");
851
- }
852
-
853
- function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
854
- const guid = chatGuid?.trim();
855
- if (!guid) {
856
- return undefined;
857
- }
858
- const parts = guid.split(";");
859
- if (parts.length >= 3) {
860
- if (parts[1] === "+") {
861
- return true;
862
- }
863
- if (parts[1] === "-") {
864
- return false;
865
- }
866
- }
867
- if (guid.includes(";+;")) {
868
- return true;
869
- }
870
- if (guid.includes(";-;")) {
871
- return false;
872
- }
873
- return undefined;
874
- }
875
-
876
- function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
877
- const guid = chatGuid?.trim();
878
- if (!guid) {
879
- return undefined;
880
- }
881
- const parts = guid.split(";");
882
- if (parts.length < 3) {
883
- return undefined;
884
- }
885
- const identifier = parts[2]?.trim();
886
- return identifier || undefined;
887
- }
888
-
889
- function formatGroupAllowlistEntry(params: {
890
- chatGuid?: string;
891
- chatId?: number;
892
- chatIdentifier?: string;
893
- }): string | null {
894
- const guid = params.chatGuid?.trim();
895
- if (guid) {
896
- return `chat_guid:${guid}`;
897
- }
898
- const chatId = params.chatId;
899
- if (typeof chatId === "number" && Number.isFinite(chatId)) {
900
- return `chat_id:${chatId}`;
901
- }
902
- const identifier = params.chatIdentifier?.trim();
903
- if (identifier) {
904
- return `chat_identifier:${identifier}`;
905
- }
906
- return null;
907
- }
908
-
909
- type BlueBubblesParticipant = {
910
- id: string;
911
- name?: string;
912
- };
913
-
914
- type NormalizedWebhookMessage = {
915
- text: string;
916
- senderId: string;
917
- senderName?: string;
918
- messageId?: string;
919
- timestamp?: number;
920
- isGroup: boolean;
921
- chatId?: number;
922
- chatGuid?: string;
923
- chatIdentifier?: string;
924
- chatName?: string;
925
- fromMe?: boolean;
926
- attachments?: BlueBubblesAttachment[];
927
- balloonBundleId?: string;
928
- associatedMessageGuid?: string;
929
- associatedMessageType?: number;
930
- associatedMessageEmoji?: string;
931
- isTapback?: boolean;
932
- participants?: BlueBubblesParticipant[];
933
- replyToId?: string;
934
- replyToBody?: string;
935
- replyToSender?: string;
936
- };
937
-
938
- type NormalizedWebhookReaction = {
939
- action: "added" | "removed";
940
- emoji: string;
941
- senderId: string;
942
- senderName?: string;
943
- messageId: string;
944
- timestamp?: number;
945
- isGroup: boolean;
946
- chatId?: number;
947
- chatGuid?: string;
948
- chatIdentifier?: string;
949
- chatName?: string;
950
- fromMe?: boolean;
951
- };
952
-
953
- const REACTION_TYPE_MAP = new Map<number, { emoji: string; action: "added" | "removed" }>([
954
- [2000, { emoji: "❤️", action: "added" }],
955
- [2001, { emoji: "👍", action: "added" }],
956
- [2002, { emoji: "👎", action: "added" }],
957
- [2003, { emoji: "😂", action: "added" }],
958
- [2004, { emoji: "‼️", action: "added" }],
959
- [2005, { emoji: "❓", action: "added" }],
960
- [3000, { emoji: "❤️", action: "removed" }],
961
- [3001, { emoji: "👍", action: "removed" }],
962
- [3002, { emoji: "👎", action: "removed" }],
963
- [3003, { emoji: "😂", action: "removed" }],
964
- [3004, { emoji: "‼️", action: "removed" }],
965
- [3005, { emoji: "❓", action: "removed" }],
966
- ]);
967
-
968
- // Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action
969
- const TAPBACK_TEXT_MAP = new Map<string, { emoji: string; action: "added" | "removed" }>([
970
- ["loved", { emoji: "❤️", action: "added" }],
971
- ["liked", { emoji: "👍", action: "added" }],
972
- ["disliked", { emoji: "👎", action: "added" }],
973
- ["laughed at", { emoji: "😂", action: "added" }],
974
- ["emphasized", { emoji: "‼️", action: "added" }],
975
- ["questioned", { emoji: "❓", action: "added" }],
976
- // Removal patterns (e.g., "Removed a heart from")
977
- ["removed a heart from", { emoji: "❤️", action: "removed" }],
978
- ["removed a like from", { emoji: "👍", action: "removed" }],
979
- ["removed a dislike from", { emoji: "👎", action: "removed" }],
980
- ["removed a laugh from", { emoji: "😂", action: "removed" }],
981
- ["removed an emphasis from", { emoji: "‼️", action: "removed" }],
982
- ["removed a question from", { emoji: "❓", action: "removed" }],
983
- ]);
984
-
985
- const TAPBACK_EMOJI_REGEX =
986
- /(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
987
-
988
- function extractFirstEmoji(text: string): string | null {
989
- const match = text.match(TAPBACK_EMOJI_REGEX);
990
- return match ? match[0] : null;
991
- }
992
-
993
- function extractQuotedTapbackText(text: string): string | null {
994
- const match = text.match(/[“"]([^”"]+)[”"]/s);
995
- return match ? match[1] : null;
996
- }
997
-
998
- function isTapbackAssociatedType(type: number | undefined): boolean {
999
- return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000;
1000
- }
1001
-
1002
- function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
1003
- if (typeof type !== "number" || !Number.isFinite(type)) {
1004
- return undefined;
1005
- }
1006
- if (type >= 3000 && type < 4000) {
1007
- return "removed";
1008
- }
1009
- if (type >= 2000 && type < 3000) {
1010
- return "added";
1011
- }
1012
- return undefined;
1013
- }
1014
-
1015
- function resolveTapbackContext(message: NormalizedWebhookMessage): {
1016
- emojiHint?: string;
1017
- actionHint?: "added" | "removed";
1018
- replyToId?: string;
1019
- } | null {
1020
- const associatedType = message.associatedMessageType;
1021
- const hasTapbackType = isTapbackAssociatedType(associatedType);
1022
- const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
1023
- if (!hasTapbackType && !hasTapbackMarker) {
1024
- return null;
1025
- }
1026
- const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
1027
- const actionHint = resolveTapbackActionHint(associatedType);
1028
- const emojiHint =
1029
- message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;
1030
- return { emojiHint, actionHint, replyToId };
1031
- }
1032
-
1033
- // Detects tapback text patterns like 'Loved "message"' and converts to structured format
1034
- function parseTapbackText(params: {
1035
- text: string;
1036
- emojiHint?: string;
1037
- actionHint?: "added" | "removed";
1038
- requireQuoted?: boolean;
1039
- }): {
1040
- emoji: string;
1041
- action: "added" | "removed";
1042
- quotedText: string;
1043
- } | null {
1044
- const trimmed = params.text.trim();
1045
- const lower = trimmed.toLowerCase();
1046
- if (!trimmed) {
1047
- return null;
1048
- }
1049
-
1050
- for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
1051
- if (lower.startsWith(pattern)) {
1052
- // Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
1053
- const afterPattern = trimmed.slice(pattern.length).trim();
1054
- if (params.requireQuoted) {
1055
- const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
1056
- if (!strictMatch) {
1057
- return null;
1058
- }
1059
- return { emoji, action, quotedText: strictMatch[1] };
1060
- }
1061
- const quotedText =
1062
- extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern;
1063
- return { emoji, action, quotedText };
1064
- }
1065
- }
1066
-
1067
- if (lower.startsWith("reacted")) {
1068
- const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
1069
- if (!emoji) {
1070
- return null;
1071
- }
1072
- const quotedText = extractQuotedTapbackText(trimmed);
1073
- if (params.requireQuoted && !quotedText) {
1074
- return null;
1075
- }
1076
- const fallback = trimmed.slice("reacted".length).trim();
1077
- return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
1078
- }
1079
-
1080
- if (lower.startsWith("removed")) {
1081
- const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
1082
- if (!emoji) {
1083
- return null;
1084
- }
1085
- const quotedText = extractQuotedTapbackText(trimmed);
1086
- if (params.requireQuoted && !quotedText) {
1087
- return null;
1088
- }
1089
- const fallback = trimmed.slice("removed".length).trim();
1090
- return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
1091
- }
1092
- return null;
1093
- }
1094
-
1095
311
  function maskSecret(value: string): string {
1096
312
  if (value.length <= 6) {
1097
313
  return "***";
@@ -1099,348 +315,6 @@ function maskSecret(value: string): string {
1099
315
  return `${value.slice(0, 2)}***${value.slice(-2)}`;
1100
316
  }
1101
317
 
1102
- function resolveBlueBubblesAckReaction(params: {
1103
- cfg: OpenClawConfig;
1104
- agentId: string;
1105
- core: BlueBubblesCoreRuntime;
1106
- runtime: BlueBubblesRuntimeEnv;
1107
- }): string | null {
1108
- const raw = resolveAckReaction(params.cfg, params.agentId).trim();
1109
- if (!raw) {
1110
- return null;
1111
- }
1112
- try {
1113
- normalizeBlueBubblesReactionInput(raw);
1114
- return raw;
1115
- } catch {
1116
- const key = raw.toLowerCase();
1117
- if (!invalidAckReactions.has(key)) {
1118
- invalidAckReactions.add(key);
1119
- logVerbose(
1120
- params.core,
1121
- params.runtime,
1122
- `ack reaction skipped (unsupported for BlueBubbles): ${raw}`,
1123
- );
1124
- }
1125
- return null;
1126
- }
1127
- }
1128
-
1129
- function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
1130
- const dataRaw = payload.data ?? payload.payload ?? payload.event;
1131
- const data =
1132
- asRecord(dataRaw) ??
1133
- (typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null);
1134
- const messageRaw = payload.message ?? data?.message ?? data;
1135
- const message =
1136
- asRecord(messageRaw) ??
1137
- (typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null);
1138
- if (!message) {
1139
- return null;
1140
- }
1141
- return message;
1142
- }
1143
-
1144
- function normalizeWebhookMessage(
1145
- payload: Record<string, unknown>,
1146
- ): NormalizedWebhookMessage | null {
1147
- const message = extractMessagePayload(payload);
1148
- if (!message) {
1149
- return null;
1150
- }
1151
-
1152
- const text =
1153
- readString(message, "text") ??
1154
- readString(message, "body") ??
1155
- readString(message, "subject") ??
1156
- "";
1157
-
1158
- const handleValue = message.handle ?? message.sender;
1159
- const handle =
1160
- asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
1161
- const senderId =
1162
- readString(handle, "address") ??
1163
- readString(handle, "handle") ??
1164
- readString(handle, "id") ??
1165
- readString(message, "senderId") ??
1166
- readString(message, "sender") ??
1167
- readString(message, "from") ??
1168
- "";
1169
-
1170
- const senderName =
1171
- readString(handle, "displayName") ??
1172
- readString(handle, "name") ??
1173
- readString(message, "senderName") ??
1174
- undefined;
1175
-
1176
- const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null;
1177
- const chatFromList = readFirstChatRecord(message);
1178
- const chatGuid =
1179
- readString(message, "chatGuid") ??
1180
- readString(message, "chat_guid") ??
1181
- readString(chat, "chatGuid") ??
1182
- readString(chat, "chat_guid") ??
1183
- readString(chat, "guid") ??
1184
- readString(chatFromList, "chatGuid") ??
1185
- readString(chatFromList, "chat_guid") ??
1186
- readString(chatFromList, "guid");
1187
- const chatIdentifier =
1188
- readString(message, "chatIdentifier") ??
1189
- readString(message, "chat_identifier") ??
1190
- readString(chat, "chatIdentifier") ??
1191
- readString(chat, "chat_identifier") ??
1192
- readString(chat, "identifier") ??
1193
- readString(chatFromList, "chatIdentifier") ??
1194
- readString(chatFromList, "chat_identifier") ??
1195
- readString(chatFromList, "identifier") ??
1196
- extractChatIdentifierFromChatGuid(chatGuid);
1197
- const chatId =
1198
- readNumberLike(message, "chatId") ??
1199
- readNumberLike(message, "chat_id") ??
1200
- readNumberLike(chat, "chatId") ??
1201
- readNumberLike(chat, "chat_id") ??
1202
- readNumberLike(chat, "id") ??
1203
- readNumberLike(chatFromList, "chatId") ??
1204
- readNumberLike(chatFromList, "chat_id") ??
1205
- readNumberLike(chatFromList, "id");
1206
- const chatName =
1207
- readString(message, "chatName") ??
1208
- readString(chat, "displayName") ??
1209
- readString(chat, "name") ??
1210
- readString(chatFromList, "displayName") ??
1211
- readString(chatFromList, "name") ??
1212
- undefined;
1213
-
1214
- const chatParticipants = chat ? chat["participants"] : undefined;
1215
- const messageParticipants = message["participants"];
1216
- const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
1217
- const participants = Array.isArray(chatParticipants)
1218
- ? chatParticipants
1219
- : Array.isArray(messageParticipants)
1220
- ? messageParticipants
1221
- : Array.isArray(chatsParticipants)
1222
- ? chatsParticipants
1223
- : [];
1224
- const normalizedParticipants = normalizeParticipantList(participants);
1225
- const participantsCount = participants.length;
1226
- const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
1227
- const explicitIsGroup =
1228
- readBoolean(message, "isGroup") ??
1229
- readBoolean(message, "is_group") ??
1230
- readBoolean(chat, "isGroup") ??
1231
- readBoolean(message, "group");
1232
- const isGroup =
1233
- typeof groupFromChatGuid === "boolean"
1234
- ? groupFromChatGuid
1235
- : (explicitIsGroup ?? participantsCount > 2);
1236
-
1237
- const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
1238
- const messageId =
1239
- readString(message, "guid") ??
1240
- readString(message, "id") ??
1241
- readString(message, "messageId") ??
1242
- undefined;
1243
- const balloonBundleId = readString(message, "balloonBundleId");
1244
- const associatedMessageGuid =
1245
- readString(message, "associatedMessageGuid") ??
1246
- readString(message, "associated_message_guid") ??
1247
- readString(message, "associatedMessageId") ??
1248
- undefined;
1249
- const associatedMessageType =
1250
- readNumberLike(message, "associatedMessageType") ??
1251
- readNumberLike(message, "associated_message_type");
1252
- const associatedMessageEmoji =
1253
- readString(message, "associatedMessageEmoji") ??
1254
- readString(message, "associated_message_emoji") ??
1255
- readString(message, "reactionEmoji") ??
1256
- readString(message, "reaction_emoji") ??
1257
- undefined;
1258
- const isTapback =
1259
- readBoolean(message, "isTapback") ??
1260
- readBoolean(message, "is_tapback") ??
1261
- readBoolean(message, "tapback") ??
1262
- undefined;
1263
-
1264
- const timestampRaw =
1265
- readNumber(message, "date") ??
1266
- readNumber(message, "dateCreated") ??
1267
- readNumber(message, "timestamp");
1268
- const timestamp =
1269
- typeof timestampRaw === "number"
1270
- ? timestampRaw > 1_000_000_000_000
1271
- ? timestampRaw
1272
- : timestampRaw * 1000
1273
- : undefined;
1274
-
1275
- const normalizedSender = normalizeBlueBubblesHandle(senderId);
1276
- if (!normalizedSender) {
1277
- return null;
1278
- }
1279
- const replyMetadata = extractReplyMetadata(message);
1280
-
1281
- return {
1282
- text,
1283
- senderId: normalizedSender,
1284
- senderName,
1285
- messageId,
1286
- timestamp,
1287
- isGroup,
1288
- chatId,
1289
- chatGuid,
1290
- chatIdentifier,
1291
- chatName,
1292
- fromMe,
1293
- attachments: extractAttachments(message),
1294
- balloonBundleId,
1295
- associatedMessageGuid,
1296
- associatedMessageType,
1297
- associatedMessageEmoji,
1298
- isTapback,
1299
- participants: normalizedParticipants,
1300
- replyToId: replyMetadata.replyToId,
1301
- replyToBody: replyMetadata.replyToBody,
1302
- replyToSender: replyMetadata.replyToSender,
1303
- };
1304
- }
1305
-
1306
- function normalizeWebhookReaction(
1307
- payload: Record<string, unknown>,
1308
- ): NormalizedWebhookReaction | null {
1309
- const message = extractMessagePayload(payload);
1310
- if (!message) {
1311
- return null;
1312
- }
1313
-
1314
- const associatedGuid =
1315
- readString(message, "associatedMessageGuid") ??
1316
- readString(message, "associated_message_guid") ??
1317
- readString(message, "associatedMessageId");
1318
- const associatedType =
1319
- readNumberLike(message, "associatedMessageType") ??
1320
- readNumberLike(message, "associated_message_type");
1321
- if (!associatedGuid || associatedType === undefined) {
1322
- return null;
1323
- }
1324
-
1325
- const mapping = REACTION_TYPE_MAP.get(associatedType);
1326
- const associatedEmoji =
1327
- readString(message, "associatedMessageEmoji") ??
1328
- readString(message, "associated_message_emoji") ??
1329
- readString(message, "reactionEmoji") ??
1330
- readString(message, "reaction_emoji");
1331
- const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
1332
- const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
1333
-
1334
- const handleValue = message.handle ?? message.sender;
1335
- const handle =
1336
- asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
1337
- const senderId =
1338
- readString(handle, "address") ??
1339
- readString(handle, "handle") ??
1340
- readString(handle, "id") ??
1341
- readString(message, "senderId") ??
1342
- readString(message, "sender") ??
1343
- readString(message, "from") ??
1344
- "";
1345
- const senderName =
1346
- readString(handle, "displayName") ??
1347
- readString(handle, "name") ??
1348
- readString(message, "senderName") ??
1349
- undefined;
1350
-
1351
- const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null;
1352
- const chatFromList = readFirstChatRecord(message);
1353
- const chatGuid =
1354
- readString(message, "chatGuid") ??
1355
- readString(message, "chat_guid") ??
1356
- readString(chat, "chatGuid") ??
1357
- readString(chat, "chat_guid") ??
1358
- readString(chat, "guid") ??
1359
- readString(chatFromList, "chatGuid") ??
1360
- readString(chatFromList, "chat_guid") ??
1361
- readString(chatFromList, "guid");
1362
- const chatIdentifier =
1363
- readString(message, "chatIdentifier") ??
1364
- readString(message, "chat_identifier") ??
1365
- readString(chat, "chatIdentifier") ??
1366
- readString(chat, "chat_identifier") ??
1367
- readString(chat, "identifier") ??
1368
- readString(chatFromList, "chatIdentifier") ??
1369
- readString(chatFromList, "chat_identifier") ??
1370
- readString(chatFromList, "identifier") ??
1371
- extractChatIdentifierFromChatGuid(chatGuid);
1372
- const chatId =
1373
- readNumberLike(message, "chatId") ??
1374
- readNumberLike(message, "chat_id") ??
1375
- readNumberLike(chat, "chatId") ??
1376
- readNumberLike(chat, "chat_id") ??
1377
- readNumberLike(chat, "id") ??
1378
- readNumberLike(chatFromList, "chatId") ??
1379
- readNumberLike(chatFromList, "chat_id") ??
1380
- readNumberLike(chatFromList, "id");
1381
- const chatName =
1382
- readString(message, "chatName") ??
1383
- readString(chat, "displayName") ??
1384
- readString(chat, "name") ??
1385
- readString(chatFromList, "displayName") ??
1386
- readString(chatFromList, "name") ??
1387
- undefined;
1388
-
1389
- const chatParticipants = chat ? chat["participants"] : undefined;
1390
- const messageParticipants = message["participants"];
1391
- const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
1392
- const participants = Array.isArray(chatParticipants)
1393
- ? chatParticipants
1394
- : Array.isArray(messageParticipants)
1395
- ? messageParticipants
1396
- : Array.isArray(chatsParticipants)
1397
- ? chatsParticipants
1398
- : [];
1399
- const participantsCount = participants.length;
1400
- const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
1401
- const explicitIsGroup =
1402
- readBoolean(message, "isGroup") ??
1403
- readBoolean(message, "is_group") ??
1404
- readBoolean(chat, "isGroup") ??
1405
- readBoolean(message, "group");
1406
- const isGroup =
1407
- typeof groupFromChatGuid === "boolean"
1408
- ? groupFromChatGuid
1409
- : (explicitIsGroup ?? participantsCount > 2);
1410
-
1411
- const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
1412
- const timestampRaw =
1413
- readNumberLike(message, "date") ??
1414
- readNumberLike(message, "dateCreated") ??
1415
- readNumberLike(message, "timestamp");
1416
- const timestamp =
1417
- typeof timestampRaw === "number"
1418
- ? timestampRaw > 1_000_000_000_000
1419
- ? timestampRaw
1420
- : timestampRaw * 1000
1421
- : undefined;
1422
-
1423
- const normalizedSender = normalizeBlueBubblesHandle(senderId);
1424
- if (!normalizedSender) {
1425
- return null;
1426
- }
1427
-
1428
- return {
1429
- action,
1430
- emoji,
1431
- senderId: normalizedSender,
1432
- senderName,
1433
- messageId: associatedGuid,
1434
- timestamp,
1435
- isGroup,
1436
- chatId,
1437
- chatGuid,
1438
- chatIdentifier,
1439
- chatName,
1440
- fromMe,
1441
- };
1442
- }
1443
-
1444
318
  export async function handleBlueBubblesWebhookRequest(
1445
319
  req: IncomingMessage,
1446
320
  res: ServerResponse,
@@ -1461,7 +335,13 @@ export async function handleBlueBubblesWebhookRequest(
1461
335
 
1462
336
  const body = await readJsonBody(req, 1024 * 1024);
1463
337
  if (!body.ok) {
1464
- res.statusCode = body.error === "payload too large" ? 413 : 400;
338
+ if (body.error === "payload too large") {
339
+ res.statusCode = 413;
340
+ } else if (body.error === "request body timeout") {
341
+ res.statusCode = 408;
342
+ } else {
343
+ res.statusCode = 400;
344
+ }
1465
345
  res.end(body.error ?? "invalid payload");
1466
346
  console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
1467
347
  return true;
@@ -1533,10 +413,6 @@ export async function handleBlueBubblesWebhookRequest(
1533
413
  if (guid && guid.trim() === token) {
1534
414
  return true;
1535
415
  }
1536
- const remote = req.socket?.remoteAddress ?? "";
1537
- if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") {
1538
- return true;
1539
- }
1540
416
  return false;
1541
417
  });
1542
418
 
@@ -1591,880 +467,6 @@ export async function handleBlueBubblesWebhookRequest(
1591
467
  return true;
1592
468
  }
1593
469
 
1594
- async function processMessage(
1595
- message: NormalizedWebhookMessage,
1596
- target: WebhookTarget,
1597
- ): Promise<void> {
1598
- const { account, config, runtime, core, statusSink } = target;
1599
-
1600
- const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
1601
- const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
1602
-
1603
- const text = message.text.trim();
1604
- const attachments = message.attachments ?? [];
1605
- const placeholder = buildMessagePlaceholder(message);
1606
- // Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
1607
- // For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
1608
- const tapbackContext = resolveTapbackContext(message);
1609
- const tapbackParsed = parseTapbackText({
1610
- text,
1611
- emojiHint: tapbackContext?.emojiHint,
1612
- actionHint: tapbackContext?.actionHint,
1613
- requireQuoted: !tapbackContext,
1614
- });
1615
- const isTapbackMessage = Boolean(tapbackParsed);
1616
- const rawBody = tapbackParsed
1617
- ? tapbackParsed.action === "removed"
1618
- ? `removed ${tapbackParsed.emoji} reaction`
1619
- : `reacted with ${tapbackParsed.emoji}`
1620
- : text || placeholder;
1621
-
1622
- const cacheMessageId = message.messageId?.trim();
1623
- let messageShortId: string | undefined;
1624
- const cacheInboundMessage = () => {
1625
- if (!cacheMessageId) {
1626
- return;
1627
- }
1628
- const cacheEntry = rememberBlueBubblesReplyCache({
1629
- accountId: account.accountId,
1630
- messageId: cacheMessageId,
1631
- chatGuid: message.chatGuid,
1632
- chatIdentifier: message.chatIdentifier,
1633
- chatId: message.chatId,
1634
- senderLabel: message.fromMe ? "me" : message.senderId,
1635
- body: rawBody,
1636
- timestamp: message.timestamp ?? Date.now(),
1637
- });
1638
- messageShortId = cacheEntry.shortId;
1639
- };
1640
-
1641
- if (message.fromMe) {
1642
- // Cache from-me messages so reply context can resolve sender/body.
1643
- cacheInboundMessage();
1644
- return;
1645
- }
1646
-
1647
- if (!rawBody) {
1648
- logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
1649
- return;
1650
- }
1651
- logVerbose(
1652
- core,
1653
- runtime,
1654
- `msg sender=${message.senderId} group=${isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
1655
- );
1656
-
1657
- const dmPolicy = account.config.dmPolicy ?? "pairing";
1658
- const groupPolicy = account.config.groupPolicy ?? "allowlist";
1659
- const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
1660
- const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
1661
- const storeAllowFrom = await core.channel.pairing
1662
- .readAllowFromStore("bluebubbles")
1663
- .catch(() => []);
1664
- const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
1665
- .map((entry) => String(entry).trim())
1666
- .filter(Boolean);
1667
- const effectiveGroupAllowFrom = [
1668
- ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
1669
- ...storeAllowFrom,
1670
- ]
1671
- .map((entry) => String(entry).trim())
1672
- .filter(Boolean);
1673
- const groupAllowEntry = formatGroupAllowlistEntry({
1674
- chatGuid: message.chatGuid,
1675
- chatId: message.chatId ?? undefined,
1676
- chatIdentifier: message.chatIdentifier ?? undefined,
1677
- });
1678
- const groupName = message.chatName?.trim() || undefined;
1679
-
1680
- if (isGroup) {
1681
- if (groupPolicy === "disabled") {
1682
- logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
1683
- logGroupAllowlistHint({
1684
- runtime,
1685
- reason: "groupPolicy=disabled",
1686
- entry: groupAllowEntry,
1687
- chatName: groupName,
1688
- accountId: account.accountId,
1689
- });
1690
- return;
1691
- }
1692
- if (groupPolicy === "allowlist") {
1693
- if (effectiveGroupAllowFrom.length === 0) {
1694
- logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
1695
- logGroupAllowlistHint({
1696
- runtime,
1697
- reason: "groupPolicy=allowlist (empty allowlist)",
1698
- entry: groupAllowEntry,
1699
- chatName: groupName,
1700
- accountId: account.accountId,
1701
- });
1702
- return;
1703
- }
1704
- const allowed = isAllowedBlueBubblesSender({
1705
- allowFrom: effectiveGroupAllowFrom,
1706
- sender: message.senderId,
1707
- chatId: message.chatId ?? undefined,
1708
- chatGuid: message.chatGuid ?? undefined,
1709
- chatIdentifier: message.chatIdentifier ?? undefined,
1710
- });
1711
- if (!allowed) {
1712
- logVerbose(
1713
- core,
1714
- runtime,
1715
- `Blocked BlueBubbles sender ${message.senderId} (not in groupAllowFrom)`,
1716
- );
1717
- logVerbose(
1718
- core,
1719
- runtime,
1720
- `drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`,
1721
- );
1722
- logGroupAllowlistHint({
1723
- runtime,
1724
- reason: "groupPolicy=allowlist (not allowlisted)",
1725
- entry: groupAllowEntry,
1726
- chatName: groupName,
1727
- accountId: account.accountId,
1728
- });
1729
- return;
1730
- }
1731
- }
1732
- } else {
1733
- if (dmPolicy === "disabled") {
1734
- logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`);
1735
- logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`);
1736
- return;
1737
- }
1738
- if (dmPolicy !== "open") {
1739
- const allowed = isAllowedBlueBubblesSender({
1740
- allowFrom: effectiveAllowFrom,
1741
- sender: message.senderId,
1742
- chatId: message.chatId ?? undefined,
1743
- chatGuid: message.chatGuid ?? undefined,
1744
- chatIdentifier: message.chatIdentifier ?? undefined,
1745
- });
1746
- if (!allowed) {
1747
- if (dmPolicy === "pairing") {
1748
- const { code, created } = await core.channel.pairing.upsertPairingRequest({
1749
- channel: "bluebubbles",
1750
- id: message.senderId,
1751
- meta: { name: message.senderName },
1752
- });
1753
- runtime.log?.(
1754
- `[bluebubbles] pairing request sender=${message.senderId} created=${created}`,
1755
- );
1756
- if (created) {
1757
- logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
1758
- try {
1759
- await sendMessageBlueBubbles(
1760
- message.senderId,
1761
- core.channel.pairing.buildPairingReply({
1762
- channel: "bluebubbles",
1763
- idLine: `Your BlueBubbles sender id: ${message.senderId}`,
1764
- code,
1765
- }),
1766
- { cfg: config, accountId: account.accountId },
1767
- );
1768
- statusSink?.({ lastOutboundAt: Date.now() });
1769
- } catch (err) {
1770
- logVerbose(
1771
- core,
1772
- runtime,
1773
- `bluebubbles pairing reply failed for ${message.senderId}: ${String(err)}`,
1774
- );
1775
- runtime.error?.(
1776
- `[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`,
1777
- );
1778
- }
1779
- }
1780
- } else {
1781
- logVerbose(
1782
- core,
1783
- runtime,
1784
- `Blocked unauthorized BlueBubbles sender ${message.senderId} (dmPolicy=${dmPolicy})`,
1785
- );
1786
- logVerbose(
1787
- core,
1788
- runtime,
1789
- `drop: dm sender not allowed sender=${message.senderId} allowFrom=${effectiveAllowFrom.join(",")}`,
1790
- );
1791
- }
1792
- return;
1793
- }
1794
- }
1795
- }
1796
-
1797
- const chatId = message.chatId ?? undefined;
1798
- const chatGuid = message.chatGuid ?? undefined;
1799
- const chatIdentifier = message.chatIdentifier ?? undefined;
1800
- const peerId = isGroup
1801
- ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
1802
- : message.senderId;
1803
-
1804
- const route = core.channel.routing.resolveAgentRoute({
1805
- cfg: config,
1806
- channel: "bluebubbles",
1807
- accountId: account.accountId,
1808
- peer: {
1809
- kind: isGroup ? "group" : "direct",
1810
- id: peerId,
1811
- },
1812
- });
1813
-
1814
- // Mention gating for group chats (parity with iMessage/WhatsApp)
1815
- const messageText = text;
1816
- const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
1817
- const wasMentioned = isGroup
1818
- ? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes)
1819
- : true;
1820
- const canDetectMention = mentionRegexes.length > 0;
1821
- const requireMention = core.channel.groups.resolveRequireMention({
1822
- cfg: config,
1823
- channel: "bluebubbles",
1824
- groupId: peerId,
1825
- accountId: account.accountId,
1826
- });
1827
-
1828
- // Command gating (parity with iMessage/WhatsApp)
1829
- const useAccessGroups = config.commands?.useAccessGroups !== false;
1830
- const hasControlCmd = core.channel.text.hasControlCommand(messageText, config);
1831
- const ownerAllowedForCommands =
1832
- effectiveAllowFrom.length > 0
1833
- ? isAllowedBlueBubblesSender({
1834
- allowFrom: effectiveAllowFrom,
1835
- sender: message.senderId,
1836
- chatId: message.chatId ?? undefined,
1837
- chatGuid: message.chatGuid ?? undefined,
1838
- chatIdentifier: message.chatIdentifier ?? undefined,
1839
- })
1840
- : false;
1841
- const groupAllowedForCommands =
1842
- effectiveGroupAllowFrom.length > 0
1843
- ? isAllowedBlueBubblesSender({
1844
- allowFrom: effectiveGroupAllowFrom,
1845
- sender: message.senderId,
1846
- chatId: message.chatId ?? undefined,
1847
- chatGuid: message.chatGuid ?? undefined,
1848
- chatIdentifier: message.chatIdentifier ?? undefined,
1849
- })
1850
- : false;
1851
- const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
1852
- const commandGate = resolveControlCommandGate({
1853
- useAccessGroups,
1854
- authorizers: [
1855
- { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
1856
- { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
1857
- ],
1858
- allowTextCommands: true,
1859
- hasControlCommand: hasControlCmd,
1860
- });
1861
- const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
1862
-
1863
- // Block control commands from unauthorized senders in groups
1864
- if (isGroup && commandGate.shouldBlock) {
1865
- logInboundDrop({
1866
- log: (msg) => logVerbose(core, runtime, msg),
1867
- channel: "bluebubbles",
1868
- reason: "control command (unauthorized)",
1869
- target: message.senderId,
1870
- });
1871
- return;
1872
- }
1873
-
1874
- // Allow control commands to bypass mention gating when authorized (parity with iMessage)
1875
- const shouldBypassMention =
1876
- isGroup && requireMention && !wasMentioned && commandAuthorized && hasControlCmd;
1877
- const effectiveWasMentioned = wasMentioned || shouldBypassMention;
1878
-
1879
- // Skip group messages that require mention but weren't mentioned
1880
- if (isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
1881
- logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`);
1882
- return;
1883
- }
1884
-
1885
- // Cache allowed inbound messages so later replies can resolve sender/body without
1886
- // surfacing dropped content (allowlist/mention/command gating).
1887
- cacheInboundMessage();
1888
-
1889
- const baseUrl = account.config.serverUrl?.trim();
1890
- const password = account.config.password?.trim();
1891
- const maxBytes =
1892
- account.config.mediaMaxMb && account.config.mediaMaxMb > 0
1893
- ? account.config.mediaMaxMb * 1024 * 1024
1894
- : 8 * 1024 * 1024;
1895
-
1896
- let mediaUrls: string[] = [];
1897
- let mediaPaths: string[] = [];
1898
- let mediaTypes: string[] = [];
1899
- if (attachments.length > 0) {
1900
- if (!baseUrl || !password) {
1901
- logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)");
1902
- } else {
1903
- for (const attachment of attachments) {
1904
- if (!attachment.guid) {
1905
- continue;
1906
- }
1907
- if (attachment.totalBytes && attachment.totalBytes > maxBytes) {
1908
- logVerbose(
1909
- core,
1910
- runtime,
1911
- `attachment too large guid=${attachment.guid} bytes=${attachment.totalBytes}`,
1912
- );
1913
- continue;
1914
- }
1915
- try {
1916
- const downloaded = await downloadBlueBubblesAttachment(attachment, {
1917
- cfg: config,
1918
- accountId: account.accountId,
1919
- maxBytes,
1920
- });
1921
- const saved = await core.channel.media.saveMediaBuffer(
1922
- Buffer.from(downloaded.buffer),
1923
- downloaded.contentType,
1924
- "inbound",
1925
- maxBytes,
1926
- );
1927
- mediaPaths.push(saved.path);
1928
- mediaUrls.push(saved.path);
1929
- if (saved.contentType) {
1930
- mediaTypes.push(saved.contentType);
1931
- }
1932
- } catch (err) {
1933
- logVerbose(
1934
- core,
1935
- runtime,
1936
- `attachment download failed guid=${attachment.guid} err=${String(err)}`,
1937
- );
1938
- }
1939
- }
1940
- }
1941
- }
1942
- let replyToId = message.replyToId;
1943
- let replyToBody = message.replyToBody;
1944
- let replyToSender = message.replyToSender;
1945
- let replyToShortId: string | undefined;
1946
-
1947
- if (isTapbackMessage && tapbackContext?.replyToId) {
1948
- replyToId = tapbackContext.replyToId;
1949
- }
1950
-
1951
- if (replyToId) {
1952
- const cached = resolveReplyContextFromCache({
1953
- accountId: account.accountId,
1954
- replyToId,
1955
- chatGuid: message.chatGuid,
1956
- chatIdentifier: message.chatIdentifier,
1957
- chatId: message.chatId,
1958
- });
1959
- if (cached) {
1960
- if (!replyToBody && cached.body) {
1961
- replyToBody = cached.body;
1962
- }
1963
- if (!replyToSender && cached.senderLabel) {
1964
- replyToSender = cached.senderLabel;
1965
- }
1966
- replyToShortId = cached.shortId;
1967
- if (core.logging.shouldLogVerbose()) {
1968
- const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
1969
- logVerbose(
1970
- core,
1971
- runtime,
1972
- `reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`,
1973
- );
1974
- }
1975
- }
1976
- }
1977
-
1978
- // If no cached short ID, try to get one from the UUID directly
1979
- if (replyToId && !replyToShortId) {
1980
- replyToShortId = getShortIdForUuid(replyToId);
1981
- }
1982
-
1983
- // Use inline [[reply_to:N]] tag format
1984
- // For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
1985
- // For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
1986
- const replyTag = formatReplyTag({ replyToId, replyToShortId });
1987
- const baseBody = replyTag
1988
- ? isTapbackMessage
1989
- ? `${rawBody} ${replyTag}`
1990
- : `${replyTag} ${rawBody}`
1991
- : rawBody;
1992
- const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
1993
- const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
1994
- const groupMembers = isGroup
1995
- ? formatGroupMembers({
1996
- participants: message.participants,
1997
- fallback: message.senderId ? { id: message.senderId, name: message.senderName } : undefined,
1998
- })
1999
- : undefined;
2000
- const storePath = core.channel.session.resolveStorePath(config.session?.store, {
2001
- agentId: route.agentId,
2002
- });
2003
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
2004
- const previousTimestamp = core.channel.session.readSessionUpdatedAt({
2005
- storePath,
2006
- sessionKey: route.sessionKey,
2007
- });
2008
- const body = core.channel.reply.formatAgentEnvelope({
2009
- channel: "BlueBubbles",
2010
- from: fromLabel,
2011
- timestamp: message.timestamp,
2012
- previousTimestamp,
2013
- envelope: envelopeOptions,
2014
- body: baseBody,
2015
- });
2016
- let chatGuidForActions = chatGuid;
2017
- if (!chatGuidForActions && baseUrl && password) {
2018
- const target =
2019
- isGroup && (chatId || chatIdentifier)
2020
- ? chatId
2021
- ? ({ kind: "chat_id", chatId } as const)
2022
- : ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const)
2023
- : ({ kind: "handle", address: message.senderId } as const);
2024
- if (target.kind !== "chat_identifier" || target.chatIdentifier) {
2025
- chatGuidForActions =
2026
- (await resolveChatGuidForTarget({
2027
- baseUrl,
2028
- password,
2029
- target,
2030
- })) ?? undefined;
2031
- }
2032
- }
2033
-
2034
- const ackReactionScope = config.messages?.ackReactionScope ?? "group-mentions";
2035
- const removeAckAfterReply = config.messages?.removeAckAfterReply ?? false;
2036
- const ackReactionValue = resolveBlueBubblesAckReaction({
2037
- cfg: config,
2038
- agentId: route.agentId,
2039
- core,
2040
- runtime,
2041
- });
2042
- const shouldAckReaction = () =>
2043
- Boolean(
2044
- ackReactionValue &&
2045
- core.channel.reactions.shouldAckReaction({
2046
- scope: ackReactionScope,
2047
- isDirect: !isGroup,
2048
- isGroup,
2049
- isMentionableGroup: isGroup,
2050
- requireMention: Boolean(requireMention),
2051
- canDetectMention,
2052
- effectiveWasMentioned,
2053
- shouldBypassMention,
2054
- }),
2055
- );
2056
- const ackMessageId = message.messageId?.trim() || "";
2057
- const ackReactionPromise =
2058
- shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
2059
- ? sendBlueBubblesReaction({
2060
- chatGuid: chatGuidForActions,
2061
- messageGuid: ackMessageId,
2062
- emoji: ackReactionValue,
2063
- opts: { cfg: config, accountId: account.accountId },
2064
- }).then(
2065
- () => true,
2066
- (err) => {
2067
- logVerbose(
2068
- core,
2069
- runtime,
2070
- `ack reaction failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
2071
- );
2072
- return false;
2073
- },
2074
- )
2075
- : null;
2076
-
2077
- // Respect sendReadReceipts config (parity with WhatsApp)
2078
- const sendReadReceipts = account.config.sendReadReceipts !== false;
2079
- if (chatGuidForActions && baseUrl && password && sendReadReceipts) {
2080
- try {
2081
- await markBlueBubblesChatRead(chatGuidForActions, {
2082
- cfg: config,
2083
- accountId: account.accountId,
2084
- });
2085
- logVerbose(core, runtime, `marked read chatGuid=${chatGuidForActions}`);
2086
- } catch (err) {
2087
- runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`);
2088
- }
2089
- } else if (!sendReadReceipts) {
2090
- logVerbose(core, runtime, "mark read skipped (sendReadReceipts=false)");
2091
- } else {
2092
- logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)");
2093
- }
2094
-
2095
- const outboundTarget = isGroup
2096
- ? formatBlueBubblesChatTarget({
2097
- chatId,
2098
- chatGuid: chatGuidForActions ?? chatGuid,
2099
- chatIdentifier,
2100
- }) || peerId
2101
- : chatGuidForActions
2102
- ? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
2103
- : message.senderId;
2104
-
2105
- const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
2106
- const trimmed = messageId?.trim();
2107
- if (!trimmed || trimmed === "ok" || trimmed === "unknown") {
2108
- return;
2109
- }
2110
- // Cache outbound message to get short ID
2111
- const cacheEntry = rememberBlueBubblesReplyCache({
2112
- accountId: account.accountId,
2113
- messageId: trimmed,
2114
- chatGuid: chatGuidForActions ?? chatGuid,
2115
- chatIdentifier,
2116
- chatId,
2117
- senderLabel: "me",
2118
- body: snippet ?? "",
2119
- timestamp: Date.now(),
2120
- });
2121
- const displayId = cacheEntry.shortId || trimmed;
2122
- const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : "";
2123
- core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, {
2124
- sessionKey: route.sessionKey,
2125
- contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
2126
- });
2127
- };
2128
-
2129
- const ctxPayload = {
2130
- Body: body,
2131
- BodyForAgent: body,
2132
- RawBody: rawBody,
2133
- CommandBody: rawBody,
2134
- BodyForCommands: rawBody,
2135
- MediaUrl: mediaUrls[0],
2136
- MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
2137
- MediaPath: mediaPaths[0],
2138
- MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
2139
- MediaType: mediaTypes[0],
2140
- MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
2141
- From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
2142
- To: `bluebubbles:${outboundTarget}`,
2143
- SessionKey: route.sessionKey,
2144
- AccountId: route.accountId,
2145
- ChatType: isGroup ? "group" : "direct",
2146
- ConversationLabel: fromLabel,
2147
- // Use short ID for token savings (agent can use this to reference the message)
2148
- ReplyToId: replyToShortId || replyToId,
2149
- ReplyToIdFull: replyToId,
2150
- ReplyToBody: replyToBody,
2151
- ReplyToSender: replyToSender,
2152
- GroupSubject: groupSubject,
2153
- GroupMembers: groupMembers,
2154
- SenderName: message.senderName || undefined,
2155
- SenderId: message.senderId,
2156
- Provider: "bluebubbles",
2157
- Surface: "bluebubbles",
2158
- // Use short ID for token savings (agent can use this to reference the message)
2159
- MessageSid: messageShortId || message.messageId,
2160
- MessageSidFull: message.messageId,
2161
- Timestamp: message.timestamp,
2162
- OriginatingChannel: "bluebubbles",
2163
- OriginatingTo: `bluebubbles:${outboundTarget}`,
2164
- WasMentioned: effectiveWasMentioned,
2165
- CommandAuthorized: commandAuthorized,
2166
- };
2167
-
2168
- let sentMessage = false;
2169
- let streamingActive = false;
2170
- let typingRestartTimer: NodeJS.Timeout | undefined;
2171
- const typingRestartDelayMs = 150;
2172
- const clearTypingRestartTimer = () => {
2173
- if (typingRestartTimer) {
2174
- clearTimeout(typingRestartTimer);
2175
- typingRestartTimer = undefined;
2176
- }
2177
- };
2178
- const restartTypingSoon = () => {
2179
- if (!streamingActive || !chatGuidForActions || !baseUrl || !password) {
2180
- return;
2181
- }
2182
- clearTypingRestartTimer();
2183
- typingRestartTimer = setTimeout(() => {
2184
- typingRestartTimer = undefined;
2185
- if (!streamingActive) {
2186
- return;
2187
- }
2188
- sendBlueBubblesTyping(chatGuidForActions, true, {
2189
- cfg: config,
2190
- accountId: account.accountId,
2191
- }).catch((err) => {
2192
- runtime.error?.(`[bluebubbles] typing restart failed: ${String(err)}`);
2193
- });
2194
- }, typingRestartDelayMs);
2195
- };
2196
- try {
2197
- const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
2198
- cfg: config,
2199
- agentId: route.agentId,
2200
- channel: "bluebubbles",
2201
- accountId: account.accountId,
2202
- });
2203
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
2204
- ctx: ctxPayload,
2205
- cfg: config,
2206
- dispatcherOptions: {
2207
- ...prefixOptions,
2208
- deliver: async (payload, info) => {
2209
- const rawReplyToId =
2210
- typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
2211
- // Resolve short ID (e.g., "5") to full UUID
2212
- const replyToMessageGuid = rawReplyToId
2213
- ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
2214
- : "";
2215
- const mediaList = payload.mediaUrls?.length
2216
- ? payload.mediaUrls
2217
- : payload.mediaUrl
2218
- ? [payload.mediaUrl]
2219
- : [];
2220
- if (mediaList.length > 0) {
2221
- const tableMode = core.channel.text.resolveMarkdownTableMode({
2222
- cfg: config,
2223
- channel: "bluebubbles",
2224
- accountId: account.accountId,
2225
- });
2226
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
2227
- let first = true;
2228
- for (const mediaUrl of mediaList) {
2229
- const caption = first ? text : undefined;
2230
- first = false;
2231
- const result = await sendBlueBubblesMedia({
2232
- cfg: config,
2233
- to: outboundTarget,
2234
- mediaUrl,
2235
- caption: caption ?? undefined,
2236
- replyToId: replyToMessageGuid || null,
2237
- accountId: account.accountId,
2238
- });
2239
- const cachedBody = (caption ?? "").trim() || "<media:attachment>";
2240
- maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
2241
- sentMessage = true;
2242
- statusSink?.({ lastOutboundAt: Date.now() });
2243
- if (info.kind === "block") {
2244
- restartTypingSoon();
2245
- }
2246
- }
2247
- return;
2248
- }
2249
-
2250
- const textLimit =
2251
- account.config.textChunkLimit && account.config.textChunkLimit > 0
2252
- ? account.config.textChunkLimit
2253
- : DEFAULT_TEXT_LIMIT;
2254
- const chunkMode = account.config.chunkMode ?? "length";
2255
- const tableMode = core.channel.text.resolveMarkdownTableMode({
2256
- cfg: config,
2257
- channel: "bluebubbles",
2258
- accountId: account.accountId,
2259
- });
2260
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
2261
- const chunks =
2262
- chunkMode === "newline"
2263
- ? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
2264
- : core.channel.text.chunkMarkdownText(text, textLimit);
2265
- if (!chunks.length && text) {
2266
- chunks.push(text);
2267
- }
2268
- if (!chunks.length) {
2269
- return;
2270
- }
2271
- for (let i = 0; i < chunks.length; i++) {
2272
- const chunk = chunks[i];
2273
- const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
2274
- cfg: config,
2275
- accountId: account.accountId,
2276
- replyToMessageGuid: replyToMessageGuid || undefined,
2277
- });
2278
- maybeEnqueueOutboundMessageId(result.messageId, chunk);
2279
- sentMessage = true;
2280
- statusSink?.({ lastOutboundAt: Date.now() });
2281
- if (info.kind === "block") {
2282
- restartTypingSoon();
2283
- }
2284
- }
2285
- },
2286
- onReplyStart: async () => {
2287
- if (!chatGuidForActions) {
2288
- return;
2289
- }
2290
- if (!baseUrl || !password) {
2291
- return;
2292
- }
2293
- streamingActive = true;
2294
- clearTypingRestartTimer();
2295
- try {
2296
- await sendBlueBubblesTyping(chatGuidForActions, true, {
2297
- cfg: config,
2298
- accountId: account.accountId,
2299
- });
2300
- } catch (err) {
2301
- runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
2302
- }
2303
- },
2304
- onIdle: async () => {
2305
- if (!chatGuidForActions) {
2306
- return;
2307
- }
2308
- if (!baseUrl || !password) {
2309
- return;
2310
- }
2311
- // Intentionally no-op for block streaming. We stop typing in finally
2312
- // after the run completes to avoid flicker between paragraph blocks.
2313
- },
2314
- onError: (err, info) => {
2315
- runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
2316
- },
2317
- },
2318
- replyOptions: {
2319
- onModelSelected,
2320
- disableBlockStreaming:
2321
- typeof account.config.blockStreaming === "boolean"
2322
- ? !account.config.blockStreaming
2323
- : undefined,
2324
- },
2325
- });
2326
- } finally {
2327
- const shouldStopTyping =
2328
- Boolean(chatGuidForActions && baseUrl && password) && (streamingActive || !sentMessage);
2329
- streamingActive = false;
2330
- clearTypingRestartTimer();
2331
- if (sentMessage && chatGuidForActions && ackMessageId) {
2332
- core.channel.reactions.removeAckReactionAfterReply({
2333
- removeAfterReply: removeAckAfterReply,
2334
- ackReactionPromise,
2335
- ackReactionValue: ackReactionValue ?? null,
2336
- remove: () =>
2337
- sendBlueBubblesReaction({
2338
- chatGuid: chatGuidForActions,
2339
- messageGuid: ackMessageId,
2340
- emoji: ackReactionValue ?? "",
2341
- remove: true,
2342
- opts: { cfg: config, accountId: account.accountId },
2343
- }),
2344
- onError: (err) => {
2345
- logAckFailure({
2346
- log: (msg) => logVerbose(core, runtime, msg),
2347
- channel: "bluebubbles",
2348
- target: `${chatGuidForActions}/${ackMessageId}`,
2349
- error: err,
2350
- });
2351
- },
2352
- });
2353
- }
2354
- if (shouldStopTyping && chatGuidForActions) {
2355
- // Stop typing after streaming completes to avoid a stuck indicator.
2356
- sendBlueBubblesTyping(chatGuidForActions, false, {
2357
- cfg: config,
2358
- accountId: account.accountId,
2359
- }).catch((err) => {
2360
- logTypingFailure({
2361
- log: (msg) => logVerbose(core, runtime, msg),
2362
- channel: "bluebubbles",
2363
- action: "stop",
2364
- target: chatGuidForActions,
2365
- error: err,
2366
- });
2367
- });
2368
- }
2369
- }
2370
- }
2371
-
2372
- async function processReaction(
2373
- reaction: NormalizedWebhookReaction,
2374
- target: WebhookTarget,
2375
- ): Promise<void> {
2376
- const { account, config, runtime, core } = target;
2377
- if (reaction.fromMe) {
2378
- return;
2379
- }
2380
-
2381
- const dmPolicy = account.config.dmPolicy ?? "pairing";
2382
- const groupPolicy = account.config.groupPolicy ?? "allowlist";
2383
- const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
2384
- const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
2385
- const storeAllowFrom = await core.channel.pairing
2386
- .readAllowFromStore("bluebubbles")
2387
- .catch(() => []);
2388
- const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
2389
- .map((entry) => String(entry).trim())
2390
- .filter(Boolean);
2391
- const effectiveGroupAllowFrom = [
2392
- ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
2393
- ...storeAllowFrom,
2394
- ]
2395
- .map((entry) => String(entry).trim())
2396
- .filter(Boolean);
2397
-
2398
- if (reaction.isGroup) {
2399
- if (groupPolicy === "disabled") {
2400
- return;
2401
- }
2402
- if (groupPolicy === "allowlist") {
2403
- if (effectiveGroupAllowFrom.length === 0) {
2404
- return;
2405
- }
2406
- const allowed = isAllowedBlueBubblesSender({
2407
- allowFrom: effectiveGroupAllowFrom,
2408
- sender: reaction.senderId,
2409
- chatId: reaction.chatId ?? undefined,
2410
- chatGuid: reaction.chatGuid ?? undefined,
2411
- chatIdentifier: reaction.chatIdentifier ?? undefined,
2412
- });
2413
- if (!allowed) {
2414
- return;
2415
- }
2416
- }
2417
- } else {
2418
- if (dmPolicy === "disabled") {
2419
- return;
2420
- }
2421
- if (dmPolicy !== "open") {
2422
- const allowed = isAllowedBlueBubblesSender({
2423
- allowFrom: effectiveAllowFrom,
2424
- sender: reaction.senderId,
2425
- chatId: reaction.chatId ?? undefined,
2426
- chatGuid: reaction.chatGuid ?? undefined,
2427
- chatIdentifier: reaction.chatIdentifier ?? undefined,
2428
- });
2429
- if (!allowed) {
2430
- return;
2431
- }
2432
- }
2433
- }
2434
-
2435
- const chatId = reaction.chatId ?? undefined;
2436
- const chatGuid = reaction.chatGuid ?? undefined;
2437
- const chatIdentifier = reaction.chatIdentifier ?? undefined;
2438
- const peerId = reaction.isGroup
2439
- ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
2440
- : reaction.senderId;
2441
-
2442
- const route = core.channel.routing.resolveAgentRoute({
2443
- cfg: config,
2444
- channel: "bluebubbles",
2445
- accountId: account.accountId,
2446
- peer: {
2447
- kind: reaction.isGroup ? "group" : "direct",
2448
- id: peerId,
2449
- },
2450
- });
2451
-
2452
- const senderLabel = reaction.senderName || reaction.senderId;
2453
- const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
2454
- // Use short ID for token savings
2455
- const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
2456
- // Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
2457
- const text =
2458
- reaction.action === "removed"
2459
- ? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}`
2460
- : `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`;
2461
- core.system.enqueueSystemEvent(text, {
2462
- sessionKey: route.sessionKey,
2463
- contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
2464
- });
2465
- logVerbose(core, runtime, `reaction event enqueued: ${text}`);
2466
- }
2467
-
2468
470
  export async function monitorBlueBubblesProvider(
2469
471
  options: BlueBubblesMonitorOptions,
2470
472
  ): Promise<void> {
@@ -2510,10 +512,4 @@ export async function monitorBlueBubblesProvider(
2510
512
  });
2511
513
  }
2512
514
 
2513
- export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
2514
- const raw = config?.webhookPath?.trim();
2515
- if (raw) {
2516
- return normalizeWebhookPath(raw);
2517
- }
2518
- return DEFAULT_WEBHOOK_PATH;
2519
- }
515
+ export { _resetBlueBubblesShortIdState, resolveBlueBubblesMessageId, resolveWebhookPathFromConfig };