@openclaw/bluebubbles 2026.2.13 → 2026.2.15
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 +17 -72
- package/src/chat.test.ts +40 -0
- package/src/chat.ts +38 -29
- 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/multipart.ts +32 -0
- package/src/probe.ts +14 -3
- 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/package.json
CHANGED
package/src/accounts.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
-
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
3
3
|
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
|
|
4
4
|
|
|
5
5
|
export type ResolvedBlueBubblesAccount = {
|
package/src/actions.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
2
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
3
3
|
import { bluebubblesMessageActions } from "./actions.js";
|
|
4
|
+
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
4
5
|
|
|
5
6
|
vi.mock("./accounts.js", () => ({
|
|
6
7
|
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
|
@@ -41,9 +42,15 @@ vi.mock("./monitor.js", () => ({
|
|
|
41
42
|
resolveBlueBubblesMessageId: vi.fn((id: string) => id),
|
|
42
43
|
}));
|
|
43
44
|
|
|
45
|
+
vi.mock("./probe.js", () => ({
|
|
46
|
+
isMacOS26OrHigher: vi.fn().mockReturnValue(false),
|
|
47
|
+
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
|
48
|
+
}));
|
|
49
|
+
|
|
44
50
|
describe("bluebubblesMessageActions", () => {
|
|
45
51
|
beforeEach(() => {
|
|
46
52
|
vi.clearAllMocks();
|
|
53
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
|
47
54
|
});
|
|
48
55
|
|
|
49
56
|
describe("listActions", () => {
|
|
@@ -94,6 +101,31 @@ describe("bluebubblesMessageActions", () => {
|
|
|
94
101
|
expect(actions).toContain("edit");
|
|
95
102
|
expect(actions).toContain("unsend");
|
|
96
103
|
});
|
|
104
|
+
|
|
105
|
+
it("hides private-api actions when private API is disabled", () => {
|
|
106
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
|
107
|
+
const cfg: OpenClawConfig = {
|
|
108
|
+
channels: {
|
|
109
|
+
bluebubbles: {
|
|
110
|
+
enabled: true,
|
|
111
|
+
serverUrl: "http://localhost:1234",
|
|
112
|
+
password: "test-password",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const actions = bluebubblesMessageActions.listActions({ cfg });
|
|
117
|
+
expect(actions).toContain("sendAttachment");
|
|
118
|
+
expect(actions).not.toContain("react");
|
|
119
|
+
expect(actions).not.toContain("reply");
|
|
120
|
+
expect(actions).not.toContain("sendWithEffect");
|
|
121
|
+
expect(actions).not.toContain("edit");
|
|
122
|
+
expect(actions).not.toContain("unsend");
|
|
123
|
+
expect(actions).not.toContain("renameGroup");
|
|
124
|
+
expect(actions).not.toContain("setGroupIcon");
|
|
125
|
+
expect(actions).not.toContain("addParticipant");
|
|
126
|
+
expect(actions).not.toContain("removeParticipant");
|
|
127
|
+
expect(actions).not.toContain("leaveGroup");
|
|
128
|
+
});
|
|
97
129
|
});
|
|
98
130
|
|
|
99
131
|
describe("supportsAction", () => {
|
|
@@ -189,6 +221,26 @@ describe("bluebubblesMessageActions", () => {
|
|
|
189
221
|
).rejects.toThrow(/emoji/i);
|
|
190
222
|
});
|
|
191
223
|
|
|
224
|
+
it("throws a private-api error for private-only actions when disabled", async () => {
|
|
225
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
|
226
|
+
const cfg: OpenClawConfig = {
|
|
227
|
+
channels: {
|
|
228
|
+
bluebubbles: {
|
|
229
|
+
serverUrl: "http://localhost:1234",
|
|
230
|
+
password: "test-password",
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
await expect(
|
|
235
|
+
bluebubblesMessageActions.handleAction({
|
|
236
|
+
action: "react",
|
|
237
|
+
params: { emoji: "❤️", messageId: "msg-123", chatGuid: "iMessage;-;+15551234567" },
|
|
238
|
+
cfg,
|
|
239
|
+
accountId: null,
|
|
240
|
+
}),
|
|
241
|
+
).rejects.toThrow("requires Private API");
|
|
242
|
+
});
|
|
243
|
+
|
|
192
244
|
it("throws when messageId is missing", async () => {
|
|
193
245
|
const cfg: OpenClawConfig = {
|
|
194
246
|
channels: {
|
package/src/actions.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
leaveBlueBubblesChat,
|
|
24
24
|
} from "./chat.js";
|
|
25
25
|
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
|
26
|
-
import { isMacOS26OrHigher } from "./probe.js";
|
|
26
|
+
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
|
|
27
27
|
import { sendBlueBubblesReaction } from "./reactions.js";
|
|
28
28
|
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
|
29
29
|
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
|
@@ -71,6 +71,18 @@ function readBooleanParam(params: Record<string, unknown>, key: string): boolean
|
|
|
71
71
|
|
|
72
72
|
/** Supported action names for BlueBubbles */
|
|
73
73
|
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
|
74
|
+
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
|
|
75
|
+
"react",
|
|
76
|
+
"edit",
|
|
77
|
+
"unsend",
|
|
78
|
+
"reply",
|
|
79
|
+
"sendWithEffect",
|
|
80
|
+
"renameGroup",
|
|
81
|
+
"setGroupIcon",
|
|
82
|
+
"addParticipant",
|
|
83
|
+
"removeParticipant",
|
|
84
|
+
"leaveGroup",
|
|
85
|
+
]);
|
|
74
86
|
|
|
75
87
|
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
76
88
|
listActions: ({ cfg }) => {
|
|
@@ -81,11 +93,15 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
81
93
|
const gate = createActionGate(cfg.channels?.bluebubbles?.actions);
|
|
82
94
|
const actions = new Set<ChannelMessageActionName>();
|
|
83
95
|
const macOS26 = isMacOS26OrHigher(account.accountId);
|
|
96
|
+
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);
|
|
84
97
|
for (const action of BLUEBUBBLES_ACTION_NAMES) {
|
|
85
98
|
const spec = BLUEBUBBLES_ACTIONS[action];
|
|
86
99
|
if (!spec?.gate) {
|
|
87
100
|
continue;
|
|
88
101
|
}
|
|
102
|
+
if (privateApiStatus === false && PRIVATE_API_ACTIONS.has(action)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
89
105
|
if ("unsupportedOnMacOS26" in spec && spec.unsupportedOnMacOS26 && macOS26) {
|
|
90
106
|
continue;
|
|
91
107
|
}
|
|
@@ -116,6 +132,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
116
132
|
const baseUrl = account.config.serverUrl?.trim();
|
|
117
133
|
const password = account.config.password?.trim();
|
|
118
134
|
const opts = { cfg: cfg, accountId: accountId ?? undefined };
|
|
135
|
+
const assertPrivateApiEnabled = () => {
|
|
136
|
+
if (getCachedBlueBubblesPrivateApiStatus(account.accountId) === false) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`BlueBubbles ${action} requires Private API, but it is disabled on the BlueBubbles server.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
119
142
|
|
|
120
143
|
// Helper to resolve chatGuid from various params or session context
|
|
121
144
|
const resolveChatGuid = async (): Promise<string> => {
|
|
@@ -159,6 +182,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
159
182
|
|
|
160
183
|
// Handle react action
|
|
161
184
|
if (action === "react") {
|
|
185
|
+
assertPrivateApiEnabled();
|
|
162
186
|
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
|
163
187
|
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
|
|
164
188
|
});
|
|
@@ -193,6 +217,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
193
217
|
|
|
194
218
|
// Handle edit action
|
|
195
219
|
if (action === "edit") {
|
|
220
|
+
assertPrivateApiEnabled();
|
|
196
221
|
// Edit is not supported on macOS 26+
|
|
197
222
|
if (isMacOS26OrHigher(accountId ?? undefined)) {
|
|
198
223
|
throw new Error(
|
|
@@ -234,6 +259,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
234
259
|
|
|
235
260
|
// Handle unsend action
|
|
236
261
|
if (action === "unsend") {
|
|
262
|
+
assertPrivateApiEnabled();
|
|
237
263
|
const rawMessageId = readStringParam(params, "messageId");
|
|
238
264
|
if (!rawMessageId) {
|
|
239
265
|
throw new Error(
|
|
@@ -255,6 +281,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
255
281
|
|
|
256
282
|
// Handle reply action
|
|
257
283
|
if (action === "reply") {
|
|
284
|
+
assertPrivateApiEnabled();
|
|
258
285
|
const rawMessageId = readStringParam(params, "messageId");
|
|
259
286
|
const text = readMessageText(params);
|
|
260
287
|
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
|
@@ -289,6 +316,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
289
316
|
|
|
290
317
|
// Handle sendWithEffect action
|
|
291
318
|
if (action === "sendWithEffect") {
|
|
319
|
+
assertPrivateApiEnabled();
|
|
292
320
|
const text = readMessageText(params);
|
|
293
321
|
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
|
294
322
|
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
|
|
@@ -321,6 +349,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
321
349
|
|
|
322
350
|
// Handle renameGroup action
|
|
323
351
|
if (action === "renameGroup") {
|
|
352
|
+
assertPrivateApiEnabled();
|
|
324
353
|
const resolvedChatGuid = await resolveChatGuid();
|
|
325
354
|
const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name");
|
|
326
355
|
if (!displayName) {
|
|
@@ -334,6 +363,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
334
363
|
|
|
335
364
|
// Handle setGroupIcon action
|
|
336
365
|
if (action === "setGroupIcon") {
|
|
366
|
+
assertPrivateApiEnabled();
|
|
337
367
|
const resolvedChatGuid = await resolveChatGuid();
|
|
338
368
|
const base64Buffer = readStringParam(params, "buffer");
|
|
339
369
|
const filename =
|
|
@@ -361,6 +391,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
361
391
|
|
|
362
392
|
// Handle addParticipant action
|
|
363
393
|
if (action === "addParticipant") {
|
|
394
|
+
assertPrivateApiEnabled();
|
|
364
395
|
const resolvedChatGuid = await resolveChatGuid();
|
|
365
396
|
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
|
|
366
397
|
if (!address) {
|
|
@@ -374,6 +405,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
374
405
|
|
|
375
406
|
// Handle removeParticipant action
|
|
376
407
|
if (action === "removeParticipant") {
|
|
408
|
+
assertPrivateApiEnabled();
|
|
377
409
|
const resolvedChatGuid = await resolveChatGuid();
|
|
378
410
|
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
|
|
379
411
|
if (!address) {
|
|
@@ -387,6 +419,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
387
419
|
|
|
388
420
|
// Handle leaveGroup action
|
|
389
421
|
if (action === "leaveGroup") {
|
|
422
|
+
assertPrivateApiEnabled();
|
|
390
423
|
const resolvedChatGuid = await resolveChatGuid();
|
|
391
424
|
|
|
392
425
|
await leaveBlueBubblesChat(resolvedChatGuid, opts);
|
package/src/attachments.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import type { BlueBubblesAttachment } from "./types.js";
|
|
3
3
|
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
|
4
|
+
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
4
5
|
|
|
5
6
|
vi.mock("./accounts.js", () => ({
|
|
6
7
|
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
|
@@ -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("downloadBlueBubblesAttachment", () => {
|
|
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(() => {
|
|
@@ -242,6 +249,8 @@ describe("sendBlueBubblesAttachment", () => {
|
|
|
242
249
|
beforeEach(() => {
|
|
243
250
|
vi.stubGlobal("fetch", mockFetch);
|
|
244
251
|
mockFetch.mockReset();
|
|
252
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
|
253
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
|
245
254
|
});
|
|
246
255
|
|
|
247
256
|
afterEach(() => {
|
|
@@ -342,4 +351,27 @@ describe("sendBlueBubblesAttachment", () => {
|
|
|
342
351
|
expect(bodyText).toContain('filename="evil.mp3"');
|
|
343
352
|
expect(bodyText).toContain('name="evil.mp3"');
|
|
344
353
|
});
|
|
354
|
+
|
|
355
|
+
it("downgrades attachment reply threading when private API is disabled", async () => {
|
|
356
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
|
357
|
+
mockFetch.mockResolvedValueOnce({
|
|
358
|
+
ok: true,
|
|
359
|
+
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
await sendBlueBubblesAttachment({
|
|
363
|
+
to: "chat_guid:iMessage;-;+15551234567",
|
|
364
|
+
buffer: new Uint8Array([1, 2, 3]),
|
|
365
|
+
filename: "photo.jpg",
|
|
366
|
+
contentType: "image/jpeg",
|
|
367
|
+
replyToMessageGuid: "reply-guid-123",
|
|
368
|
+
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
|
372
|
+
const bodyText = decodeBody(body);
|
|
373
|
+
expect(bodyText).not.toContain('name="method"');
|
|
374
|
+
expect(bodyText).not.toContain('name="selectedMessageGuid"');
|
|
375
|
+
expect(bodyText).not.toContain('name="partIndex"');
|
|
376
|
+
});
|
|
345
377
|
});
|
package/src/attachments.ts
CHANGED
|
@@ -2,8 +2,10 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
|
5
|
+
import { postMultipartFormData } from "./multipart.js";
|
|
6
|
+
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
7
|
+
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
|
5
8
|
import { resolveChatGuidForTarget } from "./send.js";
|
|
6
|
-
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
|
|
7
9
|
import {
|
|
8
10
|
blueBubblesFetchWithTimeout,
|
|
9
11
|
buildBlueBubblesApiUrl,
|
|
@@ -64,7 +66,7 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
|
|
64
66
|
if (!password) {
|
|
65
67
|
throw new Error("BlueBubbles password is required");
|
|
66
68
|
}
|
|
67
|
-
return { baseUrl, password };
|
|
69
|
+
return { baseUrl, password, accountId: account.accountId };
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
export async function downloadBlueBubblesAttachment(
|
|
@@ -101,52 +103,6 @@ export type SendBlueBubblesAttachmentResult = {
|
|
|
101
103
|
messageId: string;
|
|
102
104
|
};
|
|
103
105
|
|
|
104
|
-
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
|
105
|
-
const parsed = parseBlueBubblesTarget(raw);
|
|
106
|
-
if (parsed.kind === "handle") {
|
|
107
|
-
return {
|
|
108
|
-
kind: "handle",
|
|
109
|
-
address: normalizeBlueBubblesHandle(parsed.to),
|
|
110
|
-
service: parsed.service,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
if (parsed.kind === "chat_id") {
|
|
114
|
-
return { kind: "chat_id", chatId: parsed.chatId };
|
|
115
|
-
}
|
|
116
|
-
if (parsed.kind === "chat_guid") {
|
|
117
|
-
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
|
|
118
|
-
}
|
|
119
|
-
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function extractMessageId(payload: unknown): string {
|
|
123
|
-
if (!payload || typeof payload !== "object") {
|
|
124
|
-
return "unknown";
|
|
125
|
-
}
|
|
126
|
-
const record = payload as Record<string, unknown>;
|
|
127
|
-
const data =
|
|
128
|
-
record.data && typeof record.data === "object"
|
|
129
|
-
? (record.data as Record<string, unknown>)
|
|
130
|
-
: null;
|
|
131
|
-
const candidates = [
|
|
132
|
-
record.messageId,
|
|
133
|
-
record.guid,
|
|
134
|
-
record.id,
|
|
135
|
-
data?.messageId,
|
|
136
|
-
data?.guid,
|
|
137
|
-
data?.id,
|
|
138
|
-
];
|
|
139
|
-
for (const candidate of candidates) {
|
|
140
|
-
if (typeof candidate === "string" && candidate.trim()) {
|
|
141
|
-
return candidate.trim();
|
|
142
|
-
}
|
|
143
|
-
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
|
144
|
-
return String(candidate);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return "unknown";
|
|
148
|
-
}
|
|
149
|
-
|
|
150
106
|
/**
|
|
151
107
|
* Send an attachment via BlueBubbles API.
|
|
152
108
|
* Supports sending media files (images, videos, audio, documents) to a chat.
|
|
@@ -169,7 +125,8 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
169
125
|
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
|
|
170
126
|
filename = sanitizeFilename(filename, fallbackName);
|
|
171
127
|
contentType = contentType?.trim() || undefined;
|
|
172
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
128
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
129
|
+
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
|
|
173
130
|
|
|
174
131
|
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
|
|
175
132
|
const isAudioMessage = wantsVoice;
|
|
@@ -191,7 +148,7 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
191
148
|
}
|
|
192
149
|
}
|
|
193
150
|
|
|
194
|
-
const target =
|
|
151
|
+
const target = resolveBlueBubblesSendTarget(to);
|
|
195
152
|
const chatGuid = await resolveChatGuidForTarget({
|
|
196
153
|
baseUrl,
|
|
197
154
|
password,
|
|
@@ -238,7 +195,9 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
238
195
|
addField("chatGuid", chatGuid);
|
|
239
196
|
addField("name", filename);
|
|
240
197
|
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
|
241
|
-
|
|
198
|
+
if (privateApiStatus !== false) {
|
|
199
|
+
addField("method", "private-api");
|
|
200
|
+
}
|
|
242
201
|
|
|
243
202
|
// Add isAudioMessage flag for voice memos
|
|
244
203
|
if (isAudioMessage) {
|
|
@@ -246,7 +205,7 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
246
205
|
}
|
|
247
206
|
|
|
248
207
|
const trimmedReplyTo = replyToMessageGuid?.trim();
|
|
249
|
-
if (trimmedReplyTo) {
|
|
208
|
+
if (trimmedReplyTo && privateApiStatus !== false) {
|
|
250
209
|
addField("selectedMessageGuid", trimmedReplyTo);
|
|
251
210
|
addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
|
|
252
211
|
}
|
|
@@ -261,26 +220,12 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
261
220
|
// Close the multipart body
|
|
262
221
|
parts.push(encoder.encode(`--${boundary}--\r\n`));
|
|
263
222
|
|
|
264
|
-
|
|
265
|
-
const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
|
|
266
|
-
const body = new Uint8Array(totalLength);
|
|
267
|
-
let offset = 0;
|
|
268
|
-
for (const part of parts) {
|
|
269
|
-
body.set(part, offset);
|
|
270
|
-
offset += part.length;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const res = await blueBubblesFetchWithTimeout(
|
|
223
|
+
const res = await postMultipartFormData({
|
|
274
224
|
url,
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
},
|
|
280
|
-
body,
|
|
281
|
-
},
|
|
282
|
-
opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
|
283
|
-
);
|
|
225
|
+
boundary,
|
|
226
|
+
parts,
|
|
227
|
+
timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
|
228
|
+
});
|
|
284
229
|
|
|
285
230
|
if (!res.ok) {
|
|
286
231
|
const errorText = await res.text();
|
|
@@ -295,7 +240,7 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
295
240
|
}
|
|
296
241
|
try {
|
|
297
242
|
const parsed = JSON.parse(responseBody) as unknown;
|
|
298
|
-
return { messageId:
|
|
243
|
+
return { messageId: extractBlueBubblesMessageId(parsed) };
|
|
299
244
|
} catch {
|
|
300
245
|
return { messageId: "ok" };
|
|
301
246
|
}
|
package/src/chat.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
|
|
3
|
+
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
3
4
|
|
|
4
5
|
vi.mock("./accounts.js", () => ({
|
|
5
6
|
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
|
@@ -13,12 +14,18 @@ vi.mock("./accounts.js", () => ({
|
|
|
13
14
|
}),
|
|
14
15
|
}));
|
|
15
16
|
|
|
17
|
+
vi.mock("./probe.js", () => ({
|
|
18
|
+
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
|
19
|
+
}));
|
|
20
|
+
|
|
16
21
|
const mockFetch = vi.fn();
|
|
17
22
|
|
|
18
23
|
describe("chat", () => {
|
|
19
24
|
beforeEach(() => {
|
|
20
25
|
vi.stubGlobal("fetch", mockFetch);
|
|
21
26
|
mockFetch.mockReset();
|
|
27
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
|
28
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
|
22
29
|
});
|
|
23
30
|
|
|
24
31
|
afterEach(() => {
|
|
@@ -73,6 +80,17 @@ describe("chat", () => {
|
|
|
73
80
|
);
|
|
74
81
|
});
|
|
75
82
|
|
|
83
|
+
it("does not send read receipt when private API is disabled", async () => {
|
|
84
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
|
85
|
+
|
|
86
|
+
await markBlueBubblesChatRead("iMessage;-;+15551234567", {
|
|
87
|
+
serverUrl: "http://localhost:1234",
|
|
88
|
+
password: "test-password",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
76
94
|
it("includes password in URL query", async () => {
|
|
77
95
|
mockFetch.mockResolvedValueOnce({
|
|
78
96
|
ok: true,
|
|
@@ -190,6 +208,17 @@ describe("chat", () => {
|
|
|
190
208
|
);
|
|
191
209
|
});
|
|
192
210
|
|
|
211
|
+
it("does not send typing when private API is disabled", async () => {
|
|
212
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
|
213
|
+
|
|
214
|
+
await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
|
|
215
|
+
serverUrl: "http://localhost:1234",
|
|
216
|
+
password: "test",
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
|
|
193
222
|
it("sends typing stop with DELETE method", async () => {
|
|
194
223
|
mockFetch.mockResolvedValueOnce({
|
|
195
224
|
ok: true,
|
|
@@ -348,6 +377,17 @@ describe("chat", () => {
|
|
|
348
377
|
).rejects.toThrow("password is required");
|
|
349
378
|
});
|
|
350
379
|
|
|
380
|
+
it("throws when private API is disabled", async () => {
|
|
381
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
|
382
|
+
await expect(
|
|
383
|
+
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
|
|
384
|
+
serverUrl: "http://localhost:1234",
|
|
385
|
+
password: "test",
|
|
386
|
+
}),
|
|
387
|
+
).rejects.toThrow("requires Private API");
|
|
388
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
389
|
+
});
|
|
390
|
+
|
|
351
391
|
it("sets group icon successfully", async () => {
|
|
352
392
|
mockFetch.mockResolvedValueOnce({
|
|
353
393
|
ok: true,
|
package/src/chat.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
|
5
|
+
import { postMultipartFormData } from "./multipart.js";
|
|
6
|
+
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
5
7
|
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
|
6
8
|
|
|
7
9
|
export type BlueBubblesChatOpts = {
|
|
@@ -25,7 +27,15 @@ function resolveAccount(params: BlueBubblesChatOpts) {
|
|
|
25
27
|
if (!password) {
|
|
26
28
|
throw new Error("BlueBubbles password is required");
|
|
27
29
|
}
|
|
28
|
-
return { baseUrl, password };
|
|
30
|
+
return { baseUrl, password, accountId: account.accountId };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assertPrivateApiEnabled(accountId: string, feature: string): void {
|
|
34
|
+
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`BlueBubbles ${feature} requires Private API, but it is disabled on the BlueBubbles server.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
export async function markBlueBubblesChatRead(
|
|
@@ -36,7 +46,10 @@ export async function markBlueBubblesChatRead(
|
|
|
36
46
|
if (!trimmed) {
|
|
37
47
|
return;
|
|
38
48
|
}
|
|
39
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
49
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
50
|
+
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
40
53
|
const url = buildBlueBubblesApiUrl({
|
|
41
54
|
baseUrl,
|
|
42
55
|
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
|
|
@@ -58,7 +71,10 @@ export async function sendBlueBubblesTyping(
|
|
|
58
71
|
if (!trimmed) {
|
|
59
72
|
return;
|
|
60
73
|
}
|
|
61
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
74
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
75
|
+
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
62
78
|
const url = buildBlueBubblesApiUrl({
|
|
63
79
|
baseUrl,
|
|
64
80
|
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
|
|
@@ -93,7 +109,8 @@ export async function editBlueBubblesMessage(
|
|
|
93
109
|
throw new Error("BlueBubbles edit requires newText");
|
|
94
110
|
}
|
|
95
111
|
|
|
96
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
112
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
113
|
+
assertPrivateApiEnabled(accountId, "edit");
|
|
97
114
|
const url = buildBlueBubblesApiUrl({
|
|
98
115
|
baseUrl,
|
|
99
116
|
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`,
|
|
@@ -135,7 +152,8 @@ export async function unsendBlueBubblesMessage(
|
|
|
135
152
|
throw new Error("BlueBubbles unsend requires messageGuid");
|
|
136
153
|
}
|
|
137
154
|
|
|
138
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
155
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
156
|
+
assertPrivateApiEnabled(accountId, "unsend");
|
|
139
157
|
const url = buildBlueBubblesApiUrl({
|
|
140
158
|
baseUrl,
|
|
141
159
|
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
|
|
@@ -175,7 +193,8 @@ export async function renameBlueBubblesChat(
|
|
|
175
193
|
throw new Error("BlueBubbles rename requires chatGuid");
|
|
176
194
|
}
|
|
177
195
|
|
|
178
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
196
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
197
|
+
assertPrivateApiEnabled(accountId, "renameGroup");
|
|
179
198
|
const url = buildBlueBubblesApiUrl({
|
|
180
199
|
baseUrl,
|
|
181
200
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
|
|
@@ -215,7 +234,8 @@ export async function addBlueBubblesParticipant(
|
|
|
215
234
|
throw new Error("BlueBubbles addParticipant requires address");
|
|
216
235
|
}
|
|
217
236
|
|
|
218
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
237
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
238
|
+
assertPrivateApiEnabled(accountId, "addParticipant");
|
|
219
239
|
const url = buildBlueBubblesApiUrl({
|
|
220
240
|
baseUrl,
|
|
221
241
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
|
|
@@ -255,7 +275,8 @@ export async function removeBlueBubblesParticipant(
|
|
|
255
275
|
throw new Error("BlueBubbles removeParticipant requires address");
|
|
256
276
|
}
|
|
257
277
|
|
|
258
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
278
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
279
|
+
assertPrivateApiEnabled(accountId, "removeParticipant");
|
|
259
280
|
const url = buildBlueBubblesApiUrl({
|
|
260
281
|
baseUrl,
|
|
261
282
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
|
|
@@ -292,7 +313,8 @@ export async function leaveBlueBubblesChat(
|
|
|
292
313
|
throw new Error("BlueBubbles leaveChat requires chatGuid");
|
|
293
314
|
}
|
|
294
315
|
|
|
295
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
316
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
317
|
+
assertPrivateApiEnabled(accountId, "leaveGroup");
|
|
296
318
|
const url = buildBlueBubblesApiUrl({
|
|
297
319
|
baseUrl,
|
|
298
320
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`,
|
|
@@ -325,7 +347,8 @@ export async function setGroupIconBlueBubbles(
|
|
|
325
347
|
throw new Error("BlueBubbles setGroupIcon requires image buffer");
|
|
326
348
|
}
|
|
327
349
|
|
|
328
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
350
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
351
|
+
assertPrivateApiEnabled(accountId, "setGroupIcon");
|
|
329
352
|
const url = buildBlueBubblesApiUrl({
|
|
330
353
|
baseUrl,
|
|
331
354
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`,
|
|
@@ -354,26 +377,12 @@ export async function setGroupIconBlueBubbles(
|
|
|
354
377
|
// Close multipart body
|
|
355
378
|
parts.push(encoder.encode(`--${boundary}--\r\n`));
|
|
356
379
|
|
|
357
|
-
|
|
358
|
-
const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
|
|
359
|
-
const body = new Uint8Array(totalLength);
|
|
360
|
-
let offset = 0;
|
|
361
|
-
for (const part of parts) {
|
|
362
|
-
body.set(part, offset);
|
|
363
|
-
offset += part.length;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const res = await blueBubblesFetchWithTimeout(
|
|
380
|
+
const res = await postMultipartFormData({
|
|
367
381
|
url,
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
},
|
|
373
|
-
body,
|
|
374
|
-
},
|
|
375
|
-
opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
|
376
|
-
);
|
|
382
|
+
boundary,
|
|
383
|
+
parts,
|
|
384
|
+
timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
|
385
|
+
});
|
|
377
386
|
|
|
378
387
|
if (!res.ok) {
|
|
379
388
|
const errorText = await res.text().catch(() => "");
|
package/src/config-schema.ts
CHANGED
|
@@ -40,6 +40,7 @@ const bluebubblesAccountSchema = z.object({
|
|
|
40
40
|
textChunkLimit: z.number().int().positive().optional(),
|
|
41
41
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
42
42
|
mediaMaxMb: z.number().int().positive().optional(),
|
|
43
|
+
mediaLocalRoots: z.array(z.string()).optional(),
|
|
43
44
|
sendReadReceipts: z.boolean().optional(),
|
|
44
45
|
blockStreaming: z.boolean().optional(),
|
|
45
46
|
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
|