@openclaw/bluebubbles 2026.3.2 → 2026.3.8-beta.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.
@@ -1,12 +1,14 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
2
2
  import {
3
3
  DM_GROUP_ACCESS_REASON,
4
4
  createScopedPairingAccess,
5
5
  createReplyPrefixOptions,
6
6
  evictOldHistoryKeys,
7
+ issuePairingChallenge,
7
8
  logAckFailure,
8
9
  logInboundDrop,
9
10
  logTypingFailure,
11
+ mapAllowFromEntries,
10
12
  readStoreAllowFromForDmPolicy,
11
13
  recordPendingHistoryEntryIfEnabled,
12
14
  resolveAckReaction,
@@ -14,7 +16,7 @@ import {
14
16
  resolveControlCommandGate,
15
17
  stripMarkdown,
16
18
  type HistoryEntry,
17
- } from "openclaw/plugin-sdk";
19
+ } from "openclaw/plugin-sdk/bluebubbles";
18
20
  import { downloadBlueBubblesAttachment } from "./attachments.js";
19
21
  import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
20
22
  import { fetchBlueBubblesHistory } from "./history.js";
@@ -509,7 +511,7 @@ export async function processMessage(
509
511
 
510
512
  const dmPolicy = account.config.dmPolicy ?? "pairing";
511
513
  const groupPolicy = account.config.groupPolicy ?? "allowlist";
512
- const configuredAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
514
+ const configuredAllowFrom = mapAllowFromEntries(account.config.allowFrom);
513
515
  const storeAllowFrom = await readStoreAllowFromForDmPolicy({
514
516
  provider: "bluebubbles",
515
517
  accountId: account.accountId,
@@ -595,25 +597,24 @@ export async function processMessage(
595
597
  }
596
598
 
597
599
  if (accessDecision.decision === "pairing") {
598
- const { code, created } = await pairing.upsertPairingRequest({
599
- id: message.senderId,
600
+ await issuePairingChallenge({
601
+ channel: "bluebubbles",
602
+ senderId: message.senderId,
603
+ senderIdLine: `Your BlueBubbles sender id: ${message.senderId}`,
600
604
  meta: { name: message.senderName },
601
- });
602
- runtime.log?.(`[bluebubbles] pairing request sender=${message.senderId} created=${created}`);
603
- if (created) {
604
- logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
605
- try {
606
- await sendMessageBlueBubbles(
607
- message.senderId,
608
- core.channel.pairing.buildPairingReply({
609
- channel: "bluebubbles",
610
- idLine: `Your BlueBubbles sender id: ${message.senderId}`,
611
- code,
612
- }),
613
- { cfg: config, accountId: account.accountId },
614
- );
605
+ upsertPairingRequest: pairing.upsertPairingRequest,
606
+ onCreated: () => {
607
+ runtime.log?.(`[bluebubbles] pairing request sender=${message.senderId} created=true`);
608
+ logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
609
+ },
610
+ sendPairingReply: async (text) => {
611
+ await sendMessageBlueBubbles(message.senderId, text, {
612
+ cfg: config,
613
+ accountId: account.accountId,
614
+ });
615
615
  statusSink?.({ lastOutboundAt: Date.now() });
616
- } catch (err) {
616
+ },
617
+ onReplyError: (err) => {
617
618
  logVerbose(
618
619
  core,
619
620
  runtime,
@@ -622,8 +623,8 @@ export async function processMessage(
622
623
  runtime.error?.(
623
624
  `[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`,
624
625
  );
625
- }
626
- }
626
+ },
627
+ });
627
628
  return;
628
629
  }
629
630
 
@@ -1,4 +1,4 @@
1
- import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
2
2
  import type { ResolvedBlueBubblesAccount } from "./accounts.js";
3
3
  import { getBlueBubblesRuntime } from "./runtime.js";
4
4
  import type { BlueBubblesAccountConfig } from "./types.js";
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
- import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
6
6
  import type { ResolvedBlueBubblesAccount } from "./accounts.js";
@@ -2391,11 +2391,11 @@ describe("BlueBubbles webhook monitor", () => {
2391
2391
  });
2392
2392
 
2393
2393
  const accountA: ResolvedBlueBubblesAccount = {
2394
- ...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }),
2394
+ ...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }), // pragma: allowlist secret
2395
2395
  accountId: "acc-a",
2396
2396
  };
2397
2397
  const accountB: ResolvedBlueBubblesAccount = {
2398
- ...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }),
2398
+ ...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }), // pragma: allowlist secret
2399
2399
  accountId: "acc-b",
2400
2400
  };
2401
2401
  const config: OpenClawConfig = {};
package/src/monitor.ts CHANGED
@@ -1,13 +1,12 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
3
  import {
4
- beginWebhookRequestPipelineOrReject,
5
4
  createWebhookInFlightLimiter,
6
5
  registerWebhookTargetWithPluginRoute,
7
6
  readWebhookBodyOrReject,
8
7
  resolveWebhookTargetWithAuthOrRejectSync,
9
- resolveWebhookTargets,
10
- } from "openclaw/plugin-sdk";
8
+ withResolvedWebhookRequestPipeline,
9
+ } from "openclaw/plugin-sdk/bluebubbles";
11
10
  import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
12
11
  import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
13
12
  import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
@@ -122,156 +121,145 @@ export async function handleBlueBubblesWebhookRequest(
122
121
  req: IncomingMessage,
123
122
  res: ServerResponse,
124
123
  ): Promise<boolean> {
125
- const resolved = resolveWebhookTargets(req, webhookTargets);
126
- if (!resolved) {
127
- return false;
128
- }
129
- const { path, targets } = resolved;
130
- const url = new URL(req.url ?? "/", "http://localhost");
131
- const requestLifecycle = beginWebhookRequestPipelineOrReject({
124
+ return await withResolvedWebhookRequestPipeline({
132
125
  req,
133
126
  res,
127
+ targetsByPath: webhookTargets,
134
128
  allowMethods: ["POST"],
135
129
  inFlightLimiter: webhookInFlightLimiter,
136
- inFlightKey: `${path}:${req.socket.remoteAddress ?? "unknown"}`,
137
- });
138
- if (!requestLifecycle.ok) {
139
- return true;
140
- }
141
-
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
- },
157
- });
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") ?? "")}`,
161
- );
162
- return true;
163
- }
164
- const body = await readWebhookBodyOrReject({
165
- req,
166
- res,
167
- profile: "post-auth",
168
- invalidBodyMessage: "invalid payload",
169
- });
170
- if (!body.ok) {
171
- console.warn(`[bluebubbles] webhook rejected: status=${res.statusCode}`);
172
- return true;
173
- }
174
-
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;
181
- }
182
-
183
- const payload = asRecord(parsed.value) ?? {};
184
- const firstTarget = targets[0];
185
- if (firstTarget) {
186
- logVerbose(
187
- firstTarget.core,
188
- firstTarget.runtime,
189
- `webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
190
- );
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
- );
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
+ },
240
146
  });
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)}`,
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") ?? "")}`,
248
150
  );
151
+ return true;
152
+ }
153
+ const body = await readWebhookBodyOrReject({
154
+ req,
155
+ res,
156
+ profile: "post-auth",
157
+ invalidBodyMessage: "invalid payload",
249
158
  });
250
- }
159
+ if (!body.ok) {
160
+ console.warn(`[bluebubbles] webhook rejected: status=${res.statusCode}`);
161
+ return true;
162
+ }
251
163
 
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
- );
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;
261
170
  }
262
- } else if (message) {
171
+
172
+ const payload = asRecord(parsed.value) ?? {};
173
+ const firstTarget = targets[0];
263
174
  if (firstTarget) {
264
175
  logVerbose(
265
176
  firstTarget.core,
266
177
  firstTarget.runtime,
267
- `webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
178
+ `webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
268
179
  );
269
180
  }
270
- }
271
- return true;
272
- } finally {
273
- requestLifecycle.release();
274
- }
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
+ }
222
+
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
+ }
240
+
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
+ },
262
+ });
275
263
  }
276
264
 
277
265
  export async function monitorBlueBubblesProvider(