@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 +1 -1
- package/src/account-resolve.ts +7 -1
- package/src/actions.test.ts +25 -35
- package/src/actions.ts +2 -13
- package/src/attachments.test.ts +65 -28
- package/src/attachments.ts +2 -1
- package/src/chat.test.ts +64 -56
- package/src/config-schema.ts +1 -0
- package/src/monitor-shared.ts +3 -13
- package/src/onboarding.ts +24 -37
- package/src/reactions.test.ts +23 -36
- package/src/send.test.ts +19 -28
- package/src/types.ts +2 -0
package/package.json
CHANGED
package/src/account-resolve.ts
CHANGED
|
@@ -12,6 +12,7 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
|
|
|
12
12
|
baseUrl: string;
|
|
13
13
|
password: string;
|
|
14
14
|
accountId: string;
|
|
15
|
+
allowPrivateNetwork: boolean;
|
|
15
16
|
} {
|
|
16
17
|
const account = resolveBlueBubblesAccount({
|
|
17
18
|
cfg: params.cfg ?? {},
|
|
@@ -25,5 +26,10 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
|
|
|
25
26
|
if (!password) {
|
|
26
27
|
throw new Error("BlueBubbles password is required");
|
|
27
28
|
}
|
|
28
|
-
return {
|
|
29
|
+
return {
|
|
30
|
+
baseUrl,
|
|
31
|
+
password,
|
|
32
|
+
accountId: account.accountId,
|
|
33
|
+
allowPrivateNetwork: account.config.allowPrivateNetwork === true,
|
|
34
|
+
};
|
|
29
35
|
}
|
package/src/actions.test.ts
CHANGED
|
@@ -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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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 })
|
|
116
|
-
const action = typeof args.action === "string" ? args.action.trim() : "";
|
|
117
|
-
if (action !== "sendMessage") {
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
const to = typeof args.to === "string" ? args.to : undefined;
|
|
121
|
-
if (!to) {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
|
125
|
-
return { to, accountId };
|
|
126
|
-
},
|
|
115
|
+
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
|
127
116
|
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
|
128
117
|
const account = resolveBlueBubblesAccount({
|
|
129
118
|
cfg: cfg,
|
package/src/attachments.test.ts
CHANGED
|
@@ -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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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", () => {
|
package/src/attachments.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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 () => {
|
package/src/config-schema.ts
CHANGED
|
@@ -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
|
})
|
package/src/monitor-shared.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/reactions.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|