@openclaw/bluebubbles 2026.2.13 → 2026.2.14
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/accounts.ts +1 -1
- package/src/actions.test.ts +52 -0
- package/src/actions.ts +34 -1
- package/src/attachments.test.ts +32 -0
- package/src/attachments.ts +11 -53
- package/src/chat.test.ts +40 -0
- package/src/chat.ts +32 -10
- package/src/config-schema.ts +1 -0
- package/src/media-send.test.ts +256 -0
- package/src/media-send.ts +150 -7
- package/src/monitor-normalize.ts +107 -153
- package/src/monitor-processing.ts +36 -8
- package/src/monitor.test.ts +328 -3
- package/src/monitor.ts +124 -32
- package/src/probe.ts +12 -0
- package/src/reactions.ts +8 -2
- package/src/send-helpers.ts +53 -0
- package/src/send.test.ts +47 -0
- package/src/send.ts +18 -62
- package/src/targets.ts +50 -84
- package/src/types.ts +7 -1
package/src/send.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import type { BlueBubblesSendTarget } from "./types.js";
|
|
3
|
+
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
3
4
|
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
|
|
4
5
|
|
|
5
6
|
vi.mock("./accounts.js", () => ({
|
|
@@ -14,12 +15,18 @@ vi.mock("./accounts.js", () => ({
|
|
|
14
15
|
}),
|
|
15
16
|
}));
|
|
16
17
|
|
|
18
|
+
vi.mock("./probe.js", () => ({
|
|
19
|
+
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
|
20
|
+
}));
|
|
21
|
+
|
|
17
22
|
const mockFetch = vi.fn();
|
|
18
23
|
|
|
19
24
|
describe("send", () => {
|
|
20
25
|
beforeEach(() => {
|
|
21
26
|
vi.stubGlobal("fetch", mockFetch);
|
|
22
27
|
mockFetch.mockReset();
|
|
28
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
|
29
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
|
23
30
|
});
|
|
24
31
|
|
|
25
32
|
afterEach(() => {
|
|
@@ -611,6 +618,46 @@ describe("send", () => {
|
|
|
611
618
|
expect(body.partIndex).toBe(1);
|
|
612
619
|
});
|
|
613
620
|
|
|
621
|
+
it("downgrades threaded reply to plain send when private API is disabled", async () => {
|
|
622
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
|
623
|
+
mockFetch
|
|
624
|
+
.mockResolvedValueOnce({
|
|
625
|
+
ok: true,
|
|
626
|
+
json: () =>
|
|
627
|
+
Promise.resolve({
|
|
628
|
+
data: [
|
|
629
|
+
{
|
|
630
|
+
guid: "iMessage;-;+15551234567",
|
|
631
|
+
participants: [{ address: "+15551234567" }],
|
|
632
|
+
},
|
|
633
|
+
],
|
|
634
|
+
}),
|
|
635
|
+
})
|
|
636
|
+
.mockResolvedValueOnce({
|
|
637
|
+
ok: true,
|
|
638
|
+
text: () =>
|
|
639
|
+
Promise.resolve(
|
|
640
|
+
JSON.stringify({
|
|
641
|
+
data: { guid: "msg-uuid-plain" },
|
|
642
|
+
}),
|
|
643
|
+
),
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
|
|
647
|
+
serverUrl: "http://localhost:1234",
|
|
648
|
+
password: "test",
|
|
649
|
+
replyToMessageGuid: "reply-guid-123",
|
|
650
|
+
replyToPartIndex: 1,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
expect(result.messageId).toBe("msg-uuid-plain");
|
|
654
|
+
const sendCall = mockFetch.mock.calls[1];
|
|
655
|
+
const body = JSON.parse(sendCall[1].body);
|
|
656
|
+
expect(body.method).toBeUndefined();
|
|
657
|
+
expect(body.selectedMessageGuid).toBeUndefined();
|
|
658
|
+
expect(body.partIndex).toBeUndefined();
|
|
659
|
+
});
|
|
660
|
+
|
|
614
661
|
it("normalizes effect names and uses private-api for effects", async () => {
|
|
615
662
|
mockFetch
|
|
616
663
|
.mockResolvedValueOnce({
|
package/src/send.ts
CHANGED
|
@@ -2,11 +2,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import { stripMarkdown } from "openclaw/plugin-sdk";
|
|
4
4
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
parseBlueBubblesTarget,
|
|
9
|
-
} from "./targets.js";
|
|
5
|
+
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
6
|
+
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
|
7
|
+
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
|
|
10
8
|
import {
|
|
11
9
|
blueBubblesFetchWithTimeout,
|
|
12
10
|
buildBlueBubblesApiUrl,
|
|
@@ -73,57 +71,6 @@ function resolveEffectId(raw?: string): string | undefined {
|
|
|
73
71
|
return raw;
|
|
74
72
|
}
|
|
75
73
|
|
|
76
|
-
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
|
77
|
-
const parsed = parseBlueBubblesTarget(raw);
|
|
78
|
-
if (parsed.kind === "handle") {
|
|
79
|
-
return {
|
|
80
|
-
kind: "handle",
|
|
81
|
-
address: normalizeBlueBubblesHandle(parsed.to),
|
|
82
|
-
service: parsed.service,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
if (parsed.kind === "chat_id") {
|
|
86
|
-
return { kind: "chat_id", chatId: parsed.chatId };
|
|
87
|
-
}
|
|
88
|
-
if (parsed.kind === "chat_guid") {
|
|
89
|
-
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
|
|
90
|
-
}
|
|
91
|
-
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function extractMessageId(payload: unknown): string {
|
|
95
|
-
if (!payload || typeof payload !== "object") {
|
|
96
|
-
return "unknown";
|
|
97
|
-
}
|
|
98
|
-
const record = payload as Record<string, unknown>;
|
|
99
|
-
const data =
|
|
100
|
-
record.data && typeof record.data === "object"
|
|
101
|
-
? (record.data as Record<string, unknown>)
|
|
102
|
-
: null;
|
|
103
|
-
const candidates = [
|
|
104
|
-
record.messageId,
|
|
105
|
-
record.messageGuid,
|
|
106
|
-
record.message_guid,
|
|
107
|
-
record.guid,
|
|
108
|
-
record.id,
|
|
109
|
-
data?.messageId,
|
|
110
|
-
data?.messageGuid,
|
|
111
|
-
data?.message_guid,
|
|
112
|
-
data?.message_id,
|
|
113
|
-
data?.guid,
|
|
114
|
-
data?.id,
|
|
115
|
-
];
|
|
116
|
-
for (const candidate of candidates) {
|
|
117
|
-
if (typeof candidate === "string" && candidate.trim()) {
|
|
118
|
-
return candidate.trim();
|
|
119
|
-
}
|
|
120
|
-
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
|
121
|
-
return String(candidate);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return "unknown";
|
|
125
|
-
}
|
|
126
|
-
|
|
127
74
|
type BlueBubblesChatRecord = Record<string, unknown>;
|
|
128
75
|
|
|
129
76
|
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
|
|
@@ -364,7 +311,7 @@ async function createNewChatWithMessage(params: {
|
|
|
364
311
|
}
|
|
365
312
|
try {
|
|
366
313
|
const parsed = JSON.parse(body) as unknown;
|
|
367
|
-
return { messageId:
|
|
314
|
+
return { messageId: extractBlueBubblesMessageId(parsed) };
|
|
368
315
|
} catch {
|
|
369
316
|
return { messageId: "ok" };
|
|
370
317
|
}
|
|
@@ -397,8 +344,9 @@ export async function sendMessageBlueBubbles(
|
|
|
397
344
|
if (!password) {
|
|
398
345
|
throw new Error("BlueBubbles password is required");
|
|
399
346
|
}
|
|
347
|
+
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);
|
|
400
348
|
|
|
401
|
-
const target =
|
|
349
|
+
const target = resolveBlueBubblesSendTarget(to);
|
|
402
350
|
const chatGuid = await resolveChatGuidForTarget({
|
|
403
351
|
baseUrl,
|
|
404
352
|
password,
|
|
@@ -422,18 +370,26 @@ export async function sendMessageBlueBubbles(
|
|
|
422
370
|
);
|
|
423
371
|
}
|
|
424
372
|
const effectId = resolveEffectId(opts.effectId);
|
|
425
|
-
const
|
|
373
|
+
const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim());
|
|
374
|
+
const wantsEffect = Boolean(effectId);
|
|
375
|
+
const needsPrivateApi = wantsReplyThread || wantsEffect;
|
|
376
|
+
const canUsePrivateApi = needsPrivateApi && privateApiStatus !== false;
|
|
377
|
+
if (wantsEffect && privateApiStatus === false) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
"BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.",
|
|
380
|
+
);
|
|
381
|
+
}
|
|
426
382
|
const payload: Record<string, unknown> = {
|
|
427
383
|
chatGuid,
|
|
428
384
|
tempGuid: crypto.randomUUID(),
|
|
429
385
|
message: strippedText,
|
|
430
386
|
};
|
|
431
|
-
if (
|
|
387
|
+
if (canUsePrivateApi) {
|
|
432
388
|
payload.method = "private-api";
|
|
433
389
|
}
|
|
434
390
|
|
|
435
391
|
// Add reply threading support
|
|
436
|
-
if (
|
|
392
|
+
if (wantsReplyThread && canUsePrivateApi) {
|
|
437
393
|
payload.selectedMessageGuid = opts.replyToMessageGuid;
|
|
438
394
|
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
|
|
439
395
|
}
|
|
@@ -467,7 +423,7 @@ export async function sendMessageBlueBubbles(
|
|
|
467
423
|
}
|
|
468
424
|
try {
|
|
469
425
|
const parsed = JSON.parse(body) as unknown;
|
|
470
|
-
return { messageId:
|
|
426
|
+
return { messageId: extractBlueBubblesMessageId(parsed) };
|
|
471
427
|
} catch {
|
|
472
428
|
return { messageId: "ok" };
|
|
473
429
|
}
|
package/src/targets.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseChatAllowTargetPrefixes,
|
|
3
|
+
parseChatTargetPrefixesOrThrow,
|
|
4
|
+
resolveServicePrefixedAllowTarget,
|
|
5
|
+
resolveServicePrefixedTarget,
|
|
6
|
+
} from "openclaw/plugin-sdk";
|
|
7
|
+
|
|
1
8
|
export type BlueBubblesService = "imessage" | "sms" | "auto";
|
|
2
9
|
|
|
3
10
|
export type BlueBubblesTarget =
|
|
@@ -205,54 +212,30 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
|
|
205
212
|
}
|
|
206
213
|
const lower = trimmed.toLowerCase();
|
|
207
214
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
throw new Error(`Invalid chat_id: ${value}`);
|
|
233
|
-
}
|
|
234
|
-
return { kind: "chat_id", chatId };
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
for (const prefix of CHAT_GUID_PREFIXES) {
|
|
239
|
-
if (lower.startsWith(prefix)) {
|
|
240
|
-
const value = stripPrefix(trimmed, prefix);
|
|
241
|
-
if (!value) {
|
|
242
|
-
throw new Error("chat_guid is required");
|
|
243
|
-
}
|
|
244
|
-
return { kind: "chat_guid", chatGuid: value };
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
|
|
249
|
-
if (lower.startsWith(prefix)) {
|
|
250
|
-
const value = stripPrefix(trimmed, prefix);
|
|
251
|
-
if (!value) {
|
|
252
|
-
throw new Error("chat_identifier is required");
|
|
253
|
-
}
|
|
254
|
-
return { kind: "chat_identifier", chatIdentifier: value };
|
|
255
|
-
}
|
|
215
|
+
const servicePrefixed = resolveServicePrefixedTarget({
|
|
216
|
+
trimmed,
|
|
217
|
+
lower,
|
|
218
|
+
servicePrefixes: SERVICE_PREFIXES,
|
|
219
|
+
isChatTarget: (remainderLower) =>
|
|
220
|
+
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
|
221
|
+
CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
|
222
|
+
CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
|
223
|
+
remainderLower.startsWith("group:"),
|
|
224
|
+
parseTarget: parseBlueBubblesTarget,
|
|
225
|
+
});
|
|
226
|
+
if (servicePrefixed) {
|
|
227
|
+
return servicePrefixed;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const chatTarget = parseChatTargetPrefixesOrThrow({
|
|
231
|
+
trimmed,
|
|
232
|
+
lower,
|
|
233
|
+
chatIdPrefixes: CHAT_ID_PREFIXES,
|
|
234
|
+
chatGuidPrefixes: CHAT_GUID_PREFIXES,
|
|
235
|
+
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
|
|
236
|
+
});
|
|
237
|
+
if (chatTarget) {
|
|
238
|
+
return chatTarget;
|
|
256
239
|
}
|
|
257
240
|
|
|
258
241
|
if (lower.startsWith("group:")) {
|
|
@@ -293,42 +276,25 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget
|
|
|
293
276
|
}
|
|
294
277
|
const lower = trimmed.toLowerCase();
|
|
295
278
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
for (const prefix of CHAT_GUID_PREFIXES) {
|
|
317
|
-
if (lower.startsWith(prefix)) {
|
|
318
|
-
const value = stripPrefix(trimmed, prefix);
|
|
319
|
-
if (value) {
|
|
320
|
-
return { kind: "chat_guid", chatGuid: value };
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
|
|
326
|
-
if (lower.startsWith(prefix)) {
|
|
327
|
-
const value = stripPrefix(trimmed, prefix);
|
|
328
|
-
if (value) {
|
|
329
|
-
return { kind: "chat_identifier", chatIdentifier: value };
|
|
330
|
-
}
|
|
331
|
-
}
|
|
279
|
+
const servicePrefixed = resolveServicePrefixedAllowTarget({
|
|
280
|
+
trimmed,
|
|
281
|
+
lower,
|
|
282
|
+
servicePrefixes: SERVICE_PREFIXES,
|
|
283
|
+
parseAllowTarget: parseBlueBubblesAllowTarget,
|
|
284
|
+
});
|
|
285
|
+
if (servicePrefixed) {
|
|
286
|
+
return servicePrefixed;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const chatTarget = parseChatAllowTargetPrefixes({
|
|
290
|
+
trimmed,
|
|
291
|
+
lower,
|
|
292
|
+
chatIdPrefixes: CHAT_ID_PREFIXES,
|
|
293
|
+
chatGuidPrefixes: CHAT_GUID_PREFIXES,
|
|
294
|
+
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
|
|
295
|
+
});
|
|
296
|
+
if (chatTarget) {
|
|
297
|
+
return chatTarget;
|
|
332
298
|
}
|
|
333
299
|
|
|
334
300
|
if (lower.startsWith("group:")) {
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
export type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
|
|
3
4
|
|
|
4
5
|
export type BlueBubblesGroupConfig = {
|
|
5
6
|
/** If true, only respond in this group when mentioned. */
|
|
@@ -45,6 +46,11 @@ export type BlueBubblesAccountConfig = {
|
|
|
45
46
|
blockStreamingCoalesce?: Record<string, unknown>;
|
|
46
47
|
/** Max outbound media size in MB. */
|
|
47
48
|
mediaMaxMb?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Explicit allowlist of local directory roots permitted for outbound media paths.
|
|
51
|
+
* Local paths are rejected unless they resolve under one of these roots.
|
|
52
|
+
*/
|
|
53
|
+
mediaLocalRoots?: string[];
|
|
48
54
|
/** Send read receipts for incoming messages (default: true). */
|
|
49
55
|
sendReadReceipts?: boolean;
|
|
50
56
|
/** Per-group configuration keyed by chat GUID or identifier. */
|