@openclaw/bluebubbles 2026.3.1 → 2026.3.7

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,20 +1,14 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
3
  import {
5
- isRequestBodyLimitError,
6
- readRequestBodyWithLimit,
7
- registerWebhookTarget,
8
- rejectNonPostWebhookRequest,
9
- requestBodyErrorToText,
10
- resolveSingleWebhookTarget,
11
- resolveWebhookTargets,
12
- } from "openclaw/plugin-sdk";
13
- import {
14
- normalizeWebhookMessage,
15
- normalizeWebhookReaction,
16
- type NormalizedWebhookMessage,
17
- } from "./monitor-normalize.js";
4
+ createWebhookInFlightLimiter,
5
+ registerWebhookTargetWithPluginRoute,
6
+ readWebhookBodyOrReject,
7
+ resolveWebhookTargetWithAuthOrRejectSync,
8
+ withResolvedWebhookRequestPipeline,
9
+ } from "openclaw/plugin-sdk/bluebubbles";
10
+ import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
11
+ import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
18
12
  import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
19
13
  import {
20
14
  _resetBlueBubblesShortIdState,
@@ -24,229 +18,44 @@ import {
24
18
  DEFAULT_WEBHOOK_PATH,
25
19
  normalizeWebhookPath,
26
20
  resolveWebhookPathFromConfig,
27
- type BlueBubblesCoreRuntime,
28
21
  type BlueBubblesMonitorOptions,
29
22
  type WebhookTarget,
30
23
  } from "./monitor-shared.js";
31
24
  import { fetchBlueBubblesServerInfo } from "./probe.js";
32
25
  import { getBlueBubblesRuntime } from "./runtime.js";
33
26
 
34
- /**
35
- * Entry type for debouncing inbound messages.
36
- * Captures the normalized message and its target for later combined processing.
37
- */
38
- type BlueBubblesDebounceEntry = {
39
- message: NormalizedWebhookMessage;
40
- target: WebhookTarget;
41
- };
42
-
43
- /**
44
- * Default debounce window for inbound message coalescing (ms).
45
- * This helps combine URL text + link preview balloon messages that BlueBubbles
46
- * sends as separate webhook events when no explicit inbound debounce config exists.
47
- */
48
- const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
49
-
50
- /**
51
- * Combines multiple debounced messages into a single message for processing.
52
- * Used when multiple webhook events arrive within the debounce window.
53
- */
54
- function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
55
- if (entries.length === 0) {
56
- throw new Error("Cannot combine empty entries");
57
- }
58
- if (entries.length === 1) {
59
- return entries[0].message;
60
- }
61
-
62
- // Use the first message as the base (typically the text message)
63
- const first = entries[0].message;
64
-
65
- // Combine text from all entries, filtering out duplicates and empty strings
66
- const seenTexts = new Set<string>();
67
- const textParts: string[] = [];
68
-
69
- for (const entry of entries) {
70
- const text = entry.message.text.trim();
71
- if (!text) {
72
- continue;
73
- }
74
- // Skip duplicate text (URL might be in both text message and balloon)
75
- const normalizedText = text.toLowerCase();
76
- if (seenTexts.has(normalizedText)) {
77
- continue;
78
- }
79
- seenTexts.add(normalizedText);
80
- textParts.push(text);
81
- }
82
-
83
- // Merge attachments from all entries
84
- const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
85
-
86
- // Use the latest timestamp
87
- const timestamps = entries
88
- .map((e) => e.message.timestamp)
89
- .filter((t): t is number => typeof t === "number");
90
- const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
91
-
92
- // Collect all message IDs for reference
93
- const messageIds = entries
94
- .map((e) => e.message.messageId)
95
- .filter((id): id is string => Boolean(id));
96
-
97
- // Prefer reply context from any entry that has it
98
- const entryWithReply = entries.find((e) => e.message.replyToId);
99
-
100
- return {
101
- ...first,
102
- text: textParts.join(" "),
103
- attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
104
- timestamp: latestTimestamp,
105
- // Use first message's ID as primary (for reply reference), but we've coalesced others
106
- messageId: messageIds[0] ?? first.messageId,
107
- // Preserve reply context if present
108
- replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
109
- replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
110
- replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
111
- // Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
112
- balloonBundleId: undefined,
113
- };
114
- }
115
-
116
27
  const webhookTargets = new Map<string, WebhookTarget[]>();
28
+ const webhookInFlightLimiter = createWebhookInFlightLimiter();
29
+ const debounceRegistry = createBlueBubblesDebounceRegistry({ processMessage });
117
30
 
118
- type BlueBubblesDebouncer = {
119
- enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
120
- flushKey: (key: string) => Promise<void>;
121
- };
122
-
123
- /**
124
- * Maps webhook targets to their inbound debouncers.
125
- * Each target gets its own debouncer keyed by a unique identifier.
126
- */
127
- const targetDebouncers = new Map<WebhookTarget, BlueBubblesDebouncer>();
128
-
129
- function resolveBlueBubblesDebounceMs(
130
- config: OpenClawConfig,
131
- core: BlueBubblesCoreRuntime,
132
- ): number {
133
- const inbound = config.messages?.inbound;
134
- const hasExplicitDebounce =
135
- typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
136
- if (!hasExplicitDebounce) {
137
- return DEFAULT_INBOUND_DEBOUNCE_MS;
138
- }
139
- return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
140
- }
141
-
142
- /**
143
- * Creates or retrieves a debouncer for a webhook target.
144
- */
145
- function getOrCreateDebouncer(target: WebhookTarget) {
146
- const existing = targetDebouncers.get(target);
147
- if (existing) {
148
- return existing;
149
- }
150
-
151
- const { account, config, runtime, core } = target;
152
-
153
- const debouncer = core.channel.debounce.createInboundDebouncer<BlueBubblesDebounceEntry>({
154
- debounceMs: resolveBlueBubblesDebounceMs(config, core),
155
- buildKey: (entry) => {
156
- const msg = entry.message;
157
- // Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
158
- // same message (e.g., text-only then text+attachment).
159
- //
160
- // For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
161
- // messageId than the originating text. When present, key by associatedMessageGuid
162
- // to keep text + balloon coalescing working.
163
- const balloonBundleId = msg.balloonBundleId?.trim();
164
- const associatedMessageGuid = msg.associatedMessageGuid?.trim();
165
- if (balloonBundleId && associatedMessageGuid) {
166
- return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
167
- }
168
-
169
- const messageId = msg.messageId?.trim();
170
- if (messageId) {
171
- return `bluebubbles:${account.accountId}:msg:${messageId}`;
172
- }
173
-
174
- const chatKey =
175
- msg.chatGuid?.trim() ??
176
- msg.chatIdentifier?.trim() ??
177
- (msg.chatId ? String(msg.chatId) : "dm");
178
- return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
179
- },
180
- shouldDebounce: (entry) => {
181
- const msg = entry.message;
182
- // Skip debouncing for from-me messages (they're just cached, not processed)
183
- if (msg.fromMe) {
184
- return false;
185
- }
186
- // Skip debouncing for control commands - process immediately
187
- if (core.channel.text.hasControlCommand(msg.text, config)) {
188
- return false;
189
- }
190
- // Debounce all other messages to coalesce rapid-fire webhook events
191
- // (e.g., text+image arriving as separate webhooks for the same messageId)
192
- return true;
193
- },
194
- onFlush: async (entries) => {
195
- if (entries.length === 0) {
196
- return;
197
- }
198
-
199
- // Use target from first entry (all entries have same target due to key structure)
200
- const flushTarget = entries[0].target;
201
-
202
- if (entries.length === 1) {
203
- // Single message - process normally
204
- await processMessage(entries[0].message, flushTarget);
205
- return;
206
- }
207
-
208
- // Multiple messages - combine and process
209
- const combined = combineDebounceEntries(entries);
210
-
211
- if (core.logging.shouldLogVerbose()) {
212
- const count = entries.length;
213
- const preview = combined.text.slice(0, 50);
214
- runtime.log?.(
215
- `[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
216
- );
217
- }
218
-
219
- await processMessage(combined, flushTarget);
220
- },
221
- onError: (err) => {
222
- runtime.error?.(`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`);
31
+ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
32
+ const registered = registerWebhookTargetWithPluginRoute({
33
+ targetsByPath: webhookTargets,
34
+ target,
35
+ route: {
36
+ auth: "plugin",
37
+ match: "exact",
38
+ pluginId: "bluebubbles",
39
+ source: "bluebubbles-webhook",
40
+ accountId: target.account.accountId,
41
+ log: target.runtime.log,
42
+ handler: async (req, res) => {
43
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
44
+ if (!handled && !res.headersSent) {
45
+ res.statusCode = 404;
46
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
47
+ res.end("Not Found");
48
+ }
49
+ },
223
50
  },
224
51
  });
225
-
226
- targetDebouncers.set(target, debouncer);
227
- return debouncer;
228
- }
229
-
230
- /**
231
- * Removes a debouncer for a target (called during unregistration).
232
- */
233
- function removeDebouncer(target: WebhookTarget): void {
234
- targetDebouncers.delete(target);
235
- }
236
-
237
- export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
238
- const registered = registerWebhookTarget(webhookTargets, target);
239
52
  return () => {
240
53
  registered.unregister();
241
54
  // Clean up debouncer when target is unregistered
242
- removeDebouncer(registered.target);
55
+ debounceRegistry.removeDebouncer(registered.target);
243
56
  };
244
57
  }
245
58
 
246
- type ReadBlueBubblesWebhookBodyResult =
247
- | { ok: true; value: unknown }
248
- | { ok: false; statusCode: number; error: string };
249
-
250
59
  function parseBlueBubblesWebhookPayload(
251
60
  rawBody: string,
252
61
  ): { ok: true; value: unknown } | { ok: false; error: string } {
@@ -270,36 +79,6 @@ function parseBlueBubblesWebhookPayload(
270
79
  }
271
80
  }
272
81
 
273
- async function readBlueBubblesWebhookBody(
274
- req: IncomingMessage,
275
- maxBytes: number,
276
- ): Promise<ReadBlueBubblesWebhookBodyResult> {
277
- try {
278
- const rawBody = await readRequestBodyWithLimit(req, {
279
- maxBytes,
280
- timeoutMs: 30_000,
281
- });
282
- const parsed = parseBlueBubblesWebhookPayload(rawBody);
283
- if (!parsed.ok) {
284
- return { ok: false, statusCode: 400, error: parsed.error };
285
- }
286
- return parsed;
287
- } catch (error) {
288
- if (isRequestBodyLimitError(error)) {
289
- return {
290
- ok: false,
291
- statusCode: error.statusCode,
292
- error: requestBodyErrorToText(error.code),
293
- };
294
- }
295
- return {
296
- ok: false,
297
- statusCode: 400,
298
- error: error instanceof Error ? error.message : String(error),
299
- };
300
- }
301
- }
302
-
303
82
  function asRecord(value: unknown): Record<string, unknown> | null {
304
83
  return value && typeof value === "object" && !Array.isArray(value)
305
84
  ? (value as Record<string, unknown>)
@@ -342,143 +121,145 @@ export async function handleBlueBubblesWebhookRequest(
342
121
  req: IncomingMessage,
343
122
  res: ServerResponse,
344
123
  ): Promise<boolean> {
345
- const resolved = resolveWebhookTargets(req, webhookTargets);
346
- if (!resolved) {
347
- return false;
348
- }
349
- const { path, targets } = resolved;
350
- const url = new URL(req.url ?? "/", "http://localhost");
124
+ return await withResolvedWebhookRequestPipeline({
125
+ req,
126
+ res,
127
+ targetsByPath: webhookTargets,
128
+ allowMethods: ["POST"],
129
+ inFlightLimiter: webhookInFlightLimiter,
130
+ handle: async ({ path, targets }) => {
131
+ const url = new URL(req.url ?? "/", "http://localhost");
132
+ const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
133
+ const headerToken =
134
+ req.headers["x-guid"] ??
135
+ req.headers["x-password"] ??
136
+ req.headers["x-bluebubbles-guid"] ??
137
+ req.headers["authorization"];
138
+ const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
139
+ const target = resolveWebhookTargetWithAuthOrRejectSync({
140
+ targets,
141
+ res,
142
+ isMatch: (target) => {
143
+ const token = target.account.config.password?.trim() ?? "";
144
+ return safeEqualSecret(guid, token);
145
+ },
146
+ });
147
+ if (!target) {
148
+ console.warn(
149
+ `[bluebubbles] webhook rejected: status=${res.statusCode} path=${path} guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
150
+ );
151
+ return true;
152
+ }
153
+ const body = await readWebhookBodyOrReject({
154
+ req,
155
+ res,
156
+ profile: "post-auth",
157
+ invalidBodyMessage: "invalid payload",
158
+ });
159
+ if (!body.ok) {
160
+ console.warn(`[bluebubbles] webhook rejected: status=${res.statusCode}`);
161
+ return true;
162
+ }
351
163
 
352
- if (rejectNonPostWebhookRequest(req, res)) {
353
- return true;
354
- }
164
+ const parsed = parseBlueBubblesWebhookPayload(body.value);
165
+ if (!parsed.ok) {
166
+ res.statusCode = 400;
167
+ res.end(parsed.error);
168
+ console.warn(`[bluebubbles] webhook rejected: ${parsed.error}`);
169
+ return true;
170
+ }
355
171
 
356
- const body = await readBlueBubblesWebhookBody(req, 1024 * 1024);
357
- if (!body.ok) {
358
- res.statusCode = body.statusCode;
359
- res.end(body.error ?? "invalid payload");
360
- console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
361
- return true;
362
- }
172
+ const payload = asRecord(parsed.value) ?? {};
173
+ const firstTarget = targets[0];
174
+ if (firstTarget) {
175
+ logVerbose(
176
+ firstTarget.core,
177
+ firstTarget.runtime,
178
+ `webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
179
+ );
180
+ }
181
+ const eventTypeRaw = payload.type;
182
+ const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : "";
183
+ const allowedEventTypes = new Set([
184
+ "new-message",
185
+ "updated-message",
186
+ "message-reaction",
187
+ "reaction",
188
+ ]);
189
+ if (eventType && !allowedEventTypes.has(eventType)) {
190
+ res.statusCode = 200;
191
+ res.end("ok");
192
+ if (firstTarget) {
193
+ logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`);
194
+ }
195
+ return true;
196
+ }
197
+ const reaction = normalizeWebhookReaction(payload);
198
+ if (
199
+ (eventType === "updated-message" ||
200
+ eventType === "message-reaction" ||
201
+ eventType === "reaction") &&
202
+ !reaction
203
+ ) {
204
+ res.statusCode = 200;
205
+ res.end("ok");
206
+ if (firstTarget) {
207
+ logVerbose(
208
+ firstTarget.core,
209
+ firstTarget.runtime,
210
+ `webhook ignored ${eventType || "event"} without reaction`,
211
+ );
212
+ }
213
+ return true;
214
+ }
215
+ const message = reaction ? null : normalizeWebhookMessage(payload);
216
+ if (!message && !reaction) {
217
+ res.statusCode = 400;
218
+ res.end("invalid payload");
219
+ console.warn("[bluebubbles] webhook rejected: unable to parse message payload");
220
+ return true;
221
+ }
363
222
 
364
- const payload = asRecord(body.value) ?? {};
365
- const firstTarget = targets[0];
366
- if (firstTarget) {
367
- logVerbose(
368
- firstTarget.core,
369
- firstTarget.runtime,
370
- `webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
371
- );
372
- }
373
- const eventTypeRaw = payload.type;
374
- const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : "";
375
- const allowedEventTypes = new Set([
376
- "new-message",
377
- "updated-message",
378
- "message-reaction",
379
- "reaction",
380
- ]);
381
- if (eventType && !allowedEventTypes.has(eventType)) {
382
- res.statusCode = 200;
383
- res.end("ok");
384
- if (firstTarget) {
385
- logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`);
386
- }
387
- return true;
388
- }
389
- const reaction = normalizeWebhookReaction(payload);
390
- if (
391
- (eventType === "updated-message" ||
392
- eventType === "message-reaction" ||
393
- eventType === "reaction") &&
394
- !reaction
395
- ) {
396
- res.statusCode = 200;
397
- res.end("ok");
398
- if (firstTarget) {
399
- logVerbose(
400
- firstTarget.core,
401
- firstTarget.runtime,
402
- `webhook ignored ${eventType || "event"} without reaction`,
403
- );
404
- }
405
- return true;
406
- }
407
- const message = reaction ? null : normalizeWebhookMessage(payload);
408
- if (!message && !reaction) {
409
- res.statusCode = 400;
410
- res.end("invalid payload");
411
- console.warn("[bluebubbles] webhook rejected: unable to parse message payload");
412
- return true;
413
- }
223
+ target.statusSink?.({ lastInboundAt: Date.now() });
224
+ if (reaction) {
225
+ processReaction(reaction, target).catch((err) => {
226
+ target.runtime.error?.(
227
+ `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
228
+ );
229
+ });
230
+ } else if (message) {
231
+ // Route messages through debouncer to coalesce rapid-fire events
232
+ // (e.g., text message + URL balloon arriving as separate webhooks)
233
+ const debouncer = debounceRegistry.getOrCreateDebouncer(target);
234
+ debouncer.enqueue({ message, target }).catch((err) => {
235
+ target.runtime.error?.(
236
+ `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
237
+ );
238
+ });
239
+ }
414
240
 
415
- const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
416
- const headerToken =
417
- req.headers["x-guid"] ??
418
- req.headers["x-password"] ??
419
- req.headers["x-bluebubbles-guid"] ??
420
- req.headers["authorization"];
421
- const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
422
- const matchedTarget = resolveSingleWebhookTarget(targets, (target) => {
423
- const token = target.account.config.password?.trim() ?? "";
424
- return safeEqualSecret(guid, token);
241
+ res.statusCode = 200;
242
+ res.end("ok");
243
+ if (reaction) {
244
+ if (firstTarget) {
245
+ logVerbose(
246
+ firstTarget.core,
247
+ firstTarget.runtime,
248
+ `webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`,
249
+ );
250
+ }
251
+ } else if (message) {
252
+ if (firstTarget) {
253
+ logVerbose(
254
+ firstTarget.core,
255
+ firstTarget.runtime,
256
+ `webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
257
+ );
258
+ }
259
+ }
260
+ return true;
261
+ },
425
262
  });
426
-
427
- if (matchedTarget.kind === "none") {
428
- res.statusCode = 401;
429
- res.end("unauthorized");
430
- console.warn(
431
- `[bluebubbles] webhook rejected: unauthorized guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
432
- );
433
- return true;
434
- }
435
-
436
- if (matchedTarget.kind === "ambiguous") {
437
- res.statusCode = 401;
438
- res.end("ambiguous webhook target");
439
- console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
440
- return true;
441
- }
442
-
443
- const target = matchedTarget.target;
444
- target.statusSink?.({ lastInboundAt: Date.now() });
445
- if (reaction) {
446
- processReaction(reaction, target).catch((err) => {
447
- target.runtime.error?.(
448
- `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
449
- );
450
- });
451
- } else if (message) {
452
- // Route messages through debouncer to coalesce rapid-fire events
453
- // (e.g., text message + URL balloon arriving as separate webhooks)
454
- const debouncer = getOrCreateDebouncer(target);
455
- debouncer.enqueue({ message, target }).catch((err) => {
456
- target.runtime.error?.(
457
- `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
458
- );
459
- });
460
- }
461
-
462
- res.statusCode = 200;
463
- res.end("ok");
464
- if (reaction) {
465
- if (firstTarget) {
466
- logVerbose(
467
- firstTarget.core,
468
- firstTarget.runtime,
469
- `webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`,
470
- );
471
- }
472
- } else if (message) {
473
- if (firstTarget) {
474
- logVerbose(
475
- firstTarget.core,
476
- firstTarget.runtime,
477
- `webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
478
- );
479
- }
480
- }
481
- return true;
482
263
  }
483
264
 
484
265
  export async function monitorBlueBubblesProvider(