@openclaw/bluebubbles 2026.2.21 → 2026.2.23
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/account-resolve.ts +7 -1
- package/src/actions.test.ts +29 -46
- package/src/actions.ts +2 -13
- package/src/attachments.test.ts +156 -32
- package/src/attachments.ts +49 -16
- package/src/chat.test.ts +257 -57
- package/src/chat.ts +74 -124
- package/src/config-schema.ts +1 -0
- 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-shared.ts +3 -13
- package/src/monitor.test.ts +396 -4
- package/src/onboarding.ts +24 -37
- package/src/probe.ts +8 -0
- package/src/reactions.test.ts +27 -47
- package/src/request-url.ts +12 -0
- package/src/runtime.ts +20 -0
- package/src/send.test.ts +72 -31
- 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/types.ts +2 -0
package/package.json
CHANGED
package/src/account-resolve.ts
CHANGED
|
@@ -12,6 +12,7 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
|
|
|
12
12
|
baseUrl: string;
|
|
13
13
|
password: string;
|
|
14
14
|
accountId: string;
|
|
15
|
+
allowPrivateNetwork: boolean;
|
|
15
16
|
} {
|
|
16
17
|
const account = resolveBlueBubblesAccount({
|
|
17
18
|
cfg: params.cfg ?? {},
|
|
@@ -25,5 +26,10 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
|
|
|
25
26
|
if (!password) {
|
|
26
27
|
throw new Error("BlueBubbles password is required");
|
|
27
28
|
}
|
|
28
|
-
return {
|
|
29
|
+
return {
|
|
30
|
+
baseUrl,
|
|
31
|
+
password,
|
|
32
|
+
accountId: account.accountId,
|
|
33
|
+
allowPrivateNetwork: account.config.allowPrivateNetwork === true,
|
|
34
|
+
};
|
|
29
35
|
}
|
package/src/actions.test.ts
CHANGED
|
@@ -3,17 +3,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
|
3
3
|
import { bluebubblesMessageActions } from "./actions.js";
|
|
4
4
|
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
5
5
|
|
|
6
|
-
vi.mock("./accounts.js", () =>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
accountId: accountId ?? "default",
|
|
11
|
-
enabled: config.enabled !== false,
|
|
12
|
-
configured: Boolean(config.serverUrl && config.password),
|
|
13
|
-
config,
|
|
14
|
-
};
|
|
15
|
-
}),
|
|
16
|
-
}));
|
|
6
|
+
vi.mock("./accounts.js", async () => {
|
|
7
|
+
const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
|
|
8
|
+
return createBlueBubblesAccountsMockModule();
|
|
9
|
+
});
|
|
17
10
|
|
|
18
11
|
vi.mock("./reactions.js", () => ({
|
|
19
12
|
sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
|
|
@@ -54,6 +47,22 @@ describe("bluebubblesMessageActions", () => {
|
|
|
54
47
|
const handleAction = bluebubblesMessageActions.handleAction!;
|
|
55
48
|
const callHandleAction = (ctx: Omit<Parameters<typeof handleAction>[0], "channel">) =>
|
|
56
49
|
handleAction({ channel: "bluebubbles", ...ctx });
|
|
50
|
+
const blueBubblesConfig = (): OpenClawConfig => ({
|
|
51
|
+
channels: {
|
|
52
|
+
bluebubbles: {
|
|
53
|
+
serverUrl: "http://localhost:1234",
|
|
54
|
+
password: "test-password",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const runReactAction = async (params: Record<string, unknown>) => {
|
|
59
|
+
return await callHandleAction({
|
|
60
|
+
action: "react",
|
|
61
|
+
params,
|
|
62
|
+
cfg: blueBubblesConfig(),
|
|
63
|
+
accountId: null,
|
|
64
|
+
});
|
|
65
|
+
};
|
|
57
66
|
|
|
58
67
|
beforeEach(() => {
|
|
59
68
|
vi.clearAllMocks();
|
|
@@ -292,23 +301,10 @@ describe("bluebubblesMessageActions", () => {
|
|
|
292
301
|
it("sends reaction successfully with chatGuid", async () => {
|
|
293
302
|
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
|
294
303
|
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
password: "test-password",
|
|
300
|
-
},
|
|
301
|
-
},
|
|
302
|
-
};
|
|
303
|
-
const result = await callHandleAction({
|
|
304
|
-
action: "react",
|
|
305
|
-
params: {
|
|
306
|
-
emoji: "❤️",
|
|
307
|
-
messageId: "msg-123",
|
|
308
|
-
chatGuid: "iMessage;-;+15551234567",
|
|
309
|
-
},
|
|
310
|
-
cfg,
|
|
311
|
-
accountId: null,
|
|
304
|
+
const result = await runReactAction({
|
|
305
|
+
emoji: "❤️",
|
|
306
|
+
messageId: "msg-123",
|
|
307
|
+
chatGuid: "iMessage;-;+15551234567",
|
|
312
308
|
});
|
|
313
309
|
|
|
314
310
|
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
|
@@ -327,24 +323,11 @@ describe("bluebubblesMessageActions", () => {
|
|
|
327
323
|
it("sends reaction removal successfully", async () => {
|
|
328
324
|
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
|
329
325
|
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
},
|
|
336
|
-
},
|
|
337
|
-
};
|
|
338
|
-
const result = await callHandleAction({
|
|
339
|
-
action: "react",
|
|
340
|
-
params: {
|
|
341
|
-
emoji: "❤️",
|
|
342
|
-
messageId: "msg-123",
|
|
343
|
-
chatGuid: "iMessage;-;+15551234567",
|
|
344
|
-
remove: true,
|
|
345
|
-
},
|
|
346
|
-
cfg,
|
|
347
|
-
accountId: null,
|
|
326
|
+
const result = await runReactAction({
|
|
327
|
+
emoji: "❤️",
|
|
328
|
+
messageId: "msg-123",
|
|
329
|
+
chatGuid: "iMessage;-;+15551234567",
|
|
330
|
+
remove: true,
|
|
348
331
|
});
|
|
349
332
|
|
|
350
333
|
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
package/src/actions.ts
CHANGED
|
@@ -2,13 +2,13 @@ import {
|
|
|
2
2
|
BLUEBUBBLES_ACTION_NAMES,
|
|
3
3
|
BLUEBUBBLES_ACTIONS,
|
|
4
4
|
createActionGate,
|
|
5
|
+
extractToolSend,
|
|
5
6
|
jsonResult,
|
|
6
7
|
readNumberParam,
|
|
7
8
|
readReactionParams,
|
|
8
9
|
readStringParam,
|
|
9
10
|
type ChannelMessageActionAdapter,
|
|
10
11
|
type ChannelMessageActionName,
|
|
11
|
-
type ChannelToolSend,
|
|
12
12
|
} from "openclaw/plugin-sdk";
|
|
13
13
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
|
14
14
|
import { sendBlueBubblesAttachment } from "./attachments.js";
|
|
@@ -112,18 +112,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
112
112
|
return Array.from(actions);
|
|
113
113
|
},
|
|
114
114
|
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
|
115
|
-
extractToolSend: ({ args })
|
|
116
|
-
const action = typeof args.action === "string" ? args.action.trim() : "";
|
|
117
|
-
if (action !== "sendMessage") {
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
const to = typeof args.to === "string" ? args.to : undefined;
|
|
121
|
-
if (!to) {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
|
125
|
-
return { to, accountId };
|
|
126
|
-
},
|
|
115
|
+
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
|
127
116
|
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
|
128
117
|
const account = resolveBlueBubblesAccount({
|
|
129
118
|
cfg: cfg,
|
package/src/attachments.test.ts
CHANGED
|
@@ -1,18 +1,87 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
1
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
3
|
import "./test-mocks.js";
|
|
3
4
|
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
|
4
5
|
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
5
|
-
import {
|
|
6
|
+
import { setBlueBubblesRuntime } from "./runtime.js";
|
|
7
|
+
import {
|
|
8
|
+
BLUE_BUBBLES_PRIVATE_API_STATUS,
|
|
9
|
+
installBlueBubblesFetchTestHooks,
|
|
10
|
+
mockBlueBubblesPrivateApiStatus,
|
|
11
|
+
mockBlueBubblesPrivateApiStatusOnce,
|
|
12
|
+
} from "./test-harness.js";
|
|
6
13
|
import type { BlueBubblesAttachment } from "./types.js";
|
|
7
14
|
|
|
8
15
|
const mockFetch = vi.fn();
|
|
16
|
+
const fetchRemoteMediaMock = vi.fn(
|
|
17
|
+
async (params: {
|
|
18
|
+
url: string;
|
|
19
|
+
maxBytes?: number;
|
|
20
|
+
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
21
|
+
}) => {
|
|
22
|
+
const fetchFn = params.fetchImpl ?? fetch;
|
|
23
|
+
const res = await fetchFn(params.url);
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
const text = await res.text().catch(() => "unknown");
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Failed to fetch media from ${params.url}: HTTP ${res.status}; body: ${text}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
31
|
+
if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
|
|
32
|
+
const error = new Error(`payload exceeds maxBytes ${params.maxBytes}`) as Error & {
|
|
33
|
+
code?: string;
|
|
34
|
+
};
|
|
35
|
+
error.code = "max_bytes";
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
buffer,
|
|
40
|
+
contentType: res.headers.get("content-type") ?? undefined,
|
|
41
|
+
fileName: undefined,
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
);
|
|
9
45
|
|
|
10
46
|
installBlueBubblesFetchTestHooks({
|
|
11
47
|
mockFetch,
|
|
12
48
|
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
|
|
13
49
|
});
|
|
14
50
|
|
|
51
|
+
const runtimeStub = {
|
|
52
|
+
channel: {
|
|
53
|
+
media: {
|
|
54
|
+
fetchRemoteMedia:
|
|
55
|
+
fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
} as unknown as PluginRuntime;
|
|
59
|
+
|
|
15
60
|
describe("downloadBlueBubblesAttachment", () => {
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
fetchRemoteMediaMock.mockClear();
|
|
63
|
+
mockFetch.mockReset();
|
|
64
|
+
setBlueBubblesRuntime(runtimeStub);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
async function expectAttachmentTooLarge(params: { bufferBytes: number; maxBytes?: number }) {
|
|
68
|
+
const largeBuffer = new Uint8Array(params.bufferBytes);
|
|
69
|
+
mockFetch.mockResolvedValueOnce({
|
|
70
|
+
ok: true,
|
|
71
|
+
headers: new Headers(),
|
|
72
|
+
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const attachment: BlueBubblesAttachment = { guid: "att-large" };
|
|
76
|
+
await expect(
|
|
77
|
+
downloadBlueBubblesAttachment(attachment, {
|
|
78
|
+
serverUrl: "http://localhost:1234",
|
|
79
|
+
password: "test",
|
|
80
|
+
...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }),
|
|
81
|
+
}),
|
|
82
|
+
).rejects.toThrow("too large");
|
|
83
|
+
}
|
|
84
|
+
|
|
16
85
|
it("throws when guid is missing", async () => {
|
|
17
86
|
const attachment: BlueBubblesAttachment = {};
|
|
18
87
|
await expect(
|
|
@@ -120,42 +189,18 @@ describe("downloadBlueBubblesAttachment", () => {
|
|
|
120
189
|
serverUrl: "http://localhost:1234",
|
|
121
190
|
password: "test",
|
|
122
191
|
}),
|
|
123
|
-
).rejects.toThrow("
|
|
192
|
+
).rejects.toThrow("Attachment not found");
|
|
124
193
|
});
|
|
125
194
|
|
|
126
195
|
it("throws when attachment exceeds max bytes", async () => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
headers: new Headers(),
|
|
131
|
-
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
|
|
196
|
+
await expectAttachmentTooLarge({
|
|
197
|
+
bufferBytes: 10 * 1024 * 1024,
|
|
198
|
+
maxBytes: 5 * 1024 * 1024,
|
|
132
199
|
});
|
|
133
|
-
|
|
134
|
-
const attachment: BlueBubblesAttachment = { guid: "att-large" };
|
|
135
|
-
await expect(
|
|
136
|
-
downloadBlueBubblesAttachment(attachment, {
|
|
137
|
-
serverUrl: "http://localhost:1234",
|
|
138
|
-
password: "test",
|
|
139
|
-
maxBytes: 5 * 1024 * 1024,
|
|
140
|
-
}),
|
|
141
|
-
).rejects.toThrow("too large");
|
|
142
200
|
});
|
|
143
201
|
|
|
144
202
|
it("uses default max bytes when not specified", async () => {
|
|
145
|
-
|
|
146
|
-
mockFetch.mockResolvedValueOnce({
|
|
147
|
-
ok: true,
|
|
148
|
-
headers: new Headers(),
|
|
149
|
-
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
const attachment: BlueBubblesAttachment = { guid: "att-large" };
|
|
153
|
-
await expect(
|
|
154
|
-
downloadBlueBubblesAttachment(attachment, {
|
|
155
|
-
serverUrl: "http://localhost:1234",
|
|
156
|
-
password: "test",
|
|
157
|
-
}),
|
|
158
|
-
).rejects.toThrow("too large");
|
|
203
|
+
await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 });
|
|
159
204
|
});
|
|
160
205
|
|
|
161
206
|
it("uses attachment mimeType as fallback when response has no content-type", async () => {
|
|
@@ -223,14 +268,62 @@ describe("downloadBlueBubblesAttachment", () => {
|
|
|
223
268
|
expect(calledUrl).toContain("password=config-password");
|
|
224
269
|
expect(result.buffer).toEqual(new Uint8Array([1]));
|
|
225
270
|
});
|
|
271
|
+
|
|
272
|
+
it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => {
|
|
273
|
+
const mockBuffer = new Uint8Array([1]);
|
|
274
|
+
mockFetch.mockResolvedValueOnce({
|
|
275
|
+
ok: true,
|
|
276
|
+
headers: new Headers(),
|
|
277
|
+
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const attachment: BlueBubblesAttachment = { guid: "att-ssrf" };
|
|
281
|
+
await downloadBlueBubblesAttachment(attachment, {
|
|
282
|
+
cfg: {
|
|
283
|
+
channels: {
|
|
284
|
+
bluebubbles: {
|
|
285
|
+
serverUrl: "http://localhost:1234",
|
|
286
|
+
password: "test",
|
|
287
|
+
allowPrivateNetwork: true,
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
|
|
294
|
+
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("does not pass ssrfPolicy when allowPrivateNetwork is not set", async () => {
|
|
298
|
+
const mockBuffer = new Uint8Array([1]);
|
|
299
|
+
mockFetch.mockResolvedValueOnce({
|
|
300
|
+
ok: true,
|
|
301
|
+
headers: new Headers(),
|
|
302
|
+
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" };
|
|
306
|
+
await downloadBlueBubblesAttachment(attachment, {
|
|
307
|
+
serverUrl: "http://localhost:1234",
|
|
308
|
+
password: "test",
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
|
|
312
|
+
expect(fetchMediaArgs.ssrfPolicy).toBeUndefined();
|
|
313
|
+
});
|
|
226
314
|
});
|
|
227
315
|
|
|
228
316
|
describe("sendBlueBubblesAttachment", () => {
|
|
229
317
|
beforeEach(() => {
|
|
230
318
|
vi.stubGlobal("fetch", mockFetch);
|
|
231
319
|
mockFetch.mockReset();
|
|
320
|
+
fetchRemoteMediaMock.mockClear();
|
|
321
|
+
setBlueBubblesRuntime(runtimeStub);
|
|
232
322
|
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
|
233
|
-
|
|
323
|
+
mockBlueBubblesPrivateApiStatus(
|
|
324
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus),
|
|
325
|
+
BLUE_BUBBLES_PRIVATE_API_STATUS.unknown,
|
|
326
|
+
);
|
|
234
327
|
});
|
|
235
328
|
|
|
236
329
|
afterEach(() => {
|
|
@@ -333,7 +426,10 @@ describe("sendBlueBubblesAttachment", () => {
|
|
|
333
426
|
});
|
|
334
427
|
|
|
335
428
|
it("downgrades attachment reply threading when private API is disabled", async () => {
|
|
336
|
-
|
|
429
|
+
mockBlueBubblesPrivateApiStatusOnce(
|
|
430
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus),
|
|
431
|
+
BLUE_BUBBLES_PRIVATE_API_STATUS.disabled,
|
|
432
|
+
);
|
|
337
433
|
mockFetch.mockResolvedValueOnce({
|
|
338
434
|
ok: true,
|
|
339
435
|
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })),
|
|
@@ -354,4 +450,32 @@ describe("sendBlueBubblesAttachment", () => {
|
|
|
354
450
|
expect(bodyText).not.toContain('name="selectedMessageGuid"');
|
|
355
451
|
expect(bodyText).not.toContain('name="partIndex"');
|
|
356
452
|
});
|
|
453
|
+
|
|
454
|
+
it("warns and downgrades attachment reply threading when private API status is unknown", async () => {
|
|
455
|
+
const runtimeLog = vi.fn();
|
|
456
|
+
setBlueBubblesRuntime({
|
|
457
|
+
...runtimeStub,
|
|
458
|
+
log: runtimeLog,
|
|
459
|
+
} as unknown as PluginRuntime);
|
|
460
|
+
mockFetch.mockResolvedValueOnce({
|
|
461
|
+
ok: true,
|
|
462
|
+
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-5" })),
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
await sendBlueBubblesAttachment({
|
|
466
|
+
to: "chat_guid:iMessage;-;+15551234567",
|
|
467
|
+
buffer: new Uint8Array([1, 2, 3]),
|
|
468
|
+
filename: "photo.jpg",
|
|
469
|
+
contentType: "image/jpeg",
|
|
470
|
+
replyToMessageGuid: "reply-guid-unknown",
|
|
471
|
+
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
expect(runtimeLog).toHaveBeenCalledTimes(1);
|
|
475
|
+
expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
|
|
476
|
+
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
|
477
|
+
const bodyText = decodeBody(body);
|
|
478
|
+
expect(bodyText).not.toContain('name="selectedMessageGuid"');
|
|
479
|
+
expect(bodyText).not.toContain('name="partIndex"');
|
|
480
|
+
});
|
|
357
481
|
});
|
package/src/attachments.ts
CHANGED
|
@@ -3,7 +3,12 @@ import path from "node:path";
|
|
|
3
3
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
4
4
|
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
|
5
5
|
import { postMultipartFormData } from "./multipart.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getCachedBlueBubblesPrivateApiStatus,
|
|
8
|
+
isBlueBubblesPrivateApiStatusEnabled,
|
|
9
|
+
} from "./probe.js";
|
|
10
|
+
import { resolveRequestUrl } from "./request-url.js";
|
|
11
|
+
import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js";
|
|
7
12
|
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
|
8
13
|
import { resolveChatGuidForTarget } from "./send.js";
|
|
9
14
|
import {
|
|
@@ -57,6 +62,18 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
|
|
57
62
|
return resolveBlueBubblesServerAccount(params);
|
|
58
63
|
}
|
|
59
64
|
|
|
65
|
+
type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
|
|
66
|
+
|
|
67
|
+
function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined {
|
|
68
|
+
if (!error || typeof error !== "object") {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const code = (error as { code?: unknown }).code;
|
|
72
|
+
return code === "max_bytes" || code === "http_error" || code === "fetch_failed"
|
|
73
|
+
? code
|
|
74
|
+
: undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
export async function downloadBlueBubblesAttachment(
|
|
61
78
|
attachment: BlueBubblesAttachment,
|
|
62
79
|
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
|
|
@@ -65,26 +82,37 @@ export async function downloadBlueBubblesAttachment(
|
|
|
65
82
|
if (!guid) {
|
|
66
83
|
throw new Error("BlueBubbles attachment guid is required");
|
|
67
84
|
}
|
|
68
|
-
const { baseUrl, password } = resolveAccount(opts);
|
|
85
|
+
const { baseUrl, password, allowPrivateNetwork } = resolveAccount(opts);
|
|
69
86
|
const url = buildBlueBubblesApiUrl({
|
|
70
87
|
baseUrl,
|
|
71
88
|
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
|
|
72
89
|
password,
|
|
73
90
|
});
|
|
74
|
-
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs);
|
|
75
|
-
if (!res.ok) {
|
|
76
|
-
const errorText = await res.text().catch(() => "");
|
|
77
|
-
throw new Error(
|
|
78
|
-
`BlueBubbles attachment download failed (${res.status}): ${errorText || "unknown"}`,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
const contentType = res.headers.get("content-type") ?? undefined;
|
|
82
|
-
const buf = new Uint8Array(await res.arrayBuffer());
|
|
83
91
|
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
try {
|
|
93
|
+
const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
|
|
94
|
+
url,
|
|
95
|
+
filePathHint: attachment.transferName ?? attachment.guid ?? "attachment",
|
|
96
|
+
maxBytes,
|
|
97
|
+
ssrfPolicy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
|
|
98
|
+
fetchImpl: async (input, init) =>
|
|
99
|
+
await blueBubblesFetchWithTimeout(
|
|
100
|
+
resolveRequestUrl(input),
|
|
101
|
+
{ ...init, method: init?.method ?? "GET" },
|
|
102
|
+
opts.timeoutMs,
|
|
103
|
+
),
|
|
104
|
+
});
|
|
105
|
+
return {
|
|
106
|
+
buffer: new Uint8Array(fetched.buffer),
|
|
107
|
+
contentType: fetched.contentType ?? attachment.mimeType ?? undefined,
|
|
108
|
+
};
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (readMediaFetchErrorCode(error) === "max_bytes") {
|
|
111
|
+
throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`);
|
|
112
|
+
}
|
|
113
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
114
|
+
throw new Error(`BlueBubbles attachment download failed: ${text}`);
|
|
86
115
|
}
|
|
87
|
-
return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
|
|
88
116
|
}
|
|
89
117
|
|
|
90
118
|
export type SendBlueBubblesAttachmentResult = {
|
|
@@ -115,6 +143,7 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
115
143
|
contentType = contentType?.trim() || undefined;
|
|
116
144
|
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
117
145
|
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
|
|
146
|
+
const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
|
|
118
147
|
|
|
119
148
|
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
|
|
120
149
|
const isAudioMessage = wantsVoice;
|
|
@@ -183,7 +212,7 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
183
212
|
addField("chatGuid", chatGuid);
|
|
184
213
|
addField("name", filename);
|
|
185
214
|
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
|
186
|
-
if (
|
|
215
|
+
if (privateApiEnabled) {
|
|
187
216
|
addField("method", "private-api");
|
|
188
217
|
}
|
|
189
218
|
|
|
@@ -193,9 +222,13 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
193
222
|
}
|
|
194
223
|
|
|
195
224
|
const trimmedReplyTo = replyToMessageGuid?.trim();
|
|
196
|
-
if (trimmedReplyTo &&
|
|
225
|
+
if (trimmedReplyTo && privateApiEnabled) {
|
|
197
226
|
addField("selectedMessageGuid", trimmedReplyTo);
|
|
198
227
|
addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
|
|
228
|
+
} else if (trimmedReplyTo && privateApiStatus === null) {
|
|
229
|
+
warnBlueBubbles(
|
|
230
|
+
"Private API status unknown; sending attachment without reply threading metadata. Run a status probe to restore private-api reply features.",
|
|
231
|
+
);
|
|
199
232
|
}
|
|
200
233
|
|
|
201
234
|
// Add optional caption
|