@openclaw/bluebubbles 2026.2.21 → 2026.2.22
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/package.json +1 -1
- package/src/actions.test.ts +4 -11
- package/src/attachments.test.ts +91 -4
- package/src/attachments.ts +47 -15
- package/src/chat.test.ts +193 -1
- package/src/chat.ts +74 -124
- package/src/history.ts +177 -0
- package/src/monitor-normalize.test.ts +78 -0
- package/src/monitor-normalize.ts +41 -12
- package/src/monitor-processing.ts +383 -127
- package/src/monitor.test.ts +396 -4
- package/src/probe.ts +8 -0
- package/src/reactions.test.ts +4 -11
- package/src/request-url.ts +12 -0
- package/src/runtime.ts +20 -0
- package/src/send.test.ts +53 -3
- package/src/send.ts +49 -7
- package/src/targets.test.ts +19 -0
- package/src/targets.ts +46 -37
- package/src/test-harness.ts +31 -2
package/src/send.ts
CHANGED
|
@@ -2,7 +2,11 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
3
|
import { stripMarkdown } from "openclaw/plugin-sdk";
|
|
4
4
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getCachedBlueBubblesPrivateApiStatus,
|
|
7
|
+
isBlueBubblesPrivateApiStatusEnabled,
|
|
8
|
+
} from "./probe.js";
|
|
9
|
+
import { warnBlueBubbles } from "./runtime.js";
|
|
6
10
|
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
|
7
11
|
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
|
|
8
12
|
import {
|
|
@@ -71,6 +75,38 @@ function resolveEffectId(raw?: string): string | undefined {
|
|
|
71
75
|
return raw;
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
type PrivateApiDecision = {
|
|
79
|
+
canUsePrivateApi: boolean;
|
|
80
|
+
throwEffectDisabledError: boolean;
|
|
81
|
+
warningMessage?: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function resolvePrivateApiDecision(params: {
|
|
85
|
+
privateApiStatus: boolean | null;
|
|
86
|
+
wantsReplyThread: boolean;
|
|
87
|
+
wantsEffect: boolean;
|
|
88
|
+
}): PrivateApiDecision {
|
|
89
|
+
const { privateApiStatus, wantsReplyThread, wantsEffect } = params;
|
|
90
|
+
const needsPrivateApi = wantsReplyThread || wantsEffect;
|
|
91
|
+
const canUsePrivateApi =
|
|
92
|
+
needsPrivateApi && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
|
|
93
|
+
const throwEffectDisabledError = wantsEffect && privateApiStatus === false;
|
|
94
|
+
if (!needsPrivateApi || privateApiStatus !== null) {
|
|
95
|
+
return { canUsePrivateApi, throwEffectDisabledError };
|
|
96
|
+
}
|
|
97
|
+
const requested = [
|
|
98
|
+
wantsReplyThread ? "reply threading" : null,
|
|
99
|
+
wantsEffect ? "message effects" : null,
|
|
100
|
+
]
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.join(" + ");
|
|
103
|
+
return {
|
|
104
|
+
canUsePrivateApi,
|
|
105
|
+
throwEffectDisabledError,
|
|
106
|
+
warningMessage: `Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
74
110
|
type BlueBubblesChatRecord = Record<string, unknown>;
|
|
75
111
|
|
|
76
112
|
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
|
|
@@ -372,30 +408,36 @@ export async function sendMessageBlueBubbles(
|
|
|
372
408
|
const effectId = resolveEffectId(opts.effectId);
|
|
373
409
|
const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim());
|
|
374
410
|
const wantsEffect = Boolean(effectId);
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
411
|
+
const privateApiDecision = resolvePrivateApiDecision({
|
|
412
|
+
privateApiStatus,
|
|
413
|
+
wantsReplyThread,
|
|
414
|
+
wantsEffect,
|
|
415
|
+
});
|
|
416
|
+
if (privateApiDecision.throwEffectDisabledError) {
|
|
378
417
|
throw new Error(
|
|
379
418
|
"BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.",
|
|
380
419
|
);
|
|
381
420
|
}
|
|
421
|
+
if (privateApiDecision.warningMessage) {
|
|
422
|
+
warnBlueBubbles(privateApiDecision.warningMessage);
|
|
423
|
+
}
|
|
382
424
|
const payload: Record<string, unknown> = {
|
|
383
425
|
chatGuid,
|
|
384
426
|
tempGuid: crypto.randomUUID(),
|
|
385
427
|
message: strippedText,
|
|
386
428
|
};
|
|
387
|
-
if (canUsePrivateApi) {
|
|
429
|
+
if (privateApiDecision.canUsePrivateApi) {
|
|
388
430
|
payload.method = "private-api";
|
|
389
431
|
}
|
|
390
432
|
|
|
391
433
|
// Add reply threading support
|
|
392
|
-
if (wantsReplyThread && canUsePrivateApi) {
|
|
434
|
+
if (wantsReplyThread && privateApiDecision.canUsePrivateApi) {
|
|
393
435
|
payload.selectedMessageGuid = opts.replyToMessageGuid;
|
|
394
436
|
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
|
|
395
437
|
}
|
|
396
438
|
|
|
397
439
|
// Add message effects support
|
|
398
|
-
if (effectId) {
|
|
440
|
+
if (effectId && privateApiDecision.canUsePrivateApi) {
|
|
399
441
|
payload.effectId = effectId;
|
|
400
442
|
}
|
|
401
443
|
|
package/src/targets.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
isAllowedBlueBubblesSender,
|
|
3
4
|
looksLikeBlueBubblesTargetId,
|
|
4
5
|
normalizeBlueBubblesMessagingTarget,
|
|
5
6
|
parseBlueBubblesTarget,
|
|
@@ -181,3 +182,21 @@ describe("parseBlueBubblesAllowTarget", () => {
|
|
|
181
182
|
});
|
|
182
183
|
});
|
|
183
184
|
});
|
|
185
|
+
|
|
186
|
+
describe("isAllowedBlueBubblesSender", () => {
|
|
187
|
+
it("denies when allowFrom is empty", () => {
|
|
188
|
+
const allowed = isAllowedBlueBubblesSender({
|
|
189
|
+
allowFrom: [],
|
|
190
|
+
sender: "+15551234567",
|
|
191
|
+
});
|
|
192
|
+
expect(allowed).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("allows wildcard entries", () => {
|
|
196
|
+
const allowed = isAllowedBlueBubblesSender({
|
|
197
|
+
allowFrom: ["*"],
|
|
198
|
+
sender: "+15551234567",
|
|
199
|
+
});
|
|
200
|
+
expect(allowed).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
});
|
package/src/targets.ts
CHANGED
|
@@ -78,6 +78,40 @@ function looksLikeRawChatIdentifier(value: string): boolean {
|
|
|
78
78
|
return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
function parseGroupTarget(params: {
|
|
82
|
+
trimmed: string;
|
|
83
|
+
lower: string;
|
|
84
|
+
requireValue: boolean;
|
|
85
|
+
}): { kind: "chat_id"; chatId: number } | { kind: "chat_guid"; chatGuid: string } | null {
|
|
86
|
+
if (!params.lower.startsWith("group:")) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const value = stripPrefix(params.trimmed, "group:");
|
|
90
|
+
const chatId = Number.parseInt(value, 10);
|
|
91
|
+
if (Number.isFinite(chatId)) {
|
|
92
|
+
return { kind: "chat_id", chatId };
|
|
93
|
+
}
|
|
94
|
+
if (value) {
|
|
95
|
+
return { kind: "chat_guid", chatGuid: value };
|
|
96
|
+
}
|
|
97
|
+
if (params.requireValue) {
|
|
98
|
+
throw new Error("group target is required");
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseRawChatIdentifierTarget(
|
|
104
|
+
trimmed: string,
|
|
105
|
+
): { kind: "chat_identifier"; chatIdentifier: string } | null {
|
|
106
|
+
if (/^chat\d+$/i.test(trimmed)) {
|
|
107
|
+
return { kind: "chat_identifier", chatIdentifier: trimmed };
|
|
108
|
+
}
|
|
109
|
+
if (looksLikeRawChatIdentifier(trimmed)) {
|
|
110
|
+
return { kind: "chat_identifier", chatIdentifier: trimmed };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
81
115
|
export function normalizeBlueBubblesHandle(raw: string): string {
|
|
82
116
|
const trimmed = raw.trim();
|
|
83
117
|
if (!trimmed) {
|
|
@@ -239,16 +273,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
|
|
239
273
|
return chatTarget;
|
|
240
274
|
}
|
|
241
275
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (Number.isFinite(chatId)) {
|
|
246
|
-
return { kind: "chat_id", chatId };
|
|
247
|
-
}
|
|
248
|
-
if (!value) {
|
|
249
|
-
throw new Error("group target is required");
|
|
250
|
-
}
|
|
251
|
-
return { kind: "chat_guid", chatGuid: value };
|
|
276
|
+
const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: true });
|
|
277
|
+
if (groupTarget) {
|
|
278
|
+
return groupTarget;
|
|
252
279
|
}
|
|
253
280
|
|
|
254
281
|
const rawChatGuid = parseRawChatGuid(trimmed);
|
|
@@ -256,15 +283,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
|
|
256
283
|
return { kind: "chat_guid", chatGuid: rawChatGuid };
|
|
257
284
|
}
|
|
258
285
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
return { kind: "chat_identifier", chatIdentifier: trimmed };
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc")
|
|
266
|
-
if (looksLikeRawChatIdentifier(trimmed)) {
|
|
267
|
-
return { kind: "chat_identifier", chatIdentifier: trimmed };
|
|
286
|
+
const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed);
|
|
287
|
+
if (rawChatIdentifierTarget) {
|
|
288
|
+
return rawChatIdentifierTarget;
|
|
268
289
|
}
|
|
269
290
|
|
|
270
291
|
return { kind: "handle", to: trimmed, service: "auto" };
|
|
@@ -298,26 +319,14 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget
|
|
|
298
319
|
return chatTarget;
|
|
299
320
|
}
|
|
300
321
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
if (Number.isFinite(chatId)) {
|
|
305
|
-
return { kind: "chat_id", chatId };
|
|
306
|
-
}
|
|
307
|
-
if (value) {
|
|
308
|
-
return { kind: "chat_guid", chatGuid: value };
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
|
|
313
|
-
// These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs
|
|
314
|
-
if (/^chat\d+$/i.test(trimmed)) {
|
|
315
|
-
return { kind: "chat_identifier", chatIdentifier: trimmed };
|
|
322
|
+
const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: false });
|
|
323
|
+
if (groupTarget) {
|
|
324
|
+
return groupTarget;
|
|
316
325
|
}
|
|
317
326
|
|
|
318
|
-
|
|
319
|
-
if (
|
|
320
|
-
return
|
|
327
|
+
const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed);
|
|
328
|
+
if (rawChatIdentifierTarget) {
|
|
329
|
+
return rawChatIdentifierTarget;
|
|
321
330
|
}
|
|
322
331
|
|
|
323
332
|
return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };
|
package/src/test-harness.ts
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
import type { Mock } from "vitest";
|
|
2
2
|
import { afterEach, beforeEach, vi } from "vitest";
|
|
3
3
|
|
|
4
|
+
export const BLUE_BUBBLES_PRIVATE_API_STATUS = {
|
|
5
|
+
enabled: true,
|
|
6
|
+
disabled: false,
|
|
7
|
+
unknown: null,
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
type BlueBubblesPrivateApiStatusMock = {
|
|
11
|
+
mockReturnValue: (value: boolean | null) => unknown;
|
|
12
|
+
mockReturnValueOnce: (value: boolean | null) => unknown;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function mockBlueBubblesPrivateApiStatus(
|
|
16
|
+
mock: Pick<BlueBubblesPrivateApiStatusMock, "mockReturnValue">,
|
|
17
|
+
value: boolean | null,
|
|
18
|
+
) {
|
|
19
|
+
mock.mockReturnValue(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function mockBlueBubblesPrivateApiStatusOnce(
|
|
23
|
+
mock: Pick<BlueBubblesPrivateApiStatusMock, "mockReturnValueOnce">,
|
|
24
|
+
value: boolean | null,
|
|
25
|
+
) {
|
|
26
|
+
mock.mockReturnValueOnce(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
4
29
|
export function resolveBlueBubblesAccountFromConfig(params: {
|
|
5
30
|
cfg?: { channels?: { bluebubbles?: Record<string, unknown> } };
|
|
6
31
|
accountId?: string;
|
|
@@ -22,11 +47,15 @@ export function createBlueBubblesAccountsMockModule() {
|
|
|
22
47
|
|
|
23
48
|
type BlueBubblesProbeMockModule = {
|
|
24
49
|
getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>;
|
|
50
|
+
isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>;
|
|
25
51
|
};
|
|
26
52
|
|
|
27
53
|
export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule {
|
|
28
54
|
return {
|
|
29
|
-
getCachedBlueBubblesPrivateApiStatus: vi
|
|
55
|
+
getCachedBlueBubblesPrivateApiStatus: vi
|
|
56
|
+
.fn()
|
|
57
|
+
.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown),
|
|
58
|
+
isBlueBubblesPrivateApiStatusEnabled: vi.fn((status: boolean | null) => status === true),
|
|
30
59
|
};
|
|
31
60
|
}
|
|
32
61
|
|
|
@@ -41,7 +70,7 @@ export function installBlueBubblesFetchTestHooks(params: {
|
|
|
41
70
|
vi.stubGlobal("fetch", params.mockFetch);
|
|
42
71
|
params.mockFetch.mockReset();
|
|
43
72
|
params.privateApiStatusMock.mockReset();
|
|
44
|
-
params.privateApiStatusMock.mockReturnValue(
|
|
73
|
+
params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown);
|
|
45
74
|
});
|
|
46
75
|
|
|
47
76
|
afterEach(() => {
|