@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/index.ts +0 -1
- package/openclaw.plugin.json +1 -3
- package/package.json +5 -2
- package/src/accounts.ts +13 -5
- package/src/actions.test.ts +1 -2
- package/src/actions.ts +70 -35
- package/src/attachments.test.ts +1 -2
- package/src/attachments.ts +38 -20
- package/src/channel.ts +50 -35
- package/src/chat.test.ts +0 -1
- package/src/chat.ts +48 -24
- package/src/media-send.ts +12 -6
- package/src/monitor.test.ts +253 -57
- package/src/monitor.ts +377 -163
- package/src/onboarding.ts +19 -7
- package/src/probe.ts +19 -11
- package/src/reactions.test.ts +0 -1
- package/src/reactions.ts +20 -15
- package/src/send.test.ts +1 -2
- package/src/send.ts +75 -26
- package/src/targets.test.ts +0 -1
- package/src/targets.ts +151 -52
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 {
|
|
12
|
-
import {
|
|
10
|
+
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
|
11
|
+
import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
|
|
13
12
|
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
|
14
|
-
import {
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
185
|
+
if (!replyToId) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
177
188
|
|
|
178
189
|
const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
|
|
179
|
-
if (!cached)
|
|
180
|
-
|
|
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)
|
|
197
|
-
|
|
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(
|
|
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 =
|
|
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)
|
|
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))
|
|
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)
|
|
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)
|
|
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
|
-
//
|
|
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)
|
|
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))
|
|
381
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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))
|
|
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)
|
|
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)
|
|
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)
|
|
561
|
-
|
|
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)
|
|
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)
|
|
649
|
+
if (!record) {
|
|
650
|
+
return undefined;
|
|
651
|
+
}
|
|
578
652
|
const value = record[key];
|
|
579
|
-
if (typeof value === "number" && Number.isFinite(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))
|
|
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 =
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
793
|
+
if (!normalized?.id) {
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
705
796
|
const key = normalized.id.toLowerCase();
|
|
706
|
-
if (seen.has(key))
|
|
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)
|
|
813
|
+
if (!entry?.id) {
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
721
816
|
const key = entry.id.toLowerCase();
|
|
722
|
-
if (seen.has(key))
|
|
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)
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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)
|
|
834
|
+
if (!guid) {
|
|
835
|
+
return undefined;
|
|
836
|
+
}
|
|
738
837
|
const parts = guid.split(";");
|
|
739
838
|
if (parts.length >= 3) {
|
|
740
|
-
if (parts[1] === "+")
|
|
741
|
-
|
|
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)
|
|
857
|
+
if (!guid) {
|
|
858
|
+
return undefined;
|
|
859
|
+
}
|
|
751
860
|
const parts = guid.split(";");
|
|
752
|
-
if (parts.length < 3)
|
|
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)
|
|
874
|
+
if (guid) {
|
|
875
|
+
return `chat_guid:${guid}`;
|
|
876
|
+
}
|
|
764
877
|
const chatId = params.chatId;
|
|
765
|
-
if (typeof chatId === "number" && Number.isFinite(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)
|
|
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))
|
|
866
|
-
|
|
867
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
1048
|
+
if (!emoji) {
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
920
1051
|
const quotedText = extractQuotedTapbackText(trimmed);
|
|
921
|
-
if (params.requireQuoted && !quotedText)
|
|
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)
|
|
1061
|
+
if (!emoji) {
|
|
1062
|
+
return null;
|
|
1063
|
+
}
|
|
929
1064
|
const quotedText = extractQuotedTapbackText(trimmed);
|
|
930
|
-
if (params.requireQuoted && !quotedText)
|
|
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)
|
|
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)
|
|
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)
|
|
1117
|
+
if (!message) {
|
|
1118
|
+
return null;
|
|
1119
|
+
}
|
|
977
1120
|
return message;
|
|
978
1121
|
}
|
|
979
1122
|
|
|
980
|
-
function normalizeWebhookMessage(
|
|
1123
|
+
function normalizeWebhookMessage(
|
|
1124
|
+
payload: Record<string, unknown>,
|
|
1125
|
+
): NormalizedWebhookMessage | null {
|
|
981
1126
|
const message = extractMessagePayload(payload);
|
|
982
|
-
if (!message)
|
|
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 ??
|
|
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)
|
|
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(
|
|
1285
|
+
function normalizeWebhookReaction(
|
|
1286
|
+
payload: Record<string, unknown>,
|
|
1287
|
+
): NormalizedWebhookReaction | null {
|
|
1138
1288
|
const message = extractMessagePayload(payload);
|
|
1139
|
-
if (!message)
|
|
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)
|
|
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 ??
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
-
|
|
1354
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
1780
|
-
|
|
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
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
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")
|
|
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 =
|
|
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)
|
|
2041
|
-
|
|
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
|
-
|
|
2053
|
-
|
|
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)
|
|
2067
|
-
|
|
2068
|
-
|
|
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)
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
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 (
|
|
2126
|
-
// Stop typing
|
|
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)
|
|
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")
|
|
2370
|
+
if (groupPolicy === "disabled") {
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2169
2373
|
if (groupPolicy === "allowlist") {
|
|
2170
|
-
if (effectiveGroupAllowFrom.length === 0)
|
|
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)
|
|
2384
|
+
if (!allowed) {
|
|
2385
|
+
return;
|
|
2386
|
+
}
|
|
2179
2387
|
}
|
|
2180
2388
|
} else {
|
|
2181
|
-
if (dmPolicy === "disabled")
|
|
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)
|
|
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)
|
|
2486
|
+
if (raw) {
|
|
2487
|
+
return normalizeWebhookPath(raw);
|
|
2488
|
+
}
|
|
2275
2489
|
return DEFAULT_WEBHOOK_PATH;
|
|
2276
2490
|
}
|