@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/README.md +1 -1
- package/index.ts +2 -4
- package/package.json +4 -1
- package/src/account-resolve.ts +20 -3
- package/src/accounts.test.ts +25 -0
- package/src/accounts.ts +11 -39
- package/src/actions.test.ts +1 -1
- package/src/actions.ts +5 -20
- package/src/attachments.test.ts +1 -1
- package/src/attachments.ts +1 -1
- package/src/channel.ts +53 -80
- package/src/chat.ts +46 -39
- package/src/config-apply.ts +77 -0
- package/src/config-schema.test.ts +13 -1
- package/src/config-schema.ts +5 -4
- package/src/history.ts +1 -1
- package/src/media-send.test.ts +1 -1
- package/src/media-send.ts +1 -1
- package/src/monitor-debounce.ts +205 -0
- package/src/monitor-normalize.ts +2 -11
- package/src/monitor-processing.ts +26 -24
- package/src/monitor-shared.ts +1 -1
- package/src/monitor.test.ts +45 -738
- package/src/monitor.ts +164 -383
- package/src/monitor.webhook-auth.test.ts +767 -0
- package/src/monitor.webhook-route.test.ts +44 -0
- package/src/onboarding.secret-input.test.ts +89 -0
- package/src/onboarding.ts +37 -69
- package/src/probe.ts +6 -5
- package/src/reactions.ts +1 -1
- package/src/request-url.ts +1 -12
- package/src/runtime.ts +1 -1
- package/src/secret-input.ts +13 -0
- package/src/send-helpers.ts +34 -22
- package/src/send.test.ts +25 -1
- package/src/send.ts +24 -24
- package/src/targets.ts +3 -6
- package/src/types.ts +2 -2
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "
|
|
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
|
-
|
|
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)}`);
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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(
|