@openclaw/bluebubbles 2026.2.25 → 2026.3.2

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,15 @@
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,
4
+ beginWebhookRequestPipelineOrReject,
5
+ createWebhookInFlightLimiter,
6
+ registerWebhookTargetWithPluginRoute,
7
+ readWebhookBodyOrReject,
8
+ resolveWebhookTargetWithAuthOrRejectSync,
11
9
  resolveWebhookTargets,
12
10
  } from "openclaw/plugin-sdk";
13
- import {
14
- normalizeWebhookMessage,
15
- normalizeWebhookReaction,
16
- type NormalizedWebhookMessage,
17
- } from "./monitor-normalize.js";
11
+ import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
12
+ import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
18
13
  import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
19
14
  import {
20
15
  _resetBlueBubblesShortIdState,
@@ -24,229 +19,44 @@ import {
24
19
  DEFAULT_WEBHOOK_PATH,
25
20
  normalizeWebhookPath,
26
21
  resolveWebhookPathFromConfig,
27
- type BlueBubblesCoreRuntime,
28
22
  type BlueBubblesMonitorOptions,
29
23
  type WebhookTarget,
30
24
  } from "./monitor-shared.js";
31
25
  import { fetchBlueBubblesServerInfo } from "./probe.js";
32
26
  import { getBlueBubblesRuntime } from "./runtime.js";
33
27
 
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
28
  const webhookTargets = new Map<string, WebhookTarget[]>();
29
+ const webhookInFlightLimiter = createWebhookInFlightLimiter();
30
+ const debounceRegistry = createBlueBubblesDebounceRegistry({ processMessage });
117
31
 
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)}`);
32
+ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
33
+ const registered = registerWebhookTargetWithPluginRoute({
34
+ targetsByPath: webhookTargets,
35
+ target,
36
+ route: {
37
+ auth: "plugin",
38
+ match: "exact",
39
+ pluginId: "bluebubbles",
40
+ source: "bluebubbles-webhook",
41
+ accountId: target.account.accountId,
42
+ log: target.runtime.log,
43
+ handler: async (req, res) => {
44
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
45
+ if (!handled && !res.headersSent) {
46
+ res.statusCode = 404;
47
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
48
+ res.end("Not Found");
49
+ }
50
+ },
223
51
  },
224
52
  });
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
53
  return () => {
240
54
  registered.unregister();
241
55
  // Clean up debouncer when target is unregistered
242
- removeDebouncer(registered.target);
56
+ debounceRegistry.removeDebouncer(registered.target);
243
57
  };
244
58
  }
245
59
 
246
- type ReadBlueBubblesWebhookBodyResult =
247
- | { ok: true; value: unknown }
248
- | { ok: false; statusCode: number; error: string };
249
-
250
60
  function parseBlueBubblesWebhookPayload(
251
61
  rawBody: string,
252
62
  ): { ok: true; value: unknown } | { ok: false; error: string } {
@@ -270,36 +80,6 @@ function parseBlueBubblesWebhookPayload(
270
80
  }
271
81
  }
272
82
 
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
83
  function asRecord(value: unknown): Record<string, unknown> | null {
304
84
  return value && typeof value === "object" && !Array.isArray(value)
305
85
  ? (value as Record<string, unknown>)
@@ -348,137 +128,150 @@ export async function handleBlueBubblesWebhookRequest(
348
128
  }
349
129
  const { path, targets } = resolved;
350
130
  const url = new URL(req.url ?? "/", "http://localhost");
351
-
352
- if (rejectNonPostWebhookRequest(req, res)) {
353
- return true;
354
- }
355
-
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
- }
363
-
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
- }
414
-
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);
131
+ const requestLifecycle = beginWebhookRequestPipelineOrReject({
132
+ req,
133
+ res,
134
+ allowMethods: ["POST"],
135
+ inFlightLimiter: webhookInFlightLimiter,
136
+ inFlightKey: `${path}:${req.socket.remoteAddress ?? "unknown"}`,
425
137
  });
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}`);
138
+ if (!requestLifecycle.ok) {
440
139
  return true;
441
140
  }
442
141
 
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
- );
142
+ try {
143
+ const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
144
+ const headerToken =
145
+ req.headers["x-guid"] ??
146
+ req.headers["x-password"] ??
147
+ req.headers["x-bluebubbles-guid"] ??
148
+ req.headers["authorization"];
149
+ const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
150
+ const target = resolveWebhookTargetWithAuthOrRejectSync({
151
+ targets,
152
+ res,
153
+ isMatch: (target) => {
154
+ const token = target.account.config.password?.trim() ?? "";
155
+ return safeEqualSecret(guid, token);
156
+ },
450
157
  });
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)}`,
158
+ if (!target) {
159
+ console.warn(
160
+ `[bluebubbles] webhook rejected: status=${res.statusCode} path=${path} guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
458
161
  );
162
+ return true;
163
+ }
164
+ const body = await readWebhookBodyOrReject({
165
+ req,
166
+ res,
167
+ profile: "post-auth",
168
+ invalidBodyMessage: "invalid payload",
459
169
  });
460
- }
170
+ if (!body.ok) {
171
+ console.warn(`[bluebubbles] webhook rejected: status=${res.statusCode}`);
172
+ return true;
173
+ }
461
174
 
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
- );
175
+ const parsed = parseBlueBubblesWebhookPayload(body.value);
176
+ if (!parsed.ok) {
177
+ res.statusCode = 400;
178
+ res.end(parsed.error);
179
+ console.warn(`[bluebubbles] webhook rejected: ${parsed.error}`);
180
+ return true;
471
181
  }
472
- } else if (message) {
182
+
183
+ const payload = asRecord(parsed.value) ?? {};
184
+ const firstTarget = targets[0];
473
185
  if (firstTarget) {
474
186
  logVerbose(
475
187
  firstTarget.core,
476
188
  firstTarget.runtime,
477
- `webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
189
+ `webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
478
190
  );
479
191
  }
192
+ const eventTypeRaw = payload.type;
193
+ const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : "";
194
+ const allowedEventTypes = new Set([
195
+ "new-message",
196
+ "updated-message",
197
+ "message-reaction",
198
+ "reaction",
199
+ ]);
200
+ if (eventType && !allowedEventTypes.has(eventType)) {
201
+ res.statusCode = 200;
202
+ res.end("ok");
203
+ if (firstTarget) {
204
+ logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`);
205
+ }
206
+ return true;
207
+ }
208
+ const reaction = normalizeWebhookReaction(payload);
209
+ if (
210
+ (eventType === "updated-message" ||
211
+ eventType === "message-reaction" ||
212
+ eventType === "reaction") &&
213
+ !reaction
214
+ ) {
215
+ res.statusCode = 200;
216
+ res.end("ok");
217
+ if (firstTarget) {
218
+ logVerbose(
219
+ firstTarget.core,
220
+ firstTarget.runtime,
221
+ `webhook ignored ${eventType || "event"} without reaction`,
222
+ );
223
+ }
224
+ return true;
225
+ }
226
+ const message = reaction ? null : normalizeWebhookMessage(payload);
227
+ if (!message && !reaction) {
228
+ res.statusCode = 400;
229
+ res.end("invalid payload");
230
+ console.warn("[bluebubbles] webhook rejected: unable to parse message payload");
231
+ return true;
232
+ }
233
+
234
+ target.statusSink?.({ lastInboundAt: Date.now() });
235
+ if (reaction) {
236
+ processReaction(reaction, target).catch((err) => {
237
+ target.runtime.error?.(
238
+ `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
239
+ );
240
+ });
241
+ } else if (message) {
242
+ // Route messages through debouncer to coalesce rapid-fire events
243
+ // (e.g., text message + URL balloon arriving as separate webhooks)
244
+ const debouncer = debounceRegistry.getOrCreateDebouncer(target);
245
+ debouncer.enqueue({ message, target }).catch((err) => {
246
+ target.runtime.error?.(
247
+ `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
248
+ );
249
+ });
250
+ }
251
+
252
+ res.statusCode = 200;
253
+ res.end("ok");
254
+ if (reaction) {
255
+ if (firstTarget) {
256
+ logVerbose(
257
+ firstTarget.core,
258
+ firstTarget.runtime,
259
+ `webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`,
260
+ );
261
+ }
262
+ } else if (message) {
263
+ if (firstTarget) {
264
+ logVerbose(
265
+ firstTarget.core,
266
+ firstTarget.runtime,
267
+ `webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
268
+ );
269
+ }
270
+ }
271
+ return true;
272
+ } finally {
273
+ requestLifecycle.release();
480
274
  }
481
- return true;
482
275
  }
483
276
 
484
277
  export async function monitorBlueBubblesProvider(