@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.
- package/index.ts +20 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +33 -0
- package/src/accounts.ts +80 -0
- package/src/actions.test.ts +651 -0
- package/src/actions.ts +403 -0
- package/src/attachments.test.ts +346 -0
- package/src/attachments.ts +282 -0
- package/src/channel.ts +399 -0
- package/src/chat.test.ts +462 -0
- package/src/chat.ts +354 -0
- package/src/config-schema.ts +51 -0
- package/src/media-send.ts +168 -0
- package/src/monitor.test.ts +2146 -0
- package/src/monitor.ts +2276 -0
- package/src/onboarding.ts +340 -0
- package/src/probe.ts +127 -0
- package/src/reactions.test.ts +393 -0
- package/src/reactions.ts +183 -0
- package/src/runtime.ts +14 -0
- package/src/send.test.ts +809 -0
- package/src/send.ts +418 -0
- package/src/targets.test.ts +184 -0
- package/src/targets.ts +323 -0
- package/src/types.ts +127 -0
package/src/send.test.ts
ADDED
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
|
|
4
|
+
import type { BlueBubblesSendTarget } 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("send", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
23
|
+
mockFetch.mockReset();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.unstubAllGlobals();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("resolveChatGuidForTarget", () => {
|
|
31
|
+
it("returns chatGuid directly for chat_guid target", async () => {
|
|
32
|
+
const target: BlueBubblesSendTarget = {
|
|
33
|
+
kind: "chat_guid",
|
|
34
|
+
chatGuid: "iMessage;-;+15551234567",
|
|
35
|
+
};
|
|
36
|
+
const result = await resolveChatGuidForTarget({
|
|
37
|
+
baseUrl: "http://localhost:1234",
|
|
38
|
+
password: "test",
|
|
39
|
+
target,
|
|
40
|
+
});
|
|
41
|
+
expect(result).toBe("iMessage;-;+15551234567");
|
|
42
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("queries chats to resolve chat_id target", async () => {
|
|
46
|
+
mockFetch.mockResolvedValueOnce({
|
|
47
|
+
ok: true,
|
|
48
|
+
json: () =>
|
|
49
|
+
Promise.resolve({
|
|
50
|
+
data: [
|
|
51
|
+
{ id: 123, guid: "iMessage;-;chat123", participants: [] },
|
|
52
|
+
{ id: 456, guid: "iMessage;-;chat456", participants: [] },
|
|
53
|
+
],
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 456 };
|
|
58
|
+
const result = await resolveChatGuidForTarget({
|
|
59
|
+
baseUrl: "http://localhost:1234",
|
|
60
|
+
password: "test",
|
|
61
|
+
target,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(result).toBe("iMessage;-;chat456");
|
|
65
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
66
|
+
expect.stringContaining("/api/v1/chat/query"),
|
|
67
|
+
expect.objectContaining({ method: "POST" }),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("queries chats to resolve chat_identifier target", async () => {
|
|
72
|
+
mockFetch.mockResolvedValueOnce({
|
|
73
|
+
ok: true,
|
|
74
|
+
json: () =>
|
|
75
|
+
Promise.resolve({
|
|
76
|
+
data: [
|
|
77
|
+
{
|
|
78
|
+
identifier: "chat123@group.imessage",
|
|
79
|
+
guid: "iMessage;-;chat123",
|
|
80
|
+
participants: [],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const target: BlueBubblesSendTarget = {
|
|
87
|
+
kind: "chat_identifier",
|
|
88
|
+
chatIdentifier: "chat123@group.imessage",
|
|
89
|
+
};
|
|
90
|
+
const result = await resolveChatGuidForTarget({
|
|
91
|
+
baseUrl: "http://localhost:1234",
|
|
92
|
+
password: "test",
|
|
93
|
+
target,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(result).toBe("iMessage;-;chat123");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("matches chat_identifier against the 3rd component of chat GUID", async () => {
|
|
100
|
+
mockFetch.mockResolvedValueOnce({
|
|
101
|
+
ok: true,
|
|
102
|
+
json: () =>
|
|
103
|
+
Promise.resolve({
|
|
104
|
+
data: [
|
|
105
|
+
{
|
|
106
|
+
guid: "iMessage;+;chat660250192681427962",
|
|
107
|
+
participants: [],
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const target: BlueBubblesSendTarget = {
|
|
114
|
+
kind: "chat_identifier",
|
|
115
|
+
chatIdentifier: "chat660250192681427962",
|
|
116
|
+
};
|
|
117
|
+
const result = await resolveChatGuidForTarget({
|
|
118
|
+
baseUrl: "http://localhost:1234",
|
|
119
|
+
password: "test",
|
|
120
|
+
target,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result).toBe("iMessage;+;chat660250192681427962");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("resolves handle target by matching participant", async () => {
|
|
127
|
+
mockFetch.mockResolvedValueOnce({
|
|
128
|
+
ok: true,
|
|
129
|
+
json: () =>
|
|
130
|
+
Promise.resolve({
|
|
131
|
+
data: [
|
|
132
|
+
{
|
|
133
|
+
guid: "iMessage;-;+15559999999",
|
|
134
|
+
participants: [{ address: "+15559999999" }],
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
guid: "iMessage;-;+15551234567",
|
|
138
|
+
participants: [{ address: "+15551234567" }],
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const target: BlueBubblesSendTarget = {
|
|
145
|
+
kind: "handle",
|
|
146
|
+
address: "+15551234567",
|
|
147
|
+
service: "imessage",
|
|
148
|
+
};
|
|
149
|
+
const result = await resolveChatGuidForTarget({
|
|
150
|
+
baseUrl: "http://localhost:1234",
|
|
151
|
+
password: "test",
|
|
152
|
+
target,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(result).toBe("iMessage;-;+15551234567");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("prefers direct chat guid when handle also appears in a group chat", async () => {
|
|
159
|
+
mockFetch.mockResolvedValueOnce({
|
|
160
|
+
ok: true,
|
|
161
|
+
json: () =>
|
|
162
|
+
Promise.resolve({
|
|
163
|
+
data: [
|
|
164
|
+
{
|
|
165
|
+
guid: "iMessage;+;group-123",
|
|
166
|
+
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
guid: "iMessage;-;+15551234567",
|
|
170
|
+
participants: [{ address: "+15551234567" }],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const target: BlueBubblesSendTarget = {
|
|
177
|
+
kind: "handle",
|
|
178
|
+
address: "+15551234567",
|
|
179
|
+
service: "imessage",
|
|
180
|
+
};
|
|
181
|
+
const result = await resolveChatGuidForTarget({
|
|
182
|
+
baseUrl: "http://localhost:1234",
|
|
183
|
+
password: "test",
|
|
184
|
+
target,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(result).toBe("iMessage;-;+15551234567");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("returns null when handle only exists in group chat (not DM)", async () => {
|
|
191
|
+
// This is the critical fix: if a phone number only exists as a participant in a group chat
|
|
192
|
+
// (no direct DM chat), we should NOT send to that group. Return null instead.
|
|
193
|
+
mockFetch
|
|
194
|
+
.mockResolvedValueOnce({
|
|
195
|
+
ok: true,
|
|
196
|
+
json: () =>
|
|
197
|
+
Promise.resolve({
|
|
198
|
+
data: [
|
|
199
|
+
{
|
|
200
|
+
guid: "iMessage;+;group-the-council",
|
|
201
|
+
participants: [
|
|
202
|
+
{ address: "+12622102921" },
|
|
203
|
+
{ address: "+15550001111" },
|
|
204
|
+
{ address: "+15550002222" },
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
}),
|
|
209
|
+
})
|
|
210
|
+
// Empty second page to stop pagination
|
|
211
|
+
.mockResolvedValueOnce({
|
|
212
|
+
ok: true,
|
|
213
|
+
json: () => Promise.resolve({ data: [] }),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const target: BlueBubblesSendTarget = {
|
|
217
|
+
kind: "handle",
|
|
218
|
+
address: "+12622102921",
|
|
219
|
+
service: "imessage",
|
|
220
|
+
};
|
|
221
|
+
const result = await resolveChatGuidForTarget({
|
|
222
|
+
baseUrl: "http://localhost:1234",
|
|
223
|
+
password: "test",
|
|
224
|
+
target,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Should return null, NOT the group chat GUID
|
|
228
|
+
expect(result).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("returns null when chat not found", async () => {
|
|
232
|
+
mockFetch.mockResolvedValueOnce({
|
|
233
|
+
ok: true,
|
|
234
|
+
json: () => Promise.resolve({ data: [] }),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 999 };
|
|
238
|
+
const result = await resolveChatGuidForTarget({
|
|
239
|
+
baseUrl: "http://localhost:1234",
|
|
240
|
+
password: "test",
|
|
241
|
+
target,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(result).toBeNull();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("handles API error gracefully", async () => {
|
|
248
|
+
mockFetch.mockResolvedValueOnce({
|
|
249
|
+
ok: false,
|
|
250
|
+
status: 500,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 123 };
|
|
254
|
+
const result = await resolveChatGuidForTarget({
|
|
255
|
+
baseUrl: "http://localhost:1234",
|
|
256
|
+
password: "test",
|
|
257
|
+
target,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(result).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("paginates through chats to find match", async () => {
|
|
264
|
+
mockFetch
|
|
265
|
+
.mockResolvedValueOnce({
|
|
266
|
+
ok: true,
|
|
267
|
+
json: () =>
|
|
268
|
+
Promise.resolve({
|
|
269
|
+
data: Array(500)
|
|
270
|
+
.fill(null)
|
|
271
|
+
.map((_, i) => ({
|
|
272
|
+
id: i,
|
|
273
|
+
guid: `chat-${i}`,
|
|
274
|
+
participants: [],
|
|
275
|
+
})),
|
|
276
|
+
}),
|
|
277
|
+
})
|
|
278
|
+
.mockResolvedValueOnce({
|
|
279
|
+
ok: true,
|
|
280
|
+
json: () =>
|
|
281
|
+
Promise.resolve({
|
|
282
|
+
data: [{ id: 555, guid: "found-chat", participants: [] }],
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 555 };
|
|
287
|
+
const result = await resolveChatGuidForTarget({
|
|
288
|
+
baseUrl: "http://localhost:1234",
|
|
289
|
+
password: "test",
|
|
290
|
+
target,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(result).toBe("found-chat");
|
|
294
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("normalizes handle addresses for matching", async () => {
|
|
298
|
+
mockFetch.mockResolvedValueOnce({
|
|
299
|
+
ok: true,
|
|
300
|
+
json: () =>
|
|
301
|
+
Promise.resolve({
|
|
302
|
+
data: [
|
|
303
|
+
{
|
|
304
|
+
guid: "iMessage;-;test@example.com",
|
|
305
|
+
participants: [{ address: "Test@Example.COM" }],
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
}),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const target: BlueBubblesSendTarget = {
|
|
312
|
+
kind: "handle",
|
|
313
|
+
address: "test@example.com",
|
|
314
|
+
service: "auto",
|
|
315
|
+
};
|
|
316
|
+
const result = await resolveChatGuidForTarget({
|
|
317
|
+
baseUrl: "http://localhost:1234",
|
|
318
|
+
password: "test",
|
|
319
|
+
target,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(result).toBe("iMessage;-;test@example.com");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("extracts guid from various response formats", async () => {
|
|
326
|
+
mockFetch.mockResolvedValueOnce({
|
|
327
|
+
ok: true,
|
|
328
|
+
json: () =>
|
|
329
|
+
Promise.resolve({
|
|
330
|
+
data: [
|
|
331
|
+
{
|
|
332
|
+
chatGuid: "format1-guid",
|
|
333
|
+
id: 100,
|
|
334
|
+
participants: [],
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
}),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 100 };
|
|
341
|
+
const result = await resolveChatGuidForTarget({
|
|
342
|
+
baseUrl: "http://localhost:1234",
|
|
343
|
+
password: "test",
|
|
344
|
+
target,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(result).toBe("format1-guid");
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe("sendMessageBlueBubbles", () => {
|
|
352
|
+
beforeEach(() => {
|
|
353
|
+
mockFetch.mockReset();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("throws when text is empty", async () => {
|
|
357
|
+
await expect(
|
|
358
|
+
sendMessageBlueBubbles("+15551234567", "", {
|
|
359
|
+
serverUrl: "http://localhost:1234",
|
|
360
|
+
password: "test",
|
|
361
|
+
}),
|
|
362
|
+
).rejects.toThrow("requires text");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("throws when text is whitespace only", async () => {
|
|
366
|
+
await expect(
|
|
367
|
+
sendMessageBlueBubbles("+15551234567", " ", {
|
|
368
|
+
serverUrl: "http://localhost:1234",
|
|
369
|
+
password: "test",
|
|
370
|
+
}),
|
|
371
|
+
).rejects.toThrow("requires text");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("throws when serverUrl is missing", async () => {
|
|
375
|
+
await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow(
|
|
376
|
+
"serverUrl is required",
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("throws when password is missing", async () => {
|
|
381
|
+
await expect(
|
|
382
|
+
sendMessageBlueBubbles("+15551234567", "Hello", {
|
|
383
|
+
serverUrl: "http://localhost:1234",
|
|
384
|
+
}),
|
|
385
|
+
).rejects.toThrow("password is required");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("throws when chatGuid cannot be resolved for non-handle targets", async () => {
|
|
389
|
+
mockFetch.mockResolvedValue({
|
|
390
|
+
ok: true,
|
|
391
|
+
json: () => Promise.resolve({ data: [] }),
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await expect(
|
|
395
|
+
sendMessageBlueBubbles("chat_id:999", "Hello", {
|
|
396
|
+
serverUrl: "http://localhost:1234",
|
|
397
|
+
password: "test",
|
|
398
|
+
}),
|
|
399
|
+
).rejects.toThrow("chatGuid not found");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("sends message successfully", async () => {
|
|
403
|
+
mockFetch
|
|
404
|
+
.mockResolvedValueOnce({
|
|
405
|
+
ok: true,
|
|
406
|
+
json: () =>
|
|
407
|
+
Promise.resolve({
|
|
408
|
+
data: [
|
|
409
|
+
{
|
|
410
|
+
guid: "iMessage;-;+15551234567",
|
|
411
|
+
participants: [{ address: "+15551234567" }],
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
}),
|
|
415
|
+
})
|
|
416
|
+
.mockResolvedValueOnce({
|
|
417
|
+
ok: true,
|
|
418
|
+
text: () =>
|
|
419
|
+
Promise.resolve(
|
|
420
|
+
JSON.stringify({
|
|
421
|
+
data: { guid: "msg-uuid-123" },
|
|
422
|
+
}),
|
|
423
|
+
),
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
|
|
427
|
+
serverUrl: "http://localhost:1234",
|
|
428
|
+
password: "test",
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
expect(result.messageId).toBe("msg-uuid-123");
|
|
432
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
433
|
+
|
|
434
|
+
const sendCall = mockFetch.mock.calls[1];
|
|
435
|
+
expect(sendCall[0]).toContain("/api/v1/message/text");
|
|
436
|
+
const body = JSON.parse(sendCall[1].body);
|
|
437
|
+
expect(body.chatGuid).toBe("iMessage;-;+15551234567");
|
|
438
|
+
expect(body.message).toBe("Hello world!");
|
|
439
|
+
expect(body.method).toBeUndefined();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("creates a new chat when handle target is missing", async () => {
|
|
443
|
+
mockFetch
|
|
444
|
+
.mockResolvedValueOnce({
|
|
445
|
+
ok: true,
|
|
446
|
+
json: () => Promise.resolve({ data: [] }),
|
|
447
|
+
})
|
|
448
|
+
.mockResolvedValueOnce({
|
|
449
|
+
ok: true,
|
|
450
|
+
text: () =>
|
|
451
|
+
Promise.resolve(
|
|
452
|
+
JSON.stringify({
|
|
453
|
+
data: { guid: "new-msg-guid" },
|
|
454
|
+
}),
|
|
455
|
+
),
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
|
|
459
|
+
serverUrl: "http://localhost:1234",
|
|
460
|
+
password: "test",
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
expect(result.messageId).toBe("new-msg-guid");
|
|
464
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
465
|
+
|
|
466
|
+
const createCall = mockFetch.mock.calls[1];
|
|
467
|
+
expect(createCall[0]).toContain("/api/v1/chat/new");
|
|
468
|
+
const body = JSON.parse(createCall[1].body);
|
|
469
|
+
expect(body.addresses).toEqual(["+15550009999"]);
|
|
470
|
+
expect(body.message).toBe("Hello new chat");
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("throws when creating a new chat requires Private API", async () => {
|
|
474
|
+
mockFetch
|
|
475
|
+
.mockResolvedValueOnce({
|
|
476
|
+
ok: true,
|
|
477
|
+
json: () => Promise.resolve({ data: [] }),
|
|
478
|
+
})
|
|
479
|
+
.mockResolvedValueOnce({
|
|
480
|
+
ok: false,
|
|
481
|
+
status: 403,
|
|
482
|
+
text: () => Promise.resolve("Private API not enabled"),
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
await expect(
|
|
486
|
+
sendMessageBlueBubbles("+15550008888", "Hello", {
|
|
487
|
+
serverUrl: "http://localhost:1234",
|
|
488
|
+
password: "test",
|
|
489
|
+
}),
|
|
490
|
+
).rejects.toThrow("Private API must be enabled");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("uses private-api when reply metadata is present", async () => {
|
|
494
|
+
mockFetch
|
|
495
|
+
.mockResolvedValueOnce({
|
|
496
|
+
ok: true,
|
|
497
|
+
json: () =>
|
|
498
|
+
Promise.resolve({
|
|
499
|
+
data: [
|
|
500
|
+
{
|
|
501
|
+
guid: "iMessage;-;+15551234567",
|
|
502
|
+
participants: [{ address: "+15551234567" }],
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
}),
|
|
506
|
+
})
|
|
507
|
+
.mockResolvedValueOnce({
|
|
508
|
+
ok: true,
|
|
509
|
+
text: () =>
|
|
510
|
+
Promise.resolve(
|
|
511
|
+
JSON.stringify({
|
|
512
|
+
data: { guid: "msg-uuid-124" },
|
|
513
|
+
}),
|
|
514
|
+
),
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
|
|
518
|
+
serverUrl: "http://localhost:1234",
|
|
519
|
+
password: "test",
|
|
520
|
+
replyToMessageGuid: "reply-guid-123",
|
|
521
|
+
replyToPartIndex: 1,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
expect(result.messageId).toBe("msg-uuid-124");
|
|
525
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
526
|
+
|
|
527
|
+
const sendCall = mockFetch.mock.calls[1];
|
|
528
|
+
const body = JSON.parse(sendCall[1].body);
|
|
529
|
+
expect(body.method).toBe("private-api");
|
|
530
|
+
expect(body.selectedMessageGuid).toBe("reply-guid-123");
|
|
531
|
+
expect(body.partIndex).toBe(1);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("normalizes effect names and uses private-api for effects", async () => {
|
|
535
|
+
mockFetch
|
|
536
|
+
.mockResolvedValueOnce({
|
|
537
|
+
ok: true,
|
|
538
|
+
json: () =>
|
|
539
|
+
Promise.resolve({
|
|
540
|
+
data: [
|
|
541
|
+
{
|
|
542
|
+
guid: "iMessage;-;+15551234567",
|
|
543
|
+
participants: [{ address: "+15551234567" }],
|
|
544
|
+
},
|
|
545
|
+
],
|
|
546
|
+
}),
|
|
547
|
+
})
|
|
548
|
+
.mockResolvedValueOnce({
|
|
549
|
+
ok: true,
|
|
550
|
+
text: () =>
|
|
551
|
+
Promise.resolve(
|
|
552
|
+
JSON.stringify({
|
|
553
|
+
data: { guid: "msg-uuid-125" },
|
|
554
|
+
}),
|
|
555
|
+
),
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
|
559
|
+
serverUrl: "http://localhost:1234",
|
|
560
|
+
password: "test",
|
|
561
|
+
effectId: "invisible ink",
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
expect(result.messageId).toBe("msg-uuid-125");
|
|
565
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
566
|
+
|
|
567
|
+
const sendCall = mockFetch.mock.calls[1];
|
|
568
|
+
const body = JSON.parse(sendCall[1].body);
|
|
569
|
+
expect(body.method).toBe("private-api");
|
|
570
|
+
expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink");
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("sends message with chat_guid target directly", async () => {
|
|
574
|
+
mockFetch.mockResolvedValueOnce({
|
|
575
|
+
ok: true,
|
|
576
|
+
text: () =>
|
|
577
|
+
Promise.resolve(
|
|
578
|
+
JSON.stringify({
|
|
579
|
+
data: { messageId: "direct-msg-123" },
|
|
580
|
+
}),
|
|
581
|
+
),
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const result = await sendMessageBlueBubbles(
|
|
585
|
+
"chat_guid:iMessage;-;direct-chat",
|
|
586
|
+
"Direct message",
|
|
587
|
+
{
|
|
588
|
+
serverUrl: "http://localhost:1234",
|
|
589
|
+
password: "test",
|
|
590
|
+
},
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
expect(result.messageId).toBe("direct-msg-123");
|
|
594
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("handles send failure", async () => {
|
|
598
|
+
mockFetch
|
|
599
|
+
.mockResolvedValueOnce({
|
|
600
|
+
ok: true,
|
|
601
|
+
json: () =>
|
|
602
|
+
Promise.resolve({
|
|
603
|
+
data: [
|
|
604
|
+
{
|
|
605
|
+
guid: "iMessage;-;+15551234567",
|
|
606
|
+
participants: [{ address: "+15551234567" }],
|
|
607
|
+
},
|
|
608
|
+
],
|
|
609
|
+
}),
|
|
610
|
+
})
|
|
611
|
+
.mockResolvedValueOnce({
|
|
612
|
+
ok: false,
|
|
613
|
+
status: 500,
|
|
614
|
+
text: () => Promise.resolve("Internal server error"),
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
await expect(
|
|
618
|
+
sendMessageBlueBubbles("+15551234567", "Hello", {
|
|
619
|
+
serverUrl: "http://localhost:1234",
|
|
620
|
+
password: "test",
|
|
621
|
+
}),
|
|
622
|
+
).rejects.toThrow("send failed (500)");
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("handles empty response body", async () => {
|
|
626
|
+
mockFetch
|
|
627
|
+
.mockResolvedValueOnce({
|
|
628
|
+
ok: true,
|
|
629
|
+
json: () =>
|
|
630
|
+
Promise.resolve({
|
|
631
|
+
data: [
|
|
632
|
+
{
|
|
633
|
+
guid: "iMessage;-;+15551234567",
|
|
634
|
+
participants: [{ address: "+15551234567" }],
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
}),
|
|
638
|
+
})
|
|
639
|
+
.mockResolvedValueOnce({
|
|
640
|
+
ok: true,
|
|
641
|
+
text: () => Promise.resolve(""),
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
|
645
|
+
serverUrl: "http://localhost:1234",
|
|
646
|
+
password: "test",
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
expect(result.messageId).toBe("ok");
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("handles invalid JSON response body", async () => {
|
|
653
|
+
mockFetch
|
|
654
|
+
.mockResolvedValueOnce({
|
|
655
|
+
ok: true,
|
|
656
|
+
json: () =>
|
|
657
|
+
Promise.resolve({
|
|
658
|
+
data: [
|
|
659
|
+
{
|
|
660
|
+
guid: "iMessage;-;+15551234567",
|
|
661
|
+
participants: [{ address: "+15551234567" }],
|
|
662
|
+
},
|
|
663
|
+
],
|
|
664
|
+
}),
|
|
665
|
+
})
|
|
666
|
+
.mockResolvedValueOnce({
|
|
667
|
+
ok: true,
|
|
668
|
+
text: () => Promise.resolve("not valid json"),
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
|
672
|
+
serverUrl: "http://localhost:1234",
|
|
673
|
+
password: "test",
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
expect(result.messageId).toBe("ok");
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("extracts messageId from various response formats", async () => {
|
|
680
|
+
mockFetch
|
|
681
|
+
.mockResolvedValueOnce({
|
|
682
|
+
ok: true,
|
|
683
|
+
json: () =>
|
|
684
|
+
Promise.resolve({
|
|
685
|
+
data: [
|
|
686
|
+
{
|
|
687
|
+
guid: "iMessage;-;+15551234567",
|
|
688
|
+
participants: [{ address: "+15551234567" }],
|
|
689
|
+
},
|
|
690
|
+
],
|
|
691
|
+
}),
|
|
692
|
+
})
|
|
693
|
+
.mockResolvedValueOnce({
|
|
694
|
+
ok: true,
|
|
695
|
+
text: () =>
|
|
696
|
+
Promise.resolve(
|
|
697
|
+
JSON.stringify({
|
|
698
|
+
id: "numeric-id-456",
|
|
699
|
+
}),
|
|
700
|
+
),
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
|
704
|
+
serverUrl: "http://localhost:1234",
|
|
705
|
+
password: "test",
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
expect(result.messageId).toBe("numeric-id-456");
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("extracts messageGuid from response payload", async () => {
|
|
712
|
+
mockFetch
|
|
713
|
+
.mockResolvedValueOnce({
|
|
714
|
+
ok: true,
|
|
715
|
+
json: () =>
|
|
716
|
+
Promise.resolve({
|
|
717
|
+
data: [
|
|
718
|
+
{
|
|
719
|
+
guid: "iMessage;-;+15551234567",
|
|
720
|
+
participants: [{ address: "+15551234567" }],
|
|
721
|
+
},
|
|
722
|
+
],
|
|
723
|
+
}),
|
|
724
|
+
})
|
|
725
|
+
.mockResolvedValueOnce({
|
|
726
|
+
ok: true,
|
|
727
|
+
text: () =>
|
|
728
|
+
Promise.resolve(
|
|
729
|
+
JSON.stringify({
|
|
730
|
+
data: { messageGuid: "msg-guid-789" },
|
|
731
|
+
}),
|
|
732
|
+
),
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
|
736
|
+
serverUrl: "http://localhost:1234",
|
|
737
|
+
password: "test",
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
expect(result.messageId).toBe("msg-guid-789");
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it("resolves credentials from config", async () => {
|
|
744
|
+
mockFetch
|
|
745
|
+
.mockResolvedValueOnce({
|
|
746
|
+
ok: true,
|
|
747
|
+
json: () =>
|
|
748
|
+
Promise.resolve({
|
|
749
|
+
data: [
|
|
750
|
+
{
|
|
751
|
+
guid: "iMessage;-;+15551234567",
|
|
752
|
+
participants: [{ address: "+15551234567" }],
|
|
753
|
+
},
|
|
754
|
+
],
|
|
755
|
+
}),
|
|
756
|
+
})
|
|
757
|
+
.mockResolvedValueOnce({
|
|
758
|
+
ok: true,
|
|
759
|
+
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })),
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
|
763
|
+
cfg: {
|
|
764
|
+
channels: {
|
|
765
|
+
bluebubbles: {
|
|
766
|
+
serverUrl: "http://config-server:5678",
|
|
767
|
+
password: "config-pass",
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
expect(result.messageId).toBe("msg-123");
|
|
774
|
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
|
775
|
+
expect(calledUrl).toContain("config-server:5678");
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("includes tempGuid in request payload", async () => {
|
|
779
|
+
mockFetch
|
|
780
|
+
.mockResolvedValueOnce({
|
|
781
|
+
ok: true,
|
|
782
|
+
json: () =>
|
|
783
|
+
Promise.resolve({
|
|
784
|
+
data: [
|
|
785
|
+
{
|
|
786
|
+
guid: "iMessage;-;+15551234567",
|
|
787
|
+
participants: [{ address: "+15551234567" }],
|
|
788
|
+
},
|
|
789
|
+
],
|
|
790
|
+
}),
|
|
791
|
+
})
|
|
792
|
+
.mockResolvedValueOnce({
|
|
793
|
+
ok: true,
|
|
794
|
+
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })),
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
await sendMessageBlueBubbles("+15551234567", "Hello", {
|
|
798
|
+
serverUrl: "http://localhost:1234",
|
|
799
|
+
password: "test",
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const sendCall = mockFetch.mock.calls[1];
|
|
803
|
+
const body = JSON.parse(sendCall[1].body);
|
|
804
|
+
expect(body.tempGuid).toBeDefined();
|
|
805
|
+
expect(typeof body.tempGuid).toBe("string");
|
|
806
|
+
expect(body.tempGuid.length).toBeGreaterThan(0);
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
});
|