@openclaw/bluebubbles 2026.1.29

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.
@@ -0,0 +1,346 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
4
+ import type { BlueBubblesAttachment } from "./types.js";
5
+
6
+ vi.mock("./accounts.js", () => ({
7
+ resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
8
+ const config = cfg?.channels?.bluebubbles ?? {};
9
+ return {
10
+ accountId: accountId ?? "default",
11
+ enabled: config.enabled !== false,
12
+ configured: Boolean(config.serverUrl && config.password),
13
+ config,
14
+ };
15
+ }),
16
+ }));
17
+
18
+ const mockFetch = vi.fn();
19
+
20
+ describe("downloadBlueBubblesAttachment", () => {
21
+ beforeEach(() => {
22
+ vi.stubGlobal("fetch", mockFetch);
23
+ mockFetch.mockReset();
24
+ });
25
+
26
+ afterEach(() => {
27
+ vi.unstubAllGlobals();
28
+ });
29
+
30
+ it("throws when guid is missing", async () => {
31
+ const attachment: BlueBubblesAttachment = {};
32
+ await expect(
33
+ downloadBlueBubblesAttachment(attachment, {
34
+ serverUrl: "http://localhost:1234",
35
+ password: "test-password",
36
+ }),
37
+ ).rejects.toThrow("guid is required");
38
+ });
39
+
40
+ it("throws when guid is empty string", async () => {
41
+ const attachment: BlueBubblesAttachment = { guid: " " };
42
+ await expect(
43
+ downloadBlueBubblesAttachment(attachment, {
44
+ serverUrl: "http://localhost:1234",
45
+ password: "test-password",
46
+ }),
47
+ ).rejects.toThrow("guid is required");
48
+ });
49
+
50
+ it("throws when serverUrl is missing", async () => {
51
+ const attachment: BlueBubblesAttachment = { guid: "att-123" };
52
+ await expect(downloadBlueBubblesAttachment(attachment, {})).rejects.toThrow(
53
+ "serverUrl is required",
54
+ );
55
+ });
56
+
57
+ it("throws when password is missing", async () => {
58
+ const attachment: BlueBubblesAttachment = { guid: "att-123" };
59
+ await expect(
60
+ downloadBlueBubblesAttachment(attachment, {
61
+ serverUrl: "http://localhost:1234",
62
+ }),
63
+ ).rejects.toThrow("password is required");
64
+ });
65
+
66
+ it("downloads attachment successfully", async () => {
67
+ const mockBuffer = new Uint8Array([1, 2, 3, 4]);
68
+ mockFetch.mockResolvedValueOnce({
69
+ ok: true,
70
+ headers: new Headers({ "content-type": "image/png" }),
71
+ arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
72
+ });
73
+
74
+ const attachment: BlueBubblesAttachment = { guid: "att-123" };
75
+ const result = await downloadBlueBubblesAttachment(attachment, {
76
+ serverUrl: "http://localhost:1234",
77
+ password: "test-password",
78
+ });
79
+
80
+ expect(result.buffer).toEqual(mockBuffer);
81
+ expect(result.contentType).toBe("image/png");
82
+ expect(mockFetch).toHaveBeenCalledWith(
83
+ expect.stringContaining("/api/v1/attachment/att-123/download"),
84
+ expect.objectContaining({ method: "GET" }),
85
+ );
86
+ });
87
+
88
+ it("includes password in URL query", async () => {
89
+ const mockBuffer = new Uint8Array([1, 2, 3, 4]);
90
+ mockFetch.mockResolvedValueOnce({
91
+ ok: true,
92
+ headers: new Headers({ "content-type": "image/jpeg" }),
93
+ arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
94
+ });
95
+
96
+ const attachment: BlueBubblesAttachment = { guid: "att-456" };
97
+ await downloadBlueBubblesAttachment(attachment, {
98
+ serverUrl: "http://localhost:1234",
99
+ password: "my-secret-password",
100
+ });
101
+
102
+ const calledUrl = mockFetch.mock.calls[0][0] as string;
103
+ expect(calledUrl).toContain("password=my-secret-password");
104
+ });
105
+
106
+ it("encodes guid in URL", async () => {
107
+ const mockBuffer = new Uint8Array([1]);
108
+ mockFetch.mockResolvedValueOnce({
109
+ ok: true,
110
+ headers: new Headers(),
111
+ arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
112
+ });
113
+
114
+ const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" };
115
+ await downloadBlueBubblesAttachment(attachment, {
116
+ serverUrl: "http://localhost:1234",
117
+ password: "test",
118
+ });
119
+
120
+ const calledUrl = mockFetch.mock.calls[0][0] as string;
121
+ expect(calledUrl).toContain("att%2Fwith%2Fspecial%20chars");
122
+ });
123
+
124
+ it("throws on non-ok response", async () => {
125
+ mockFetch.mockResolvedValueOnce({
126
+ ok: false,
127
+ status: 404,
128
+ text: () => Promise.resolve("Attachment not found"),
129
+ });
130
+
131
+ const attachment: BlueBubblesAttachment = { guid: "att-missing" };
132
+ await expect(
133
+ downloadBlueBubblesAttachment(attachment, {
134
+ serverUrl: "http://localhost:1234",
135
+ password: "test",
136
+ }),
137
+ ).rejects.toThrow("download failed (404): Attachment not found");
138
+ });
139
+
140
+ it("throws when attachment exceeds max bytes", async () => {
141
+ const largeBuffer = new Uint8Array(10 * 1024 * 1024);
142
+ mockFetch.mockResolvedValueOnce({
143
+ ok: true,
144
+ headers: new Headers(),
145
+ arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
146
+ });
147
+
148
+ const attachment: BlueBubblesAttachment = { guid: "att-large" };
149
+ await expect(
150
+ downloadBlueBubblesAttachment(attachment, {
151
+ serverUrl: "http://localhost:1234",
152
+ password: "test",
153
+ maxBytes: 5 * 1024 * 1024,
154
+ }),
155
+ ).rejects.toThrow("too large");
156
+ });
157
+
158
+ it("uses default max bytes when not specified", async () => {
159
+ const largeBuffer = new Uint8Array(9 * 1024 * 1024);
160
+ mockFetch.mockResolvedValueOnce({
161
+ ok: true,
162
+ headers: new Headers(),
163
+ arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
164
+ });
165
+
166
+ const attachment: BlueBubblesAttachment = { guid: "att-large" };
167
+ await expect(
168
+ downloadBlueBubblesAttachment(attachment, {
169
+ serverUrl: "http://localhost:1234",
170
+ password: "test",
171
+ }),
172
+ ).rejects.toThrow("too large");
173
+ });
174
+
175
+ it("uses attachment mimeType as fallback when response has no content-type", async () => {
176
+ const mockBuffer = new Uint8Array([1, 2, 3]);
177
+ mockFetch.mockResolvedValueOnce({
178
+ ok: true,
179
+ headers: new Headers(),
180
+ arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
181
+ });
182
+
183
+ const attachment: BlueBubblesAttachment = {
184
+ guid: "att-789",
185
+ mimeType: "video/mp4",
186
+ };
187
+ const result = await downloadBlueBubblesAttachment(attachment, {
188
+ serverUrl: "http://localhost:1234",
189
+ password: "test",
190
+ });
191
+
192
+ expect(result.contentType).toBe("video/mp4");
193
+ });
194
+
195
+ it("prefers response content-type over attachment mimeType", async () => {
196
+ const mockBuffer = new Uint8Array([1, 2, 3]);
197
+ mockFetch.mockResolvedValueOnce({
198
+ ok: true,
199
+ headers: new Headers({ "content-type": "image/webp" }),
200
+ arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
201
+ });
202
+
203
+ const attachment: BlueBubblesAttachment = {
204
+ guid: "att-xyz",
205
+ mimeType: "image/png",
206
+ };
207
+ const result = await downloadBlueBubblesAttachment(attachment, {
208
+ serverUrl: "http://localhost:1234",
209
+ password: "test",
210
+ });
211
+
212
+ expect(result.contentType).toBe("image/webp");
213
+ });
214
+
215
+ it("resolves credentials from config when opts not provided", async () => {
216
+ const mockBuffer = new Uint8Array([1]);
217
+ mockFetch.mockResolvedValueOnce({
218
+ ok: true,
219
+ headers: new Headers(),
220
+ arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
221
+ });
222
+
223
+ const attachment: BlueBubblesAttachment = { guid: "att-config" };
224
+ const result = await downloadBlueBubblesAttachment(attachment, {
225
+ cfg: {
226
+ channels: {
227
+ bluebubbles: {
228
+ serverUrl: "http://config-server:5678",
229
+ password: "config-password",
230
+ },
231
+ },
232
+ },
233
+ });
234
+
235
+ const calledUrl = mockFetch.mock.calls[0][0] as string;
236
+ expect(calledUrl).toContain("config-server:5678");
237
+ expect(calledUrl).toContain("password=config-password");
238
+ expect(result.buffer).toEqual(new Uint8Array([1]));
239
+ });
240
+ });
241
+
242
+ describe("sendBlueBubblesAttachment", () => {
243
+ beforeEach(() => {
244
+ vi.stubGlobal("fetch", mockFetch);
245
+ mockFetch.mockReset();
246
+ });
247
+
248
+ afterEach(() => {
249
+ vi.unstubAllGlobals();
250
+ });
251
+
252
+ function decodeBody(body: Uint8Array) {
253
+ return Buffer.from(body).toString("utf8");
254
+ }
255
+
256
+ it("marks voice memos when asVoice is true and mp3 is provided", async () => {
257
+ mockFetch.mockResolvedValueOnce({
258
+ ok: true,
259
+ text: () => Promise.resolve(JSON.stringify({ messageId: "msg-1" })),
260
+ });
261
+
262
+ await sendBlueBubblesAttachment({
263
+ to: "chat_guid:iMessage;-;+15551234567",
264
+ buffer: new Uint8Array([1, 2, 3]),
265
+ filename: "voice.mp3",
266
+ contentType: "audio/mpeg",
267
+ asVoice: true,
268
+ opts: { serverUrl: "http://localhost:1234", password: "test" },
269
+ });
270
+
271
+ const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
272
+ const bodyText = decodeBody(body);
273
+ expect(bodyText).toContain('name="isAudioMessage"');
274
+ expect(bodyText).toContain("true");
275
+ expect(bodyText).toContain('filename="voice.mp3"');
276
+ });
277
+
278
+ it("normalizes mp3 filenames for voice memos", async () => {
279
+ mockFetch.mockResolvedValueOnce({
280
+ ok: true,
281
+ text: () => Promise.resolve(JSON.stringify({ messageId: "msg-2" })),
282
+ });
283
+
284
+ await sendBlueBubblesAttachment({
285
+ to: "chat_guid:iMessage;-;+15551234567",
286
+ buffer: new Uint8Array([1, 2, 3]),
287
+ filename: "voice",
288
+ contentType: "audio/mpeg",
289
+ asVoice: true,
290
+ opts: { serverUrl: "http://localhost:1234", password: "test" },
291
+ });
292
+
293
+ const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
294
+ const bodyText = decodeBody(body);
295
+ expect(bodyText).toContain('filename="voice.mp3"');
296
+ expect(bodyText).toContain('name="voice.mp3"');
297
+ });
298
+
299
+ it("throws when asVoice is true but media is not audio", async () => {
300
+ await expect(
301
+ sendBlueBubblesAttachment({
302
+ to: "chat_guid:iMessage;-;+15551234567",
303
+ buffer: new Uint8Array([1, 2, 3]),
304
+ filename: "image.png",
305
+ contentType: "image/png",
306
+ asVoice: true,
307
+ opts: { serverUrl: "http://localhost:1234", password: "test" },
308
+ }),
309
+ ).rejects.toThrow("voice messages require audio");
310
+ expect(mockFetch).not.toHaveBeenCalled();
311
+ });
312
+
313
+ it("throws when asVoice is true but audio is not mp3 or caf", async () => {
314
+ await expect(
315
+ sendBlueBubblesAttachment({
316
+ to: "chat_guid:iMessage;-;+15551234567",
317
+ buffer: new Uint8Array([1, 2, 3]),
318
+ filename: "voice.wav",
319
+ contentType: "audio/wav",
320
+ asVoice: true,
321
+ opts: { serverUrl: "http://localhost:1234", password: "test" },
322
+ }),
323
+ ).rejects.toThrow("require mp3 or caf");
324
+ expect(mockFetch).not.toHaveBeenCalled();
325
+ });
326
+
327
+ it("sanitizes filenames before sending", async () => {
328
+ mockFetch.mockResolvedValueOnce({
329
+ ok: true,
330
+ text: () => Promise.resolve(JSON.stringify({ messageId: "msg-3" })),
331
+ });
332
+
333
+ await sendBlueBubblesAttachment({
334
+ to: "chat_guid:iMessage;-;+15551234567",
335
+ buffer: new Uint8Array([1, 2, 3]),
336
+ filename: "../evil.mp3",
337
+ contentType: "audio/mpeg",
338
+ opts: { serverUrl: "http://localhost:1234", password: "test" },
339
+ });
340
+
341
+ const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
342
+ const bodyText = decodeBody(body);
343
+ expect(bodyText).toContain('filename="evil.mp3"');
344
+ expect(bodyText).toContain('name="evil.mp3"');
345
+ });
346
+ });
@@ -0,0 +1,282 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
+ import { resolveBlueBubblesAccount } from "./accounts.js";
5
+ import { resolveChatGuidForTarget } from "./send.js";
6
+ import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
7
+ import {
8
+ blueBubblesFetchWithTimeout,
9
+ buildBlueBubblesApiUrl,
10
+ type BlueBubblesAttachment,
11
+ type BlueBubblesSendTarget,
12
+ } from "./types.js";
13
+
14
+ export type BlueBubblesAttachmentOpts = {
15
+ serverUrl?: string;
16
+ password?: string;
17
+ accountId?: string;
18
+ timeoutMs?: number;
19
+ cfg?: OpenClawConfig;
20
+ };
21
+
22
+ const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
23
+ const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]);
24
+ const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
25
+
26
+ function sanitizeFilename(input: string | undefined, fallback: string): string {
27
+ const trimmed = input?.trim() ?? "";
28
+ const base = trimmed ? path.basename(trimmed) : "";
29
+ return base || fallback;
30
+ }
31
+
32
+ function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
33
+ const currentExt = path.extname(filename);
34
+ if (currentExt.toLowerCase() === extension) return filename;
35
+ const base = currentExt ? filename.slice(0, -currentExt.length) : filename;
36
+ return `${base || fallbackBase}${extension}`;
37
+ }
38
+
39
+ function resolveVoiceInfo(filename: string, contentType?: string) {
40
+ const normalizedType = contentType?.trim().toLowerCase();
41
+ const extension = path.extname(filename).toLowerCase();
42
+ const isMp3 = extension === ".mp3" || (normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false);
43
+ const isCaf = extension === ".caf" || (normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false);
44
+ const isAudio = isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/"));
45
+ return { isAudio, isMp3, isCaf };
46
+ }
47
+
48
+ function resolveAccount(params: BlueBubblesAttachmentOpts) {
49
+ const account = resolveBlueBubblesAccount({
50
+ cfg: params.cfg ?? {},
51
+ accountId: params.accountId,
52
+ });
53
+ const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
54
+ const password = params.password?.trim() || account.config.password?.trim();
55
+ if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
56
+ if (!password) throw new Error("BlueBubbles password is required");
57
+ return { baseUrl, password };
58
+ }
59
+
60
+ export async function downloadBlueBubblesAttachment(
61
+ attachment: BlueBubblesAttachment,
62
+ opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
63
+ ): Promise<{ buffer: Uint8Array; contentType?: string }> {
64
+ const guid = attachment.guid?.trim();
65
+ if (!guid) throw new Error("BlueBubbles attachment guid is required");
66
+ const { baseUrl, password } = resolveAccount(opts);
67
+ const url = buildBlueBubblesApiUrl({
68
+ baseUrl,
69
+ path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
70
+ password,
71
+ });
72
+ const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs);
73
+ if (!res.ok) {
74
+ const errorText = await res.text().catch(() => "");
75
+ throw new Error(
76
+ `BlueBubbles attachment download failed (${res.status}): ${errorText || "unknown"}`,
77
+ );
78
+ }
79
+ const contentType = res.headers.get("content-type") ?? undefined;
80
+ const buf = new Uint8Array(await res.arrayBuffer());
81
+ const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
82
+ if (buf.byteLength > maxBytes) {
83
+ throw new Error(`BlueBubbles attachment too large (${buf.byteLength} bytes)`);
84
+ }
85
+ return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
86
+ }
87
+
88
+ export type SendBlueBubblesAttachmentResult = {
89
+ messageId: string;
90
+ };
91
+
92
+ function resolveSendTarget(raw: string): BlueBubblesSendTarget {
93
+ const parsed = parseBlueBubblesTarget(raw);
94
+ if (parsed.kind === "handle") {
95
+ return {
96
+ kind: "handle",
97
+ address: normalizeBlueBubblesHandle(parsed.to),
98
+ service: parsed.service,
99
+ };
100
+ }
101
+ if (parsed.kind === "chat_id") {
102
+ return { kind: "chat_id", chatId: parsed.chatId };
103
+ }
104
+ if (parsed.kind === "chat_guid") {
105
+ return { kind: "chat_guid", chatGuid: parsed.chatGuid };
106
+ }
107
+ return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
108
+ }
109
+
110
+ function extractMessageId(payload: unknown): string {
111
+ if (!payload || typeof payload !== "object") return "unknown";
112
+ const record = payload as Record<string, unknown>;
113
+ const data = record.data && typeof record.data === "object" ? (record.data as Record<string, unknown>) : null;
114
+ const candidates = [
115
+ record.messageId,
116
+ record.guid,
117
+ record.id,
118
+ data?.messageId,
119
+ data?.guid,
120
+ data?.id,
121
+ ];
122
+ for (const candidate of candidates) {
123
+ if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
124
+ if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate);
125
+ }
126
+ return "unknown";
127
+ }
128
+
129
+ /**
130
+ * Send an attachment via BlueBubbles API.
131
+ * Supports sending media files (images, videos, audio, documents) to a chat.
132
+ * When asVoice is true, expects MP3/CAF audio and marks it as an iMessage voice memo.
133
+ */
134
+ export async function sendBlueBubblesAttachment(params: {
135
+ to: string;
136
+ buffer: Uint8Array;
137
+ filename: string;
138
+ contentType?: string;
139
+ caption?: string;
140
+ replyToMessageGuid?: string;
141
+ replyToPartIndex?: number;
142
+ asVoice?: boolean;
143
+ opts?: BlueBubblesAttachmentOpts;
144
+ }): Promise<SendBlueBubblesAttachmentResult> {
145
+ const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params;
146
+ let { buffer, filename, contentType } = params;
147
+ const wantsVoice = asVoice === true;
148
+ const fallbackName = wantsVoice ? "Audio Message" : "attachment";
149
+ filename = sanitizeFilename(filename, fallbackName);
150
+ contentType = contentType?.trim() || undefined;
151
+ const { baseUrl, password } = resolveAccount(opts);
152
+
153
+ // Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
154
+ const isAudioMessage = wantsVoice;
155
+ if (isAudioMessage) {
156
+ const voiceInfo = resolveVoiceInfo(filename, contentType);
157
+ if (!voiceInfo.isAudio) {
158
+ throw new Error("BlueBubbles voice messages require audio media (mp3 or caf).");
159
+ }
160
+ if (voiceInfo.isMp3) {
161
+ filename = ensureExtension(filename, ".mp3", fallbackName);
162
+ contentType = contentType ?? "audio/mpeg";
163
+ } else if (voiceInfo.isCaf) {
164
+ filename = ensureExtension(filename, ".caf", fallbackName);
165
+ contentType = contentType ?? "audio/x-caf";
166
+ } else {
167
+ throw new Error(
168
+ "BlueBubbles voice messages require mp3 or caf audio (convert before sending).",
169
+ );
170
+ }
171
+ }
172
+
173
+ const target = resolveSendTarget(to);
174
+ const chatGuid = await resolveChatGuidForTarget({
175
+ baseUrl,
176
+ password,
177
+ timeoutMs: opts.timeoutMs,
178
+ target,
179
+ });
180
+ if (!chatGuid) {
181
+ throw new Error(
182
+ "BlueBubbles attachment send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
183
+ );
184
+ }
185
+
186
+ const url = buildBlueBubblesApiUrl({
187
+ baseUrl,
188
+ path: "/api/v1/message/attachment",
189
+ password,
190
+ });
191
+
192
+ // Build FormData with the attachment
193
+ const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
194
+ const parts: Uint8Array[] = [];
195
+ const encoder = new TextEncoder();
196
+
197
+ // Helper to add a form field
198
+ const addField = (name: string, value: string) => {
199
+ parts.push(encoder.encode(`--${boundary}\r\n`));
200
+ parts.push(encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`));
201
+ parts.push(encoder.encode(`${value}\r\n`));
202
+ };
203
+
204
+ // Helper to add a file field
205
+ const addFile = (name: string, fileBuffer: Uint8Array, fileName: string, mimeType?: string) => {
206
+ parts.push(encoder.encode(`--${boundary}\r\n`));
207
+ parts.push(
208
+ encoder.encode(
209
+ `Content-Disposition: form-data; name="${name}"; filename="${fileName}"\r\n`,
210
+ ),
211
+ );
212
+ parts.push(encoder.encode(`Content-Type: ${mimeType ?? "application/octet-stream"}\r\n\r\n`));
213
+ parts.push(fileBuffer);
214
+ parts.push(encoder.encode("\r\n"));
215
+ };
216
+
217
+ // Add required fields
218
+ addFile("attachment", buffer, filename, contentType);
219
+ addField("chatGuid", chatGuid);
220
+ addField("name", filename);
221
+ addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
222
+ addField("method", "private-api");
223
+
224
+ // Add isAudioMessage flag for voice memos
225
+ if (isAudioMessage) {
226
+ addField("isAudioMessage", "true");
227
+ }
228
+
229
+ const trimmedReplyTo = replyToMessageGuid?.trim();
230
+ if (trimmedReplyTo) {
231
+ addField("selectedMessageGuid", trimmedReplyTo);
232
+ addField(
233
+ "partIndex",
234
+ typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0",
235
+ );
236
+ }
237
+
238
+ // Add optional caption
239
+ if (caption) {
240
+ addField("message", caption);
241
+ addField("text", caption);
242
+ addField("caption", caption);
243
+ }
244
+
245
+ // Close the multipart body
246
+ parts.push(encoder.encode(`--${boundary}--\r\n`));
247
+
248
+ // Combine all parts into a single buffer
249
+ const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
250
+ const body = new Uint8Array(totalLength);
251
+ let offset = 0;
252
+ for (const part of parts) {
253
+ body.set(part, offset);
254
+ offset += part.length;
255
+ }
256
+
257
+ const res = await blueBubblesFetchWithTimeout(
258
+ url,
259
+ {
260
+ method: "POST",
261
+ headers: {
262
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
263
+ },
264
+ body,
265
+ },
266
+ opts.timeoutMs ?? 60_000, // longer timeout for file uploads
267
+ );
268
+
269
+ if (!res.ok) {
270
+ const errorText = await res.text();
271
+ throw new Error(`BlueBubbles attachment send failed (${res.status}): ${errorText || "unknown"}`);
272
+ }
273
+
274
+ const responseBody = await res.text();
275
+ if (!responseBody) return { messageId: "ok" };
276
+ try {
277
+ const parsed = JSON.parse(responseBody) as unknown;
278
+ return { messageId: extractMessageId(parsed) };
279
+ } catch {
280
+ return { messageId: "ok" };
281
+ }
282
+ }