@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/src/chat.ts ADDED
@@ -0,0 +1,354 @@
1
+ import crypto from "node:crypto";
2
+ import { resolveBlueBubblesAccount } from "./accounts.js";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
+ import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
5
+
6
+ export type BlueBubblesChatOpts = {
7
+ serverUrl?: string;
8
+ password?: string;
9
+ accountId?: string;
10
+ timeoutMs?: number;
11
+ cfg?: OpenClawConfig;
12
+ };
13
+
14
+ function resolveAccount(params: BlueBubblesChatOpts) {
15
+ const account = resolveBlueBubblesAccount({
16
+ cfg: params.cfg ?? {},
17
+ accountId: params.accountId,
18
+ });
19
+ const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
20
+ const password = params.password?.trim() || account.config.password?.trim();
21
+ if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
22
+ if (!password) throw new Error("BlueBubbles password is required");
23
+ return { baseUrl, password };
24
+ }
25
+
26
+ export async function markBlueBubblesChatRead(
27
+ chatGuid: string,
28
+ opts: BlueBubblesChatOpts = {},
29
+ ): Promise<void> {
30
+ const trimmed = chatGuid.trim();
31
+ if (!trimmed) return;
32
+ const { baseUrl, password } = resolveAccount(opts);
33
+ const url = buildBlueBubblesApiUrl({
34
+ baseUrl,
35
+ path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
36
+ password,
37
+ });
38
+ const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
39
+ if (!res.ok) {
40
+ const errorText = await res.text().catch(() => "");
41
+ throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`);
42
+ }
43
+ }
44
+
45
+ export async function sendBlueBubblesTyping(
46
+ chatGuid: string,
47
+ typing: boolean,
48
+ opts: BlueBubblesChatOpts = {},
49
+ ): Promise<void> {
50
+ const trimmed = chatGuid.trim();
51
+ if (!trimmed) return;
52
+ const { baseUrl, password } = resolveAccount(opts);
53
+ const url = buildBlueBubblesApiUrl({
54
+ baseUrl,
55
+ path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
56
+ password,
57
+ });
58
+ const res = await blueBubblesFetchWithTimeout(
59
+ url,
60
+ { method: typing ? "POST" : "DELETE" },
61
+ opts.timeoutMs,
62
+ );
63
+ if (!res.ok) {
64
+ const errorText = await res.text().catch(() => "");
65
+ throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Edit a message via BlueBubbles API.
71
+ * Requires macOS 13 (Ventura) or higher with Private API enabled.
72
+ */
73
+ export async function editBlueBubblesMessage(
74
+ messageGuid: string,
75
+ newText: string,
76
+ opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {},
77
+ ): Promise<void> {
78
+ const trimmedGuid = messageGuid.trim();
79
+ if (!trimmedGuid) throw new Error("BlueBubbles edit requires messageGuid");
80
+ const trimmedText = newText.trim();
81
+ if (!trimmedText) throw new Error("BlueBubbles edit requires newText");
82
+
83
+ const { baseUrl, password } = resolveAccount(opts);
84
+ const url = buildBlueBubblesApiUrl({
85
+ baseUrl,
86
+ path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`,
87
+ password,
88
+ });
89
+
90
+ const payload = {
91
+ editedMessage: trimmedText,
92
+ backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
93
+ partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
94
+ };
95
+
96
+ const res = await blueBubblesFetchWithTimeout(
97
+ url,
98
+ {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/json" },
101
+ body: JSON.stringify(payload),
102
+ },
103
+ opts.timeoutMs,
104
+ );
105
+
106
+ if (!res.ok) {
107
+ const errorText = await res.text().catch(() => "");
108
+ throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Unsend (retract) a message via BlueBubbles API.
114
+ * Requires macOS 13 (Ventura) or higher with Private API enabled.
115
+ */
116
+ export async function unsendBlueBubblesMessage(
117
+ messageGuid: string,
118
+ opts: BlueBubblesChatOpts & { partIndex?: number } = {},
119
+ ): Promise<void> {
120
+ const trimmedGuid = messageGuid.trim();
121
+ if (!trimmedGuid) throw new Error("BlueBubbles unsend requires messageGuid");
122
+
123
+ const { baseUrl, password } = resolveAccount(opts);
124
+ const url = buildBlueBubblesApiUrl({
125
+ baseUrl,
126
+ path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
127
+ password,
128
+ });
129
+
130
+ const payload = {
131
+ partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
132
+ };
133
+
134
+ const res = await blueBubblesFetchWithTimeout(
135
+ url,
136
+ {
137
+ method: "POST",
138
+ headers: { "Content-Type": "application/json" },
139
+ body: JSON.stringify(payload),
140
+ },
141
+ opts.timeoutMs,
142
+ );
143
+
144
+ if (!res.ok) {
145
+ const errorText = await res.text().catch(() => "");
146
+ throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Rename a group chat via BlueBubbles API.
152
+ */
153
+ export async function renameBlueBubblesChat(
154
+ chatGuid: string,
155
+ displayName: string,
156
+ opts: BlueBubblesChatOpts = {},
157
+ ): Promise<void> {
158
+ const trimmedGuid = chatGuid.trim();
159
+ if (!trimmedGuid) throw new Error("BlueBubbles rename requires chatGuid");
160
+
161
+ const { baseUrl, password } = resolveAccount(opts);
162
+ const url = buildBlueBubblesApiUrl({
163
+ baseUrl,
164
+ path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
165
+ password,
166
+ });
167
+
168
+ const res = await blueBubblesFetchWithTimeout(
169
+ url,
170
+ {
171
+ method: "PUT",
172
+ headers: { "Content-Type": "application/json" },
173
+ body: JSON.stringify({ displayName }),
174
+ },
175
+ opts.timeoutMs,
176
+ );
177
+
178
+ if (!res.ok) {
179
+ const errorText = await res.text().catch(() => "");
180
+ throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Add a participant to a group chat via BlueBubbles API.
186
+ */
187
+ export async function addBlueBubblesParticipant(
188
+ chatGuid: string,
189
+ address: string,
190
+ opts: BlueBubblesChatOpts = {},
191
+ ): Promise<void> {
192
+ const trimmedGuid = chatGuid.trim();
193
+ if (!trimmedGuid) throw new Error("BlueBubbles addParticipant requires chatGuid");
194
+ const trimmedAddress = address.trim();
195
+ if (!trimmedAddress) throw new Error("BlueBubbles addParticipant requires address");
196
+
197
+ const { baseUrl, password } = resolveAccount(opts);
198
+ const url = buildBlueBubblesApiUrl({
199
+ baseUrl,
200
+ path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
201
+ password,
202
+ });
203
+
204
+ const res = await blueBubblesFetchWithTimeout(
205
+ url,
206
+ {
207
+ method: "POST",
208
+ headers: { "Content-Type": "application/json" },
209
+ body: JSON.stringify({ address: trimmedAddress }),
210
+ },
211
+ opts.timeoutMs,
212
+ );
213
+
214
+ if (!res.ok) {
215
+ const errorText = await res.text().catch(() => "");
216
+ throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Remove a participant from a group chat via BlueBubbles API.
222
+ */
223
+ export async function removeBlueBubblesParticipant(
224
+ chatGuid: string,
225
+ address: string,
226
+ opts: BlueBubblesChatOpts = {},
227
+ ): Promise<void> {
228
+ const trimmedGuid = chatGuid.trim();
229
+ if (!trimmedGuid) throw new Error("BlueBubbles removeParticipant requires chatGuid");
230
+ const trimmedAddress = address.trim();
231
+ if (!trimmedAddress) throw new Error("BlueBubbles removeParticipant requires address");
232
+
233
+ const { baseUrl, password } = resolveAccount(opts);
234
+ const url = buildBlueBubblesApiUrl({
235
+ baseUrl,
236
+ path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
237
+ password,
238
+ });
239
+
240
+ const res = await blueBubblesFetchWithTimeout(
241
+ url,
242
+ {
243
+ method: "DELETE",
244
+ headers: { "Content-Type": "application/json" },
245
+ body: JSON.stringify({ address: trimmedAddress }),
246
+ },
247
+ opts.timeoutMs,
248
+ );
249
+
250
+ if (!res.ok) {
251
+ const errorText = await res.text().catch(() => "");
252
+ throw new Error(`BlueBubbles removeParticipant failed (${res.status}): ${errorText || "unknown"}`);
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Leave a group chat via BlueBubbles API.
258
+ */
259
+ export async function leaveBlueBubblesChat(
260
+ chatGuid: string,
261
+ opts: BlueBubblesChatOpts = {},
262
+ ): Promise<void> {
263
+ const trimmedGuid = chatGuid.trim();
264
+ if (!trimmedGuid) throw new Error("BlueBubbles leaveChat requires chatGuid");
265
+
266
+ const { baseUrl, password } = resolveAccount(opts);
267
+ const url = buildBlueBubblesApiUrl({
268
+ baseUrl,
269
+ path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`,
270
+ password,
271
+ });
272
+
273
+ const res = await blueBubblesFetchWithTimeout(
274
+ url,
275
+ { method: "POST" },
276
+ opts.timeoutMs,
277
+ );
278
+
279
+ if (!res.ok) {
280
+ const errorText = await res.text().catch(() => "");
281
+ throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`);
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Set a group chat's icon/photo via BlueBubbles API.
287
+ * Requires Private API to be enabled.
288
+ */
289
+ export async function setGroupIconBlueBubbles(
290
+ chatGuid: string,
291
+ buffer: Uint8Array,
292
+ filename: string,
293
+ opts: BlueBubblesChatOpts & { contentType?: string } = {},
294
+ ): Promise<void> {
295
+ const trimmedGuid = chatGuid.trim();
296
+ if (!trimmedGuid) throw new Error("BlueBubbles setGroupIcon requires chatGuid");
297
+ if (!buffer || buffer.length === 0) {
298
+ throw new Error("BlueBubbles setGroupIcon requires image buffer");
299
+ }
300
+
301
+ const { baseUrl, password } = resolveAccount(opts);
302
+ const url = buildBlueBubblesApiUrl({
303
+ baseUrl,
304
+ path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`,
305
+ password,
306
+ });
307
+
308
+ // Build multipart form-data
309
+ const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
310
+ const parts: Uint8Array[] = [];
311
+ const encoder = new TextEncoder();
312
+
313
+ // Add file field named "icon" as per API spec
314
+ parts.push(encoder.encode(`--${boundary}\r\n`));
315
+ parts.push(
316
+ encoder.encode(
317
+ `Content-Disposition: form-data; name="icon"; filename="${filename}"\r\n`,
318
+ ),
319
+ );
320
+ parts.push(
321
+ encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`),
322
+ );
323
+ parts.push(buffer);
324
+ parts.push(encoder.encode("\r\n"));
325
+
326
+ // Close multipart body
327
+ parts.push(encoder.encode(`--${boundary}--\r\n`));
328
+
329
+ // Combine into single buffer
330
+ const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
331
+ const body = new Uint8Array(totalLength);
332
+ let offset = 0;
333
+ for (const part of parts) {
334
+ body.set(part, offset);
335
+ offset += part.length;
336
+ }
337
+
338
+ const res = await blueBubblesFetchWithTimeout(
339
+ url,
340
+ {
341
+ method: "POST",
342
+ headers: {
343
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
344
+ },
345
+ body,
346
+ },
347
+ opts.timeoutMs ?? 60_000, // longer timeout for file uploads
348
+ );
349
+
350
+ if (!res.ok) {
351
+ const errorText = await res.text().catch(() => "");
352
+ throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`);
353
+ }
354
+ }
@@ -0,0 +1,51 @@
1
+ import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
2
+ import { z } from "zod";
3
+
4
+ const allowFromEntry = z.union([z.string(), z.number()]);
5
+
6
+ const bluebubblesActionSchema = z
7
+ .object({
8
+ reactions: z.boolean().default(true),
9
+ edit: z.boolean().default(true),
10
+ unsend: z.boolean().default(true),
11
+ reply: z.boolean().default(true),
12
+ sendWithEffect: z.boolean().default(true),
13
+ renameGroup: z.boolean().default(true),
14
+ setGroupIcon: z.boolean().default(true),
15
+ addParticipant: z.boolean().default(true),
16
+ removeParticipant: z.boolean().default(true),
17
+ leaveGroup: z.boolean().default(true),
18
+ sendAttachment: z.boolean().default(true),
19
+ })
20
+ .optional();
21
+
22
+ const bluebubblesGroupConfigSchema = z.object({
23
+ requireMention: z.boolean().optional(),
24
+ tools: ToolPolicySchema,
25
+ });
26
+
27
+ const bluebubblesAccountSchema = z.object({
28
+ name: z.string().optional(),
29
+ enabled: z.boolean().optional(),
30
+ markdown: MarkdownConfigSchema,
31
+ serverUrl: z.string().optional(),
32
+ password: z.string().optional(),
33
+ webhookPath: z.string().optional(),
34
+ dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
35
+ allowFrom: z.array(allowFromEntry).optional(),
36
+ groupAllowFrom: z.array(allowFromEntry).optional(),
37
+ groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
38
+ historyLimit: z.number().int().min(0).optional(),
39
+ dmHistoryLimit: z.number().int().min(0).optional(),
40
+ textChunkLimit: z.number().int().positive().optional(),
41
+ chunkMode: z.enum(["length", "newline"]).optional(),
42
+ mediaMaxMb: z.number().int().positive().optional(),
43
+ sendReadReceipts: z.boolean().optional(),
44
+ blockStreaming: z.boolean().optional(),
45
+ groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
46
+ });
47
+
48
+ export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
49
+ accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
50
+ actions: bluebubblesActionSchema,
51
+ });
@@ -0,0 +1,168 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk";
5
+
6
+ import { sendBlueBubblesAttachment } from "./attachments.js";
7
+ import { resolveBlueBubblesMessageId } from "./monitor.js";
8
+ import { getBlueBubblesRuntime } from "./runtime.js";
9
+ import { sendMessageBlueBubbles } from "./send.js";
10
+
11
+ const HTTP_URL_RE = /^https?:\/\//i;
12
+ const MB = 1024 * 1024;
13
+
14
+ function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void {
15
+ if (typeof maxBytes !== "number" || maxBytes <= 0) return;
16
+ if (sizeBytes <= maxBytes) return;
17
+ const maxLabel = (maxBytes / MB).toFixed(0);
18
+ const sizeLabel = (sizeBytes / MB).toFixed(2);
19
+ throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`);
20
+ }
21
+
22
+ function resolveLocalMediaPath(source: string): string {
23
+ if (!source.startsWith("file://")) return source;
24
+ try {
25
+ return fileURLToPath(source);
26
+ } catch {
27
+ throw new Error(`Invalid file:// URL: ${source}`);
28
+ }
29
+ }
30
+
31
+ function resolveFilenameFromSource(source?: string): string | undefined {
32
+ if (!source) return undefined;
33
+ if (source.startsWith("file://")) {
34
+ try {
35
+ return path.basename(fileURLToPath(source)) || undefined;
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ if (HTTP_URL_RE.test(source)) {
41
+ try {
42
+ return path.basename(new URL(source).pathname) || undefined;
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ }
47
+ const base = path.basename(source);
48
+ return base || undefined;
49
+ }
50
+
51
+ export async function sendBlueBubblesMedia(params: {
52
+ cfg: OpenClawConfig;
53
+ to: string;
54
+ mediaUrl?: string;
55
+ mediaPath?: string;
56
+ mediaBuffer?: Uint8Array;
57
+ contentType?: string;
58
+ filename?: string;
59
+ caption?: string;
60
+ replyToId?: string | null;
61
+ accountId?: string;
62
+ asVoice?: boolean;
63
+ }) {
64
+ const {
65
+ cfg,
66
+ to,
67
+ mediaUrl,
68
+ mediaPath,
69
+ mediaBuffer,
70
+ contentType,
71
+ filename,
72
+ caption,
73
+ replyToId,
74
+ accountId,
75
+ asVoice,
76
+ } = params;
77
+ const core = getBlueBubblesRuntime();
78
+ const maxBytes = resolveChannelMediaMaxBytes({
79
+ cfg,
80
+ resolveChannelLimitMb: ({ cfg, accountId }) =>
81
+ cfg.channels?.bluebubbles?.accounts?.[accountId]?.mediaMaxMb ??
82
+ cfg.channels?.bluebubbles?.mediaMaxMb,
83
+ accountId,
84
+ });
85
+
86
+ let buffer: Uint8Array;
87
+ let resolvedContentType = contentType ?? undefined;
88
+ let resolvedFilename = filename ?? undefined;
89
+
90
+ if (mediaBuffer) {
91
+ assertMediaWithinLimit(mediaBuffer.byteLength, maxBytes);
92
+ buffer = mediaBuffer;
93
+ if (!resolvedContentType) {
94
+ const hint = mediaPath ?? mediaUrl;
95
+ const detected = await core.media.detectMime({
96
+ buffer: Buffer.isBuffer(mediaBuffer) ? mediaBuffer : Buffer.from(mediaBuffer),
97
+ filePath: hint,
98
+ });
99
+ resolvedContentType = detected ?? undefined;
100
+ }
101
+ if (!resolvedFilename) {
102
+ resolvedFilename = resolveFilenameFromSource(mediaPath ?? mediaUrl);
103
+ }
104
+ } else {
105
+ const source = mediaPath ?? mediaUrl;
106
+ if (!source) {
107
+ throw new Error("BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer.");
108
+ }
109
+ if (HTTP_URL_RE.test(source)) {
110
+ const fetched = await core.channel.media.fetchRemoteMedia({
111
+ url: source,
112
+ maxBytes: typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined,
113
+ });
114
+ buffer = fetched.buffer;
115
+ resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
116
+ resolvedFilename = resolvedFilename ?? fetched.fileName;
117
+ } else {
118
+ const localPath = resolveLocalMediaPath(source);
119
+ const fs = await import("node:fs/promises");
120
+ if (typeof maxBytes === "number" && maxBytes > 0) {
121
+ const stats = await fs.stat(localPath);
122
+ assertMediaWithinLimit(stats.size, maxBytes);
123
+ }
124
+ const data = await fs.readFile(localPath);
125
+ assertMediaWithinLimit(data.byteLength, maxBytes);
126
+ buffer = new Uint8Array(data);
127
+ if (!resolvedContentType) {
128
+ const detected = await core.media.detectMime({
129
+ buffer: data,
130
+ filePath: localPath,
131
+ });
132
+ resolvedContentType = detected ?? undefined;
133
+ }
134
+ if (!resolvedFilename) {
135
+ resolvedFilename = resolveFilenameFromSource(localPath);
136
+ }
137
+ }
138
+ }
139
+
140
+ // Resolve short ID (e.g., "5") to full UUID
141
+ const replyToMessageGuid = replyToId?.trim()
142
+ ? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true })
143
+ : undefined;
144
+
145
+ const attachmentResult = await sendBlueBubblesAttachment({
146
+ to,
147
+ buffer,
148
+ filename: resolvedFilename ?? "attachment",
149
+ contentType: resolvedContentType ?? undefined,
150
+ replyToMessageGuid,
151
+ asVoice,
152
+ opts: {
153
+ cfg,
154
+ accountId,
155
+ },
156
+ });
157
+
158
+ const trimmedCaption = caption?.trim();
159
+ if (trimmedCaption) {
160
+ await sendMessageBlueBubbles(to, trimmedCaption, {
161
+ cfg,
162
+ accountId,
163
+ replyToMessageGuid,
164
+ });
165
+ }
166
+
167
+ return attachmentResult;
168
+ }