@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.
- package/index.ts +2 -2
- package/package.json +4 -1
- package/src/account-resolve.ts +1 -1
- package/src/accounts.ts +7 -36
- package/src/actions.test.ts +1 -1
- package/src/actions.ts +1 -1
- package/src/attachments.test.ts +1 -1
- package/src/attachments.ts +1 -1
- package/src/channel.ts +50 -70
- package/src/chat.ts +46 -39
- package/src/config-apply.ts +77 -0
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +10 -8
- 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 +1 -1
- package/src/monitor-normalize.ts +2 -11
- package/src/monitor-processing.ts +23 -22
- package/src/monitor-shared.ts +1 -1
- package/src/monitor.test.ts +3 -3
- package/src/monitor.ts +126 -138
- package/src/monitor.webhook-auth.test.ts +77 -172
- package/src/monitor.webhook-route.test.ts +1 -1
- package/src/onboarding.secret-input.test.ts +10 -2
- package/src/onboarding.ts +29 -67
- package/src/probe.ts +1 -1
- package/src/reactions.ts +1 -1
- package/src/request-url.ts +1 -12
- package/src/runtime.ts +8 -13
- package/src/secret-input.ts +8 -14
- package/src/send.test.ts +1 -1
- package/src/send.ts +17 -22
- package/src/targets.ts +1 -1
- package/src/types.ts +2 -2
|
@@ -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
|
|
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
|
-
|
|
599
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
}
|
|
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
|
|
package/src/monitor-shared.ts
CHANGED
|
@@ -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";
|
package/src/monitor.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
|
178
|
+
`webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
|
|
268
179
|
);
|
|
269
180
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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(
|