@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/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,9 @@ 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 { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
6
|
+
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
|
5
7
|
import { resolveChatGuidForTarget } from "./send.js";
|
|
6
|
-
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
|
|
7
8
|
import {
|
|
8
9
|
blueBubblesFetchWithTimeout,
|
|
9
10
|
buildBlueBubblesApiUrl,
|
|
@@ -64,7 +65,7 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
|
|
64
65
|
if (!password) {
|
|
65
66
|
throw new Error("BlueBubbles password is required");
|
|
66
67
|
}
|
|
67
|
-
return { baseUrl, password };
|
|
68
|
+
return { baseUrl, password, accountId: account.accountId };
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
export async function downloadBlueBubblesAttachment(
|
|
@@ -101,52 +102,6 @@ export type SendBlueBubblesAttachmentResult = {
|
|
|
101
102
|
messageId: string;
|
|
102
103
|
};
|
|
103
104
|
|
|
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
105
|
/**
|
|
151
106
|
* Send an attachment via BlueBubbles API.
|
|
152
107
|
* Supports sending media files (images, videos, audio, documents) to a chat.
|
|
@@ -169,7 +124,8 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
169
124
|
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
|
|
170
125
|
filename = sanitizeFilename(filename, fallbackName);
|
|
171
126
|
contentType = contentType?.trim() || undefined;
|
|
172
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
127
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
128
|
+
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
|
|
173
129
|
|
|
174
130
|
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
|
|
175
131
|
const isAudioMessage = wantsVoice;
|
|
@@ -191,7 +147,7 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
191
147
|
}
|
|
192
148
|
}
|
|
193
149
|
|
|
194
|
-
const target =
|
|
150
|
+
const target = resolveBlueBubblesSendTarget(to);
|
|
195
151
|
const chatGuid = await resolveChatGuidForTarget({
|
|
196
152
|
baseUrl,
|
|
197
153
|
password,
|
|
@@ -238,7 +194,9 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
238
194
|
addField("chatGuid", chatGuid);
|
|
239
195
|
addField("name", filename);
|
|
240
196
|
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
|
241
|
-
|
|
197
|
+
if (privateApiStatus !== false) {
|
|
198
|
+
addField("method", "private-api");
|
|
199
|
+
}
|
|
242
200
|
|
|
243
201
|
// Add isAudioMessage flag for voice memos
|
|
244
202
|
if (isAudioMessage) {
|
|
@@ -246,7 +204,7 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
246
204
|
}
|
|
247
205
|
|
|
248
206
|
const trimmedReplyTo = replyToMessageGuid?.trim();
|
|
249
|
-
if (trimmedReplyTo) {
|
|
207
|
+
if (trimmedReplyTo && privateApiStatus !== false) {
|
|
250
208
|
addField("selectedMessageGuid", trimmedReplyTo);
|
|
251
209
|
addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
|
|
252
210
|
}
|
|
@@ -295,7 +253,7 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
295
253
|
}
|
|
296
254
|
try {
|
|
297
255
|
const parsed = JSON.parse(responseBody) as unknown;
|
|
298
|
-
return { messageId:
|
|
256
|
+
return { messageId: extractBlueBubblesMessageId(parsed) };
|
|
299
257
|
} catch {
|
|
300
258
|
return { messageId: "ok" };
|
|
301
259
|
}
|
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,7 @@ 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 { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
5
6
|
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
|
6
7
|
|
|
7
8
|
export type BlueBubblesChatOpts = {
|
|
@@ -25,7 +26,15 @@ function resolveAccount(params: BlueBubblesChatOpts) {
|
|
|
25
26
|
if (!password) {
|
|
26
27
|
throw new Error("BlueBubbles password is required");
|
|
27
28
|
}
|
|
28
|
-
return { baseUrl, password };
|
|
29
|
+
return { baseUrl, password, accountId: account.accountId };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assertPrivateApiEnabled(accountId: string, feature: string): void {
|
|
33
|
+
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`BlueBubbles ${feature} requires Private API, but it is disabled on the BlueBubbles server.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
export async function markBlueBubblesChatRead(
|
|
@@ -36,7 +45,10 @@ export async function markBlueBubblesChatRead(
|
|
|
36
45
|
if (!trimmed) {
|
|
37
46
|
return;
|
|
38
47
|
}
|
|
39
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
48
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
49
|
+
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
40
52
|
const url = buildBlueBubblesApiUrl({
|
|
41
53
|
baseUrl,
|
|
42
54
|
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
|
|
@@ -58,7 +70,10 @@ export async function sendBlueBubblesTyping(
|
|
|
58
70
|
if (!trimmed) {
|
|
59
71
|
return;
|
|
60
72
|
}
|
|
61
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
73
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
74
|
+
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
62
77
|
const url = buildBlueBubblesApiUrl({
|
|
63
78
|
baseUrl,
|
|
64
79
|
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
|
|
@@ -93,7 +108,8 @@ export async function editBlueBubblesMessage(
|
|
|
93
108
|
throw new Error("BlueBubbles edit requires newText");
|
|
94
109
|
}
|
|
95
110
|
|
|
96
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
111
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
112
|
+
assertPrivateApiEnabled(accountId, "edit");
|
|
97
113
|
const url = buildBlueBubblesApiUrl({
|
|
98
114
|
baseUrl,
|
|
99
115
|
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`,
|
|
@@ -135,7 +151,8 @@ export async function unsendBlueBubblesMessage(
|
|
|
135
151
|
throw new Error("BlueBubbles unsend requires messageGuid");
|
|
136
152
|
}
|
|
137
153
|
|
|
138
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
154
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
155
|
+
assertPrivateApiEnabled(accountId, "unsend");
|
|
139
156
|
const url = buildBlueBubblesApiUrl({
|
|
140
157
|
baseUrl,
|
|
141
158
|
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
|
|
@@ -175,7 +192,8 @@ export async function renameBlueBubblesChat(
|
|
|
175
192
|
throw new Error("BlueBubbles rename requires chatGuid");
|
|
176
193
|
}
|
|
177
194
|
|
|
178
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
195
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
196
|
+
assertPrivateApiEnabled(accountId, "renameGroup");
|
|
179
197
|
const url = buildBlueBubblesApiUrl({
|
|
180
198
|
baseUrl,
|
|
181
199
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
|
|
@@ -215,7 +233,8 @@ export async function addBlueBubblesParticipant(
|
|
|
215
233
|
throw new Error("BlueBubbles addParticipant requires address");
|
|
216
234
|
}
|
|
217
235
|
|
|
218
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
236
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
237
|
+
assertPrivateApiEnabled(accountId, "addParticipant");
|
|
219
238
|
const url = buildBlueBubblesApiUrl({
|
|
220
239
|
baseUrl,
|
|
221
240
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
|
|
@@ -255,7 +274,8 @@ export async function removeBlueBubblesParticipant(
|
|
|
255
274
|
throw new Error("BlueBubbles removeParticipant requires address");
|
|
256
275
|
}
|
|
257
276
|
|
|
258
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
277
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
278
|
+
assertPrivateApiEnabled(accountId, "removeParticipant");
|
|
259
279
|
const url = buildBlueBubblesApiUrl({
|
|
260
280
|
baseUrl,
|
|
261
281
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
|
|
@@ -292,7 +312,8 @@ export async function leaveBlueBubblesChat(
|
|
|
292
312
|
throw new Error("BlueBubbles leaveChat requires chatGuid");
|
|
293
313
|
}
|
|
294
314
|
|
|
295
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
315
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
316
|
+
assertPrivateApiEnabled(accountId, "leaveGroup");
|
|
296
317
|
const url = buildBlueBubblesApiUrl({
|
|
297
318
|
baseUrl,
|
|
298
319
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`,
|
|
@@ -325,7 +346,8 @@ export async function setGroupIconBlueBubbles(
|
|
|
325
346
|
throw new Error("BlueBubbles setGroupIcon requires image buffer");
|
|
326
347
|
}
|
|
327
348
|
|
|
328
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
349
|
+
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
350
|
+
assertPrivateApiEnabled(accountId, "setGroupIcon");
|
|
329
351
|
const url = buildBlueBubblesApiUrl({
|
|
330
352
|
baseUrl,
|
|
331
353
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`,
|
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(),
|