@openclaw/bluebubbles 2026.3.1 → 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/README.md +1 -1
- package/index.ts +0 -2
- package/package.json +1 -1
- package/src/account-resolve.ts +19 -2
- package/src/accounts.test.ts +25 -0
- package/src/accounts.ts +4 -3
- package/src/actions.ts +4 -19
- package/src/channel.ts +3 -10
- package/src/config-schema.test.ts +12 -0
- package/src/config-schema.ts +4 -3
- package/src/monitor-debounce.ts +205 -0
- package/src/monitor-processing.ts +3 -2
- package/src/monitor.test.ts +42 -735
- package/src/monitor.ts +157 -364
- package/src/monitor.webhook-auth.test.ts +862 -0
- package/src/monitor.webhook-route.test.ts +44 -0
- package/src/onboarding.secret-input.test.ts +81 -0
- package/src/onboarding.ts +8 -2
- package/src/probe.ts +5 -4
- package/src/secret-input.ts +19 -0
- package/src/send-helpers.ts +34 -22
- package/src/send.test.ts +24 -0
- package/src/send.ts +7 -2
- package/src/targets.ts +2 -5
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
resolveSingleWebhookTarget,
|
|
4
|
+
beginWebhookRequestPipelineOrReject,
|
|
5
|
+
createWebhookInFlightLimiter,
|
|
6
|
+
registerWebhookTargetWithPluginRoute,
|
|
7
|
+
readWebhookBodyOrReject,
|
|
8
|
+
resolveWebhookTargetWithAuthOrRejectSync,
|
|
11
9
|
resolveWebhookTargets,
|
|
12
10
|
} from "openclaw/plugin-sdk";
|
|
13
|
-
import {
|
|
14
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
|
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(
|