@openclaw/bluebubbles 2026.2.22 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/bluebubbles",
3
- "version": "2026.2.22",
3
+ "version": "2026.2.23",
4
4
  "description": "OpenClaw BlueBubbles channel plugin",
5
5
  "type": "module",
6
6
  "devDependencies": {
@@ -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 { baseUrl, password, accountId: account.accountId };
29
+ return {
30
+ baseUrl,
31
+ password,
32
+ accountId: account.accountId,
33
+ allowPrivateNetwork: account.config.allowPrivateNetwork === true,
34
+ };
29
35
  }
@@ -47,6 +47,22 @@ describe("bluebubblesMessageActions", () => {
47
47
  const handleAction = bluebubblesMessageActions.handleAction!;
48
48
  const callHandleAction = (ctx: Omit<Parameters<typeof handleAction>[0], "channel">) =>
49
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
+ };
50
66
 
51
67
  beforeEach(() => {
52
68
  vi.clearAllMocks();
@@ -285,23 +301,10 @@ describe("bluebubblesMessageActions", () => {
285
301
  it("sends reaction successfully with chatGuid", async () => {
286
302
  const { sendBlueBubblesReaction } = await import("./reactions.js");
287
303
 
288
- const cfg: OpenClawConfig = {
289
- channels: {
290
- bluebubbles: {
291
- serverUrl: "http://localhost:1234",
292
- password: "test-password",
293
- },
294
- },
295
- };
296
- const result = await callHandleAction({
297
- action: "react",
298
- params: {
299
- emoji: "❤️",
300
- messageId: "msg-123",
301
- chatGuid: "iMessage;-;+15551234567",
302
- },
303
- cfg,
304
- accountId: null,
304
+ const result = await runReactAction({
305
+ emoji: "❤️",
306
+ messageId: "msg-123",
307
+ chatGuid: "iMessage;-;+15551234567",
305
308
  });
306
309
 
307
310
  expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
@@ -320,24 +323,11 @@ describe("bluebubblesMessageActions", () => {
320
323
  it("sends reaction removal successfully", async () => {
321
324
  const { sendBlueBubblesReaction } = await import("./reactions.js");
322
325
 
323
- const cfg: OpenClawConfig = {
324
- channels: {
325
- bluebubbles: {
326
- serverUrl: "http://localhost:1234",
327
- password: "test-password",
328
- },
329
- },
330
- };
331
- const result = await callHandleAction({
332
- action: "react",
333
- params: {
334
- emoji: "❤️",
335
- messageId: "msg-123",
336
- chatGuid: "iMessage;-;+15551234567",
337
- remove: true,
338
- },
339
- cfg,
340
- accountId: null,
326
+ const result = await runReactAction({
327
+ emoji: "❤️",
328
+ messageId: "msg-123",
329
+ chatGuid: "iMessage;-;+15551234567",
330
+ remove: true,
341
331
  });
342
332
 
343
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 }): ChannelToolSend | null => {
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,
@@ -64,6 +64,24 @@ describe("downloadBlueBubblesAttachment", () => {
64
64
  setBlueBubblesRuntime(runtimeStub);
65
65
  });
66
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
+
67
85
  it("throws when guid is missing", async () => {
68
86
  const attachment: BlueBubblesAttachment = {};
69
87
  await expect(
@@ -175,38 +193,14 @@ describe("downloadBlueBubblesAttachment", () => {
175
193
  });
176
194
 
177
195
  it("throws when attachment exceeds max bytes", async () => {
178
- const largeBuffer = new Uint8Array(10 * 1024 * 1024);
179
- mockFetch.mockResolvedValueOnce({
180
- ok: true,
181
- headers: new Headers(),
182
- arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
196
+ await expectAttachmentTooLarge({
197
+ bufferBytes: 10 * 1024 * 1024,
198
+ maxBytes: 5 * 1024 * 1024,
183
199
  });
184
-
185
- const attachment: BlueBubblesAttachment = { guid: "att-large" };
186
- await expect(
187
- downloadBlueBubblesAttachment(attachment, {
188
- serverUrl: "http://localhost:1234",
189
- password: "test",
190
- maxBytes: 5 * 1024 * 1024,
191
- }),
192
- ).rejects.toThrow("too large");
193
200
  });
194
201
 
195
202
  it("uses default max bytes when not specified", async () => {
196
- const largeBuffer = new Uint8Array(9 * 1024 * 1024);
197
- mockFetch.mockResolvedValueOnce({
198
- ok: true,
199
- headers: new Headers(),
200
- arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
201
- });
202
-
203
- const attachment: BlueBubblesAttachment = { guid: "att-large" };
204
- await expect(
205
- downloadBlueBubblesAttachment(attachment, {
206
- serverUrl: "http://localhost:1234",
207
- password: "test",
208
- }),
209
- ).rejects.toThrow("too large");
203
+ await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 });
210
204
  });
211
205
 
212
206
  it("uses attachment mimeType as fallback when response has no content-type", async () => {
@@ -274,6 +268,49 @@ describe("downloadBlueBubblesAttachment", () => {
274
268
  expect(calledUrl).toContain("password=config-password");
275
269
  expect(result.buffer).toEqual(new Uint8Array([1]));
276
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
+ });
277
314
  });
278
315
 
279
316
  describe("sendBlueBubblesAttachment", () => {
@@ -82,7 +82,7 @@ export async function downloadBlueBubblesAttachment(
82
82
  if (!guid) {
83
83
  throw new Error("BlueBubbles attachment guid is required");
84
84
  }
85
- const { baseUrl, password } = resolveAccount(opts);
85
+ const { baseUrl, password, allowPrivateNetwork } = resolveAccount(opts);
86
86
  const url = buildBlueBubblesApiUrl({
87
87
  baseUrl,
88
88
  path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
@@ -94,6 +94,7 @@ export async function downloadBlueBubblesAttachment(
94
94
  url,
95
95
  filePathHint: attachment.transferName ?? attachment.guid ?? "attachment",
96
96
  maxBytes,
97
+ ssrfPolicy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
97
98
  fetchImpl: async (input, init) =>
98
99
  await blueBubblesFetchWithTimeout(
99
100
  resolveRequestUrl(input),
package/src/chat.test.ts CHANGED
@@ -22,6 +22,44 @@ installBlueBubblesFetchTestHooks({
22
22
  });
23
23
 
24
24
  describe("chat", () => {
25
+ function mockOkTextResponse() {
26
+ mockFetch.mockResolvedValueOnce({
27
+ ok: true,
28
+ text: () => Promise.resolve(""),
29
+ });
30
+ }
31
+
32
+ async function expectCalledUrlIncludesPassword(params: {
33
+ password: string;
34
+ invoke: () => Promise<void>;
35
+ }) {
36
+ mockOkTextResponse();
37
+ await params.invoke();
38
+ const calledUrl = mockFetch.mock.calls[0][0] as string;
39
+ expect(calledUrl).toContain(`password=${params.password}`);
40
+ }
41
+
42
+ async function expectCalledUrlUsesConfigCredentials(params: {
43
+ serverHost: string;
44
+ password: string;
45
+ invoke: (cfg: {
46
+ channels: { bluebubbles: { serverUrl: string; password: string } };
47
+ }) => Promise<void>;
48
+ }) {
49
+ mockOkTextResponse();
50
+ await params.invoke({
51
+ channels: {
52
+ bluebubbles: {
53
+ serverUrl: `http://${params.serverHost}`,
54
+ password: params.password,
55
+ },
56
+ },
57
+ });
58
+ const calledUrl = mockFetch.mock.calls[0][0] as string;
59
+ expect(calledUrl).toContain(params.serverHost);
60
+ expect(calledUrl).toContain(`password=${params.password}`);
61
+ }
62
+
25
63
  describe("markBlueBubblesChatRead", () => {
26
64
  it("does nothing when chatGuid is empty or whitespace", async () => {
27
65
  for (const chatGuid of ["", " "]) {
@@ -73,18 +111,14 @@ describe("chat", () => {
73
111
  });
74
112
 
75
113
  it("includes password in URL query", async () => {
76
- mockFetch.mockResolvedValueOnce({
77
- ok: true,
78
- text: () => Promise.resolve(""),
79
- });
80
-
81
- await markBlueBubblesChatRead("chat-123", {
82
- serverUrl: "http://localhost:1234",
114
+ await expectCalledUrlIncludesPassword({
83
115
  password: "my-secret",
116
+ invoke: () =>
117
+ markBlueBubblesChatRead("chat-123", {
118
+ serverUrl: "http://localhost:1234",
119
+ password: "my-secret",
120
+ }),
84
121
  });
85
-
86
- const calledUrl = mockFetch.mock.calls[0][0] as string;
87
- expect(calledUrl).toContain("password=my-secret");
88
122
  });
89
123
 
90
124
  it("throws on non-ok response", async () => {
@@ -119,25 +153,14 @@ describe("chat", () => {
119
153
  });
120
154
 
121
155
  it("resolves credentials from config", async () => {
122
- mockFetch.mockResolvedValueOnce({
123
- ok: true,
124
- text: () => Promise.resolve(""),
125
- });
126
-
127
- await markBlueBubblesChatRead("chat-123", {
128
- cfg: {
129
- channels: {
130
- bluebubbles: {
131
- serverUrl: "http://config-server:9999",
132
- password: "config-pass",
133
- },
134
- },
135
- },
156
+ await expectCalledUrlUsesConfigCredentials({
157
+ serverHost: "config-server:9999",
158
+ password: "config-pass",
159
+ invoke: (cfg) =>
160
+ markBlueBubblesChatRead("chat-123", {
161
+ cfg,
162
+ }),
136
163
  });
137
-
138
- const calledUrl = mockFetch.mock.calls[0][0] as string;
139
- expect(calledUrl).toContain("config-server:9999");
140
- expect(calledUrl).toContain("password=config-pass");
141
164
  });
142
165
  });
143
166
 
@@ -536,18 +559,14 @@ describe("chat", () => {
536
559
  });
537
560
 
538
561
  it("includes password in URL query", async () => {
539
- mockFetch.mockResolvedValueOnce({
540
- ok: true,
541
- text: () => Promise.resolve(""),
542
- });
543
-
544
- await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
545
- serverUrl: "http://localhost:1234",
562
+ await expectCalledUrlIncludesPassword({
546
563
  password: "my-secret",
564
+ invoke: () =>
565
+ setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
566
+ serverUrl: "http://localhost:1234",
567
+ password: "my-secret",
568
+ }),
547
569
  });
548
-
549
- const calledUrl = mockFetch.mock.calls[0][0] as string;
550
- expect(calledUrl).toContain("password=my-secret");
551
570
  });
552
571
 
553
572
  it("throws on non-ok response", async () => {
@@ -582,25 +601,14 @@ describe("chat", () => {
582
601
  });
583
602
 
584
603
  it("resolves credentials from config", async () => {
585
- mockFetch.mockResolvedValueOnce({
586
- ok: true,
587
- text: () => Promise.resolve(""),
588
- });
589
-
590
- await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
591
- cfg: {
592
- channels: {
593
- bluebubbles: {
594
- serverUrl: "http://config-server:9999",
595
- password: "config-pass",
596
- },
597
- },
598
- },
604
+ await expectCalledUrlUsesConfigCredentials({
605
+ serverHost: "config-server:9999",
606
+ password: "config-pass",
607
+ invoke: (cfg) =>
608
+ setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
609
+ cfg,
610
+ }),
599
611
  });
600
-
601
- const calledUrl = mockFetch.mock.calls[0][0] as string;
602
- expect(calledUrl).toContain("config-server:9999");
603
- expect(calledUrl).toContain("password=config-pass");
604
612
  });
605
613
 
606
614
  it("includes filename in multipart body", async () => {
@@ -43,6 +43,7 @@ const bluebubblesAccountSchema = z
43
43
  mediaMaxMb: z.number().int().positive().optional(),
44
44
  mediaLocalRoots: z.array(z.string()).optional(),
45
45
  sendReadReceipts: z.boolean().optional(),
46
+ allowPrivateNetwork: z.boolean().optional(),
46
47
  blockStreaming: z.boolean().optional(),
47
48
  groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
48
49
  })
@@ -1,8 +1,10 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk";
2
2
  import type { ResolvedBlueBubblesAccount } from "./accounts.js";
3
3
  import { getBlueBubblesRuntime } from "./runtime.js";
4
4
  import type { BlueBubblesAccountConfig } from "./types.js";
5
5
 
6
+ export { normalizeWebhookPath };
7
+
6
8
  export type BlueBubblesRuntimeEnv = {
7
9
  log?: (message: string) => void;
8
10
  error?: (message: string) => void;
@@ -30,18 +32,6 @@ export type WebhookTarget = {
30
32
 
31
33
  export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
32
34
 
33
- export function normalizeWebhookPath(raw: string): string {
34
- const trimmed = raw.trim();
35
- if (!trimmed) {
36
- return "/";
37
- }
38
- const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
39
- if (withSlash.length > 1 && withSlash.endsWith("/")) {
40
- return withSlash.slice(0, -1);
41
- }
42
- return withSlash;
43
- }
44
-
45
35
  export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
46
36
  const raw = config?.webhookPath?.trim();
47
37
  if (raw) {
package/src/onboarding.ts CHANGED
@@ -176,6 +176,28 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
176
176
 
177
177
  let next = cfg;
178
178
  const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId });
179
+ const validateServerUrlInput = (value: unknown): string | undefined => {
180
+ const trimmed = String(value ?? "").trim();
181
+ if (!trimmed) {
182
+ return "Required";
183
+ }
184
+ try {
185
+ const normalized = normalizeBlueBubblesServerUrl(trimmed);
186
+ new URL(normalized);
187
+ return undefined;
188
+ } catch {
189
+ return "Invalid URL format";
190
+ }
191
+ };
192
+ const promptServerUrl = async (initialValue?: string): Promise<string> => {
193
+ const entered = await prompter.text({
194
+ message: "BlueBubbles server URL",
195
+ placeholder: "http://192.168.1.100:1234",
196
+ initialValue,
197
+ validate: validateServerUrlInput,
198
+ });
199
+ return String(entered).trim();
200
+ };
179
201
 
180
202
  // Prompt for server URL
181
203
  let serverUrl = resolvedAccount.config.serverUrl?.trim();
@@ -188,49 +210,14 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
188
210
  ].join("\n"),
189
211
  "BlueBubbles server URL",
190
212
  );
191
- const entered = await prompter.text({
192
- message: "BlueBubbles server URL",
193
- placeholder: "http://192.168.1.100:1234",
194
- validate: (value) => {
195
- const trimmed = String(value ?? "").trim();
196
- if (!trimmed) {
197
- return "Required";
198
- }
199
- try {
200
- const normalized = normalizeBlueBubblesServerUrl(trimmed);
201
- new URL(normalized);
202
- return undefined;
203
- } catch {
204
- return "Invalid URL format";
205
- }
206
- },
207
- });
208
- serverUrl = String(entered).trim();
213
+ serverUrl = await promptServerUrl();
209
214
  } else {
210
215
  const keepUrl = await prompter.confirm({
211
216
  message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`,
212
217
  initialValue: true,
213
218
  });
214
219
  if (!keepUrl) {
215
- const entered = await prompter.text({
216
- message: "BlueBubbles server URL",
217
- placeholder: "http://192.168.1.100:1234",
218
- initialValue: serverUrl,
219
- validate: (value) => {
220
- const trimmed = String(value ?? "").trim();
221
- if (!trimmed) {
222
- return "Required";
223
- }
224
- try {
225
- const normalized = normalizeBlueBubblesServerUrl(trimmed);
226
- new URL(normalized);
227
- return undefined;
228
- } catch {
229
- return "Invalid URL format";
230
- }
231
- },
232
- });
233
- serverUrl = String(entered).trim();
220
+ serverUrl = await promptServerUrl(serverUrl);
234
221
  }
235
222
  }
236
223
 
@@ -19,6 +19,27 @@ describe("reactions", () => {
19
19
  });
20
20
 
21
21
  describe("sendBlueBubblesReaction", () => {
22
+ async function expectRemovedReaction(emoji: string) {
23
+ mockFetch.mockResolvedValueOnce({
24
+ ok: true,
25
+ text: () => Promise.resolve(""),
26
+ });
27
+
28
+ await sendBlueBubblesReaction({
29
+ chatGuid: "chat-123",
30
+ messageGuid: "msg-123",
31
+ emoji,
32
+ remove: true,
33
+ opts: {
34
+ serverUrl: "http://localhost:1234",
35
+ password: "test",
36
+ },
37
+ });
38
+
39
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
40
+ expect(body.reaction).toBe("-love");
41
+ }
42
+
22
43
  it("throws when chatGuid is empty", async () => {
23
44
  await expect(
24
45
  sendBlueBubblesReaction({
@@ -208,45 +229,11 @@ describe("reactions", () => {
208
229
  });
209
230
 
210
231
  it("sends reaction removal with dash prefix", async () => {
211
- mockFetch.mockResolvedValueOnce({
212
- ok: true,
213
- text: () => Promise.resolve(""),
214
- });
215
-
216
- await sendBlueBubblesReaction({
217
- chatGuid: "chat-123",
218
- messageGuid: "msg-123",
219
- emoji: "love",
220
- remove: true,
221
- opts: {
222
- serverUrl: "http://localhost:1234",
223
- password: "test",
224
- },
225
- });
226
-
227
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
228
- expect(body.reaction).toBe("-love");
232
+ await expectRemovedReaction("love");
229
233
  });
230
234
 
231
235
  it("strips leading dash from emoji when remove flag is set", async () => {
232
- mockFetch.mockResolvedValueOnce({
233
- ok: true,
234
- text: () => Promise.resolve(""),
235
- });
236
-
237
- await sendBlueBubblesReaction({
238
- chatGuid: "chat-123",
239
- messageGuid: "msg-123",
240
- emoji: "-love",
241
- remove: true,
242
- opts: {
243
- serverUrl: "http://localhost:1234",
244
- password: "test",
245
- },
246
- });
247
-
248
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
249
- expect(body.reaction).toBe("-love");
236
+ await expectRemovedReaction("-love");
250
237
  });
251
238
 
252
239
  it("uses custom partIndex when provided", async () => {
package/src/send.test.ts CHANGED
@@ -44,6 +44,23 @@ function mockSendResponse(body: unknown) {
44
44
  });
45
45
  }
46
46
 
47
+ function mockNewChatSendResponse(guid: string) {
48
+ mockFetch
49
+ .mockResolvedValueOnce({
50
+ ok: true,
51
+ json: () => Promise.resolve({ data: [] }),
52
+ })
53
+ .mockResolvedValueOnce({
54
+ ok: true,
55
+ text: () =>
56
+ Promise.resolve(
57
+ JSON.stringify({
58
+ data: { guid },
59
+ }),
60
+ ),
61
+ });
62
+ }
63
+
47
64
  describe("send", () => {
48
65
  describe("resolveChatGuidForTarget", () => {
49
66
  const resolveHandleTargetGuid = async (data: Array<Record<string, unknown>>) => {
@@ -453,20 +470,7 @@ describe("send", () => {
453
470
  });
454
471
 
455
472
  it("strips markdown when creating a new chat", async () => {
456
- mockFetch
457
- .mockResolvedValueOnce({
458
- ok: true,
459
- json: () => Promise.resolve({ data: [] }),
460
- })
461
- .mockResolvedValueOnce({
462
- ok: true,
463
- text: () =>
464
- Promise.resolve(
465
- JSON.stringify({
466
- data: { guid: "new-msg-stripped" },
467
- }),
468
- ),
469
- });
473
+ mockNewChatSendResponse("new-msg-stripped");
470
474
 
471
475
  const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", {
472
476
  serverUrl: "http://localhost:1234",
@@ -483,20 +487,7 @@ describe("send", () => {
483
487
  });
484
488
 
485
489
  it("creates a new chat when handle target is missing", async () => {
486
- mockFetch
487
- .mockResolvedValueOnce({
488
- ok: true,
489
- json: () => Promise.resolve({ data: [] }),
490
- })
491
- .mockResolvedValueOnce({
492
- ok: true,
493
- text: () =>
494
- Promise.resolve(
495
- JSON.stringify({
496
- data: { guid: "new-msg-guid" },
497
- }),
498
- ),
499
- });
490
+ mockNewChatSendResponse("new-msg-guid");
500
491
 
501
492
  const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
502
493
  serverUrl: "http://localhost:1234",
package/src/types.ts CHANGED
@@ -53,6 +53,8 @@ export type BlueBubblesAccountConfig = {
53
53
  mediaLocalRoots?: string[];
54
54
  /** Send read receipts for incoming messages (default: true). */
55
55
  sendReadReceipts?: boolean;
56
+ /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for same-host BlueBubbles setups. */
57
+ allowPrivateNetwork?: boolean;
56
58
  /** Per-group configuration keyed by chat GUID or identifier. */
57
59
  groups?: Record<string, BlueBubblesGroupConfig>;
58
60
  };