@openclaw/bluebubbles 2026.2.21 → 2026.2.22
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/actions.test.ts +4 -11
- package/src/attachments.test.ts +91 -4
- package/src/attachments.ts +47 -15
- package/src/chat.test.ts +193 -1
- package/src/chat.ts +74 -124
- 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.test.ts +396 -4
- package/src/probe.ts +8 -0
- package/src/reactions.test.ts +4 -11
- package/src/request-url.ts +12 -0
- package/src/runtime.ts +20 -0
- package/src/send.test.ts +53 -3
- 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/package.json
CHANGED
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),
|
package/src/attachments.test.ts
CHANGED
|
@@ -1,18 +1,69 @@
|
|
|
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
|
+
|
|
16
67
|
it("throws when guid is missing", async () => {
|
|
17
68
|
const attachment: BlueBubblesAttachment = {};
|
|
18
69
|
await expect(
|
|
@@ -120,7 +171,7 @@ describe("downloadBlueBubblesAttachment", () => {
|
|
|
120
171
|
serverUrl: "http://localhost:1234",
|
|
121
172
|
password: "test",
|
|
122
173
|
}),
|
|
123
|
-
).rejects.toThrow("
|
|
174
|
+
).rejects.toThrow("Attachment not found");
|
|
124
175
|
});
|
|
125
176
|
|
|
126
177
|
it("throws when attachment exceeds max bytes", async () => {
|
|
@@ -229,8 +280,13 @@ describe("sendBlueBubblesAttachment", () => {
|
|
|
229
280
|
beforeEach(() => {
|
|
230
281
|
vi.stubGlobal("fetch", mockFetch);
|
|
231
282
|
mockFetch.mockReset();
|
|
283
|
+
fetchRemoteMediaMock.mockClear();
|
|
284
|
+
setBlueBubblesRuntime(runtimeStub);
|
|
232
285
|
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
|
233
|
-
|
|
286
|
+
mockBlueBubblesPrivateApiStatus(
|
|
287
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus),
|
|
288
|
+
BLUE_BUBBLES_PRIVATE_API_STATUS.unknown,
|
|
289
|
+
);
|
|
234
290
|
});
|
|
235
291
|
|
|
236
292
|
afterEach(() => {
|
|
@@ -333,7 +389,10 @@ describe("sendBlueBubblesAttachment", () => {
|
|
|
333
389
|
});
|
|
334
390
|
|
|
335
391
|
it("downgrades attachment reply threading when private API is disabled", async () => {
|
|
336
|
-
|
|
392
|
+
mockBlueBubblesPrivateApiStatusOnce(
|
|
393
|
+
vi.mocked(getCachedBlueBubblesPrivateApiStatus),
|
|
394
|
+
BLUE_BUBBLES_PRIVATE_API_STATUS.disabled,
|
|
395
|
+
);
|
|
337
396
|
mockFetch.mockResolvedValueOnce({
|
|
338
397
|
ok: true,
|
|
339
398
|
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })),
|
|
@@ -354,4 +413,32 @@ describe("sendBlueBubblesAttachment", () => {
|
|
|
354
413
|
expect(bodyText).not.toContain('name="selectedMessageGuid"');
|
|
355
414
|
expect(bodyText).not.toContain('name="partIndex"');
|
|
356
415
|
});
|
|
416
|
+
|
|
417
|
+
it("warns and downgrades attachment reply threading when private API status is unknown", async () => {
|
|
418
|
+
const runtimeLog = vi.fn();
|
|
419
|
+
setBlueBubblesRuntime({
|
|
420
|
+
...runtimeStub,
|
|
421
|
+
log: runtimeLog,
|
|
422
|
+
} as unknown as PluginRuntime);
|
|
423
|
+
mockFetch.mockResolvedValueOnce({
|
|
424
|
+
ok: true,
|
|
425
|
+
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-5" })),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
await sendBlueBubblesAttachment({
|
|
429
|
+
to: "chat_guid:iMessage;-;+15551234567",
|
|
430
|
+
buffer: new Uint8Array([1, 2, 3]),
|
|
431
|
+
filename: "photo.jpg",
|
|
432
|
+
contentType: "image/jpeg",
|
|
433
|
+
replyToMessageGuid: "reply-guid-unknown",
|
|
434
|
+
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
expect(runtimeLog).toHaveBeenCalledTimes(1);
|
|
438
|
+
expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
|
|
439
|
+
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
|
440
|
+
const bodyText = decodeBody(body);
|
|
441
|
+
expect(bodyText).not.toContain('name="selectedMessageGuid"');
|
|
442
|
+
expect(bodyText).not.toContain('name="partIndex"');
|
|
443
|
+
});
|
|
357
444
|
});
|
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 } = {},
|
|
@@ -71,20 +88,30 @@ export async function downloadBlueBubblesAttachment(
|
|
|
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
|
+
fetchImpl: async (input, init) =>
|
|
98
|
+
await blueBubblesFetchWithTimeout(
|
|
99
|
+
resolveRequestUrl(input),
|
|
100
|
+
{ ...init, method: init?.method ?? "GET" },
|
|
101
|
+
opts.timeoutMs,
|
|
102
|
+
),
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
buffer: new Uint8Array(fetched.buffer),
|
|
106
|
+
contentType: fetched.contentType ?? attachment.mimeType ?? undefined,
|
|
107
|
+
};
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (readMediaFetchErrorCode(error) === "max_bytes") {
|
|
110
|
+
throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`);
|
|
111
|
+
}
|
|
112
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
113
|
+
throw new Error(`BlueBubbles attachment download failed: ${text}`);
|
|
86
114
|
}
|
|
87
|
-
return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
|
|
88
115
|
}
|
|
89
116
|
|
|
90
117
|
export type SendBlueBubblesAttachmentResult = {
|
|
@@ -115,6 +142,7 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
115
142
|
contentType = contentType?.trim() || undefined;
|
|
116
143
|
const { baseUrl, password, accountId } = resolveAccount(opts);
|
|
117
144
|
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
|
|
145
|
+
const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
|
|
118
146
|
|
|
119
147
|
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
|
|
120
148
|
const isAudioMessage = wantsVoice;
|
|
@@ -183,7 +211,7 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
183
211
|
addField("chatGuid", chatGuid);
|
|
184
212
|
addField("name", filename);
|
|
185
213
|
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
|
186
|
-
if (
|
|
214
|
+
if (privateApiEnabled) {
|
|
187
215
|
addField("method", "private-api");
|
|
188
216
|
}
|
|
189
217
|
|
|
@@ -193,9 +221,13 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
193
221
|
}
|
|
194
222
|
|
|
195
223
|
const trimmedReplyTo = replyToMessageGuid?.trim();
|
|
196
|
-
if (trimmedReplyTo &&
|
|
224
|
+
if (trimmedReplyTo && privateApiEnabled) {
|
|
197
225
|
addField("selectedMessageGuid", trimmedReplyTo);
|
|
198
226
|
addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
|
|
227
|
+
} else if (trimmedReplyTo && privateApiStatus === null) {
|
|
228
|
+
warnBlueBubbles(
|
|
229
|
+
"Private API status unknown; sending attachment without reply threading metadata. Run a status probe to restore private-api reply features.",
|
|
230
|
+
);
|
|
199
231
|
}
|
|
200
232
|
|
|
201
233
|
// Add optional caption
|
package/src/chat.test.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import "./test-mocks.js";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
addBlueBubblesParticipant,
|
|
5
|
+
editBlueBubblesMessage,
|
|
6
|
+
leaveBlueBubblesChat,
|
|
7
|
+
markBlueBubblesChatRead,
|
|
8
|
+
removeBlueBubblesParticipant,
|
|
9
|
+
renameBlueBubblesChat,
|
|
10
|
+
sendBlueBubblesTyping,
|
|
11
|
+
setGroupIconBlueBubbles,
|
|
12
|
+
unsendBlueBubblesMessage,
|
|
13
|
+
} from "./chat.js";
|
|
4
14
|
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
5
15
|
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
|
|
6
16
|
|
|
@@ -278,6 +288,188 @@ describe("chat", () => {
|
|
|
278
288
|
});
|
|
279
289
|
});
|
|
280
290
|
|
|
291
|
+
describe("editBlueBubblesMessage", () => {
|
|
292
|
+
it("throws when required args are missing", async () => {
|
|
293
|
+
await expect(editBlueBubblesMessage("", "updated", {})).rejects.toThrow("messageGuid");
|
|
294
|
+
await expect(editBlueBubblesMessage("message-guid", " ", {})).rejects.toThrow("newText");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("sends edit request with default payload values", async () => {
|
|
298
|
+
mockFetch.mockResolvedValueOnce({
|
|
299
|
+
ok: true,
|
|
300
|
+
text: () => Promise.resolve(""),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await editBlueBubblesMessage(" message-guid ", " updated text ", {
|
|
304
|
+
serverUrl: "http://localhost:1234",
|
|
305
|
+
password: "test-password",
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
309
|
+
expect.stringContaining("/api/v1/message/message-guid/edit"),
|
|
310
|
+
expect.objectContaining({
|
|
311
|
+
method: "POST",
|
|
312
|
+
headers: { "Content-Type": "application/json" },
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
316
|
+
expect(body).toEqual({
|
|
317
|
+
editedMessage: "updated text",
|
|
318
|
+
backwardsCompatibilityMessage: "Edited to: updated text",
|
|
319
|
+
partIndex: 0,
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("supports custom part index and backwards compatibility message", async () => {
|
|
324
|
+
mockFetch.mockResolvedValueOnce({
|
|
325
|
+
ok: true,
|
|
326
|
+
text: () => Promise.resolve(""),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
await editBlueBubblesMessage("message-guid", "new text", {
|
|
330
|
+
serverUrl: "http://localhost:1234",
|
|
331
|
+
password: "test-password",
|
|
332
|
+
partIndex: 3,
|
|
333
|
+
backwardsCompatMessage: "custom-backwards-message",
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
337
|
+
expect(body.partIndex).toBe(3);
|
|
338
|
+
expect(body.backwardsCompatibilityMessage).toBe("custom-backwards-message");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("throws on non-ok response", async () => {
|
|
342
|
+
mockFetch.mockResolvedValueOnce({
|
|
343
|
+
ok: false,
|
|
344
|
+
status: 422,
|
|
345
|
+
text: () => Promise.resolve("Unprocessable"),
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await expect(
|
|
349
|
+
editBlueBubblesMessage("message-guid", "new text", {
|
|
350
|
+
serverUrl: "http://localhost:1234",
|
|
351
|
+
password: "test-password",
|
|
352
|
+
}),
|
|
353
|
+
).rejects.toThrow("edit failed (422): Unprocessable");
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("unsendBlueBubblesMessage", () => {
|
|
358
|
+
it("throws when messageGuid is missing", async () => {
|
|
359
|
+
await expect(unsendBlueBubblesMessage("", {})).rejects.toThrow("messageGuid");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("sends unsend request with default part index", async () => {
|
|
363
|
+
mockFetch.mockResolvedValueOnce({
|
|
364
|
+
ok: true,
|
|
365
|
+
text: () => Promise.resolve(""),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
await unsendBlueBubblesMessage(" msg-123 ", {
|
|
369
|
+
serverUrl: "http://localhost:1234",
|
|
370
|
+
password: "test-password",
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
374
|
+
expect.stringContaining("/api/v1/message/msg-123/unsend"),
|
|
375
|
+
expect.objectContaining({
|
|
376
|
+
method: "POST",
|
|
377
|
+
headers: { "Content-Type": "application/json" },
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
381
|
+
expect(body.partIndex).toBe(0);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("uses custom part index", async () => {
|
|
385
|
+
mockFetch.mockResolvedValueOnce({
|
|
386
|
+
ok: true,
|
|
387
|
+
text: () => Promise.resolve(""),
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
await unsendBlueBubblesMessage("msg-123", {
|
|
391
|
+
serverUrl: "http://localhost:1234",
|
|
392
|
+
password: "test-password",
|
|
393
|
+
partIndex: 2,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
397
|
+
expect(body.partIndex).toBe(2);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("group chat mutation actions", () => {
|
|
402
|
+
it("renames chat", async () => {
|
|
403
|
+
mockFetch.mockResolvedValueOnce({
|
|
404
|
+
ok: true,
|
|
405
|
+
text: () => Promise.resolve(""),
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
await renameBlueBubblesChat(" chat-guid ", "New Group Name", {
|
|
409
|
+
serverUrl: "http://localhost:1234",
|
|
410
|
+
password: "test-password",
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
414
|
+
expect.stringContaining("/api/v1/chat/chat-guid"),
|
|
415
|
+
expect.objectContaining({ method: "PUT" }),
|
|
416
|
+
);
|
|
417
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
418
|
+
expect(body.displayName).toBe("New Group Name");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("adds and removes participant using matching endpoint", async () => {
|
|
422
|
+
mockFetch
|
|
423
|
+
.mockResolvedValueOnce({
|
|
424
|
+
ok: true,
|
|
425
|
+
text: () => Promise.resolve(""),
|
|
426
|
+
})
|
|
427
|
+
.mockResolvedValueOnce({
|
|
428
|
+
ok: true,
|
|
429
|
+
text: () => Promise.resolve(""),
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
await addBlueBubblesParticipant("chat-guid", "+15551234567", {
|
|
433
|
+
serverUrl: "http://localhost:1234",
|
|
434
|
+
password: "test-password",
|
|
435
|
+
});
|
|
436
|
+
await removeBlueBubblesParticipant("chat-guid", "+15551234567", {
|
|
437
|
+
serverUrl: "http://localhost:1234",
|
|
438
|
+
password: "test-password",
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
442
|
+
expect(mockFetch.mock.calls[0][0]).toContain("/api/v1/chat/chat-guid/participant");
|
|
443
|
+
expect(mockFetch.mock.calls[0][1].method).toBe("POST");
|
|
444
|
+
expect(mockFetch.mock.calls[1][0]).toContain("/api/v1/chat/chat-guid/participant");
|
|
445
|
+
expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
|
|
446
|
+
|
|
447
|
+
const addBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
448
|
+
const removeBody = JSON.parse(mockFetch.mock.calls[1][1].body);
|
|
449
|
+
expect(addBody.address).toBe("+15551234567");
|
|
450
|
+
expect(removeBody.address).toBe("+15551234567");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("leaves chat without JSON body", async () => {
|
|
454
|
+
mockFetch.mockResolvedValueOnce({
|
|
455
|
+
ok: true,
|
|
456
|
+
text: () => Promise.resolve(""),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
await leaveBlueBubblesChat("chat-guid", {
|
|
460
|
+
serverUrl: "http://localhost:1234",
|
|
461
|
+
password: "test-password",
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
465
|
+
expect.stringContaining("/api/v1/chat/chat-guid/leave"),
|
|
466
|
+
expect.objectContaining({ method: "POST" }),
|
|
467
|
+
);
|
|
468
|
+
expect(mockFetch.mock.calls[0][1].body).toBeUndefined();
|
|
469
|
+
expect(mockFetch.mock.calls[0][1].headers).toBeUndefined();
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
281
473
|
describe("setGroupIconBlueBubbles", () => {
|
|
282
474
|
it("throws when chatGuid is empty", async () => {
|
|
283
475
|
await expect(
|