@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.
@@ -0,0 +1,393 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ import { sendBlueBubblesReaction } from "./reactions.js";
4
+
5
+ vi.mock("./accounts.js", () => ({
6
+ resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
7
+ const config = cfg?.channels?.bluebubbles ?? {};
8
+ return {
9
+ accountId: accountId ?? "default",
10
+ enabled: config.enabled !== false,
11
+ configured: Boolean(config.serverUrl && config.password),
12
+ config,
13
+ };
14
+ }),
15
+ }));
16
+
17
+ const mockFetch = vi.fn();
18
+
19
+ describe("reactions", () => {
20
+ beforeEach(() => {
21
+ vi.stubGlobal("fetch", mockFetch);
22
+ mockFetch.mockReset();
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.unstubAllGlobals();
27
+ });
28
+
29
+ describe("sendBlueBubblesReaction", () => {
30
+ it("throws when chatGuid is empty", async () => {
31
+ await expect(
32
+ sendBlueBubblesReaction({
33
+ chatGuid: "",
34
+ messageGuid: "msg-123",
35
+ emoji: "love",
36
+ opts: {
37
+ serverUrl: "http://localhost:1234",
38
+ password: "test",
39
+ },
40
+ }),
41
+ ).rejects.toThrow("chatGuid");
42
+ });
43
+
44
+ it("throws when messageGuid is empty", async () => {
45
+ await expect(
46
+ sendBlueBubblesReaction({
47
+ chatGuid: "chat-123",
48
+ messageGuid: "",
49
+ emoji: "love",
50
+ opts: {
51
+ serverUrl: "http://localhost:1234",
52
+ password: "test",
53
+ },
54
+ }),
55
+ ).rejects.toThrow("messageGuid");
56
+ });
57
+
58
+ it("throws when emoji is empty", async () => {
59
+ await expect(
60
+ sendBlueBubblesReaction({
61
+ chatGuid: "chat-123",
62
+ messageGuid: "msg-123",
63
+ emoji: "",
64
+ opts: {
65
+ serverUrl: "http://localhost:1234",
66
+ password: "test",
67
+ },
68
+ }),
69
+ ).rejects.toThrow("emoji or name");
70
+ });
71
+
72
+ it("throws when serverUrl is missing", async () => {
73
+ await expect(
74
+ sendBlueBubblesReaction({
75
+ chatGuid: "chat-123",
76
+ messageGuid: "msg-123",
77
+ emoji: "love",
78
+ opts: {},
79
+ }),
80
+ ).rejects.toThrow("serverUrl is required");
81
+ });
82
+
83
+ it("throws when password is missing", async () => {
84
+ await expect(
85
+ sendBlueBubblesReaction({
86
+ chatGuid: "chat-123",
87
+ messageGuid: "msg-123",
88
+ emoji: "love",
89
+ opts: {
90
+ serverUrl: "http://localhost:1234",
91
+ },
92
+ }),
93
+ ).rejects.toThrow("password is required");
94
+ });
95
+
96
+ it("throws for unsupported reaction type", async () => {
97
+ await expect(
98
+ sendBlueBubblesReaction({
99
+ chatGuid: "chat-123",
100
+ messageGuid: "msg-123",
101
+ emoji: "unsupported",
102
+ opts: {
103
+ serverUrl: "http://localhost:1234",
104
+ password: "test",
105
+ },
106
+ }),
107
+ ).rejects.toThrow("Unsupported BlueBubbles reaction");
108
+ });
109
+
110
+ describe("reaction type normalization", () => {
111
+ const testCases = [
112
+ { input: "love", expected: "love" },
113
+ { input: "like", expected: "like" },
114
+ { input: "dislike", expected: "dislike" },
115
+ { input: "laugh", expected: "laugh" },
116
+ { input: "emphasize", expected: "emphasize" },
117
+ { input: "question", expected: "question" },
118
+ { input: "heart", expected: "love" },
119
+ { input: "thumbs_up", expected: "like" },
120
+ { input: "thumbs-down", expected: "dislike" },
121
+ { input: "thumbs_down", expected: "dislike" },
122
+ { input: "haha", expected: "laugh" },
123
+ { input: "lol", expected: "laugh" },
124
+ { input: "emphasis", expected: "emphasize" },
125
+ { input: "exclaim", expected: "emphasize" },
126
+ { input: "❤️", expected: "love" },
127
+ { input: "❤", expected: "love" },
128
+ { input: "♥️", expected: "love" },
129
+ { input: "😍", expected: "love" },
130
+ { input: "👍", expected: "like" },
131
+ { input: "👎", expected: "dislike" },
132
+ { input: "😂", expected: "laugh" },
133
+ { input: "🤣", expected: "laugh" },
134
+ { input: "😆", expected: "laugh" },
135
+ { input: "‼️", expected: "emphasize" },
136
+ { input: "‼", expected: "emphasize" },
137
+ { input: "❗", expected: "emphasize" },
138
+ { input: "❓", expected: "question" },
139
+ { input: "❔", expected: "question" },
140
+ { input: "LOVE", expected: "love" },
141
+ { input: "Like", expected: "like" },
142
+ ];
143
+
144
+ for (const { input, expected } of testCases) {
145
+ it(`normalizes "${input}" to "${expected}"`, async () => {
146
+ mockFetch.mockResolvedValueOnce({
147
+ ok: true,
148
+ text: () => Promise.resolve(""),
149
+ });
150
+
151
+ await sendBlueBubblesReaction({
152
+ chatGuid: "chat-123",
153
+ messageGuid: "msg-123",
154
+ emoji: input,
155
+ opts: {
156
+ serverUrl: "http://localhost:1234",
157
+ password: "test",
158
+ },
159
+ });
160
+
161
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
162
+ expect(body.reaction).toBe(expected);
163
+ });
164
+ }
165
+ });
166
+
167
+ it("sends reaction successfully", async () => {
168
+ mockFetch.mockResolvedValueOnce({
169
+ ok: true,
170
+ text: () => Promise.resolve(""),
171
+ });
172
+
173
+ await sendBlueBubblesReaction({
174
+ chatGuid: "iMessage;-;+15551234567",
175
+ messageGuid: "msg-uuid-123",
176
+ emoji: "love",
177
+ opts: {
178
+ serverUrl: "http://localhost:1234",
179
+ password: "test-password",
180
+ },
181
+ });
182
+
183
+ expect(mockFetch).toHaveBeenCalledWith(
184
+ expect.stringContaining("/api/v1/message/react"),
185
+ expect.objectContaining({
186
+ method: "POST",
187
+ headers: { "Content-Type": "application/json" },
188
+ }),
189
+ );
190
+
191
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
192
+ expect(body.chatGuid).toBe("iMessage;-;+15551234567");
193
+ expect(body.selectedMessageGuid).toBe("msg-uuid-123");
194
+ expect(body.reaction).toBe("love");
195
+ expect(body.partIndex).toBe(0);
196
+ });
197
+
198
+ it("includes password in URL query", async () => {
199
+ mockFetch.mockResolvedValueOnce({
200
+ ok: true,
201
+ text: () => Promise.resolve(""),
202
+ });
203
+
204
+ await sendBlueBubblesReaction({
205
+ chatGuid: "chat-123",
206
+ messageGuid: "msg-123",
207
+ emoji: "like",
208
+ opts: {
209
+ serverUrl: "http://localhost:1234",
210
+ password: "my-react-password",
211
+ },
212
+ });
213
+
214
+ const calledUrl = mockFetch.mock.calls[0][0] as string;
215
+ expect(calledUrl).toContain("password=my-react-password");
216
+ });
217
+
218
+ it("sends reaction removal with dash prefix", async () => {
219
+ mockFetch.mockResolvedValueOnce({
220
+ ok: true,
221
+ text: () => Promise.resolve(""),
222
+ });
223
+
224
+ await sendBlueBubblesReaction({
225
+ chatGuid: "chat-123",
226
+ messageGuid: "msg-123",
227
+ emoji: "love",
228
+ remove: true,
229
+ opts: {
230
+ serverUrl: "http://localhost:1234",
231
+ password: "test",
232
+ },
233
+ });
234
+
235
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
236
+ expect(body.reaction).toBe("-love");
237
+ });
238
+
239
+ it("strips leading dash from emoji when remove flag is set", async () => {
240
+ mockFetch.mockResolvedValueOnce({
241
+ ok: true,
242
+ text: () => Promise.resolve(""),
243
+ });
244
+
245
+ await sendBlueBubblesReaction({
246
+ chatGuid: "chat-123",
247
+ messageGuid: "msg-123",
248
+ emoji: "-love",
249
+ remove: true,
250
+ opts: {
251
+ serverUrl: "http://localhost:1234",
252
+ password: "test",
253
+ },
254
+ });
255
+
256
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
257
+ expect(body.reaction).toBe("-love");
258
+ });
259
+
260
+ it("uses custom partIndex when provided", async () => {
261
+ mockFetch.mockResolvedValueOnce({
262
+ ok: true,
263
+ text: () => Promise.resolve(""),
264
+ });
265
+
266
+ await sendBlueBubblesReaction({
267
+ chatGuid: "chat-123",
268
+ messageGuid: "msg-123",
269
+ emoji: "laugh",
270
+ partIndex: 3,
271
+ opts: {
272
+ serverUrl: "http://localhost:1234",
273
+ password: "test",
274
+ },
275
+ });
276
+
277
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
278
+ expect(body.partIndex).toBe(3);
279
+ });
280
+
281
+ it("throws on non-ok response", async () => {
282
+ mockFetch.mockResolvedValueOnce({
283
+ ok: false,
284
+ status: 400,
285
+ text: () => Promise.resolve("Invalid reaction type"),
286
+ });
287
+
288
+ await expect(
289
+ sendBlueBubblesReaction({
290
+ chatGuid: "chat-123",
291
+ messageGuid: "msg-123",
292
+ emoji: "like",
293
+ opts: {
294
+ serverUrl: "http://localhost:1234",
295
+ password: "test",
296
+ },
297
+ }),
298
+ ).rejects.toThrow("reaction failed (400): Invalid reaction type");
299
+ });
300
+
301
+ it("resolves credentials from config", async () => {
302
+ mockFetch.mockResolvedValueOnce({
303
+ ok: true,
304
+ text: () => Promise.resolve(""),
305
+ });
306
+
307
+ await sendBlueBubblesReaction({
308
+ chatGuid: "chat-123",
309
+ messageGuid: "msg-123",
310
+ emoji: "emphasize",
311
+ opts: {
312
+ cfg: {
313
+ channels: {
314
+ bluebubbles: {
315
+ serverUrl: "http://react-server:7777",
316
+ password: "react-pass",
317
+ },
318
+ },
319
+ },
320
+ },
321
+ });
322
+
323
+ const calledUrl = mockFetch.mock.calls[0][0] as string;
324
+ expect(calledUrl).toContain("react-server:7777");
325
+ expect(calledUrl).toContain("password=react-pass");
326
+ });
327
+
328
+ it("trims chatGuid and messageGuid", async () => {
329
+ mockFetch.mockResolvedValueOnce({
330
+ ok: true,
331
+ text: () => Promise.resolve(""),
332
+ });
333
+
334
+ await sendBlueBubblesReaction({
335
+ chatGuid: " chat-with-spaces ",
336
+ messageGuid: " msg-with-spaces ",
337
+ emoji: "question",
338
+ opts: {
339
+ serverUrl: "http://localhost:1234",
340
+ password: "test",
341
+ },
342
+ });
343
+
344
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
345
+ expect(body.chatGuid).toBe("chat-with-spaces");
346
+ expect(body.selectedMessageGuid).toBe("msg-with-spaces");
347
+ });
348
+
349
+ describe("reaction removal aliases", () => {
350
+ it("handles emoji-based removal", async () => {
351
+ mockFetch.mockResolvedValueOnce({
352
+ ok: true,
353
+ text: () => Promise.resolve(""),
354
+ });
355
+
356
+ await sendBlueBubblesReaction({
357
+ chatGuid: "chat-123",
358
+ messageGuid: "msg-123",
359
+ emoji: "👍",
360
+ remove: true,
361
+ opts: {
362
+ serverUrl: "http://localhost:1234",
363
+ password: "test",
364
+ },
365
+ });
366
+
367
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
368
+ expect(body.reaction).toBe("-like");
369
+ });
370
+
371
+ it("handles text alias removal", async () => {
372
+ mockFetch.mockResolvedValueOnce({
373
+ ok: true,
374
+ text: () => Promise.resolve(""),
375
+ });
376
+
377
+ await sendBlueBubblesReaction({
378
+ chatGuid: "chat-123",
379
+ messageGuid: "msg-123",
380
+ emoji: "haha",
381
+ remove: true,
382
+ opts: {
383
+ serverUrl: "http://localhost:1234",
384
+ password: "test",
385
+ },
386
+ });
387
+
388
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
389
+ expect(body.reaction).toBe("-laugh");
390
+ });
391
+ });
392
+ });
393
+ });
@@ -0,0 +1,183 @@
1
+ import { resolveBlueBubblesAccount } from "./accounts.js";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
4
+
5
+ export type BlueBubblesReactionOpts = {
6
+ serverUrl?: string;
7
+ password?: string;
8
+ accountId?: string;
9
+ timeoutMs?: number;
10
+ cfg?: OpenClawConfig;
11
+ };
12
+
13
+ const REACTION_TYPES = new Set([
14
+ "love",
15
+ "like",
16
+ "dislike",
17
+ "laugh",
18
+ "emphasize",
19
+ "question",
20
+ ]);
21
+
22
+ const REACTION_ALIASES = new Map<string, string>([
23
+ // General
24
+ ["heart", "love"],
25
+ ["love", "love"],
26
+ ["❤", "love"],
27
+ ["❤️", "love"],
28
+ ["red_heart", "love"],
29
+ ["thumbs_up", "like"],
30
+ ["thumbsup", "like"],
31
+ ["thumbs-up", "like"],
32
+ ["thumbsup", "like"],
33
+ ["like", "like"],
34
+ ["thumb", "like"],
35
+ ["ok", "like"],
36
+ ["thumbs_down", "dislike"],
37
+ ["thumbsdown", "dislike"],
38
+ ["thumbs-down", "dislike"],
39
+ ["dislike", "dislike"],
40
+ ["boo", "dislike"],
41
+ ["no", "dislike"],
42
+ // Laugh
43
+ ["haha", "laugh"],
44
+ ["lol", "laugh"],
45
+ ["lmao", "laugh"],
46
+ ["rofl", "laugh"],
47
+ ["😂", "laugh"],
48
+ ["🤣", "laugh"],
49
+ ["xd", "laugh"],
50
+ ["laugh", "laugh"],
51
+ // Emphasize / exclaim
52
+ ["emphasis", "emphasize"],
53
+ ["emphasize", "emphasize"],
54
+ ["exclaim", "emphasize"],
55
+ ["!!", "emphasize"],
56
+ ["‼", "emphasize"],
57
+ ["‼️", "emphasize"],
58
+ ["❗", "emphasize"],
59
+ ["important", "emphasize"],
60
+ ["bang", "emphasize"],
61
+ // Question
62
+ ["question", "question"],
63
+ ["?", "question"],
64
+ ["❓", "question"],
65
+ ["❔", "question"],
66
+ ["ask", "question"],
67
+ // Apple/Messages names
68
+ ["loved", "love"],
69
+ ["liked", "like"],
70
+ ["disliked", "dislike"],
71
+ ["laughed", "laugh"],
72
+ ["emphasized", "emphasize"],
73
+ ["questioned", "question"],
74
+ // Colloquial / informal
75
+ ["fire", "love"],
76
+ ["🔥", "love"],
77
+ ["wow", "emphasize"],
78
+ ["!", "emphasize"],
79
+ // Edge: generic emoji name forms
80
+ ["heart_eyes", "love"],
81
+ ["smile", "laugh"],
82
+ ["smiley", "laugh"],
83
+ ["happy", "laugh"],
84
+ ["joy", "laugh"],
85
+ ]);
86
+
87
+ const REACTION_EMOJIS = new Map<string, string>([
88
+ // Love
89
+ ["❤️", "love"],
90
+ ["❤", "love"],
91
+ ["♥️", "love"],
92
+ ["♥", "love"],
93
+ ["😍", "love"],
94
+ ["💕", "love"],
95
+ // Like
96
+ ["👍", "like"],
97
+ ["👌", "like"],
98
+ // Dislike
99
+ ["👎", "dislike"],
100
+ ["🙅", "dislike"],
101
+ // Laugh
102
+ ["😂", "laugh"],
103
+ ["🤣", "laugh"],
104
+ ["😆", "laugh"],
105
+ ["😁", "laugh"],
106
+ ["😹", "laugh"],
107
+ // Emphasize
108
+ ["‼️", "emphasize"],
109
+ ["‼", "emphasize"],
110
+ ["!!", "emphasize"],
111
+ ["❗", "emphasize"],
112
+ ["❕", "emphasize"],
113
+ ["!", "emphasize"],
114
+ // Question
115
+ ["❓", "question"],
116
+ ["❔", "question"],
117
+ ["?", "question"],
118
+ ]);
119
+
120
+ function resolveAccount(params: BlueBubblesReactionOpts) {
121
+ const account = resolveBlueBubblesAccount({
122
+ cfg: params.cfg ?? {},
123
+ accountId: params.accountId,
124
+ });
125
+ const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
126
+ const password = params.password?.trim() || account.config.password?.trim();
127
+ if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
128
+ if (!password) throw new Error("BlueBubbles password is required");
129
+ return { baseUrl, password };
130
+ }
131
+
132
+ export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
133
+ const trimmed = emoji.trim();
134
+ if (!trimmed) throw new Error("BlueBubbles reaction requires an emoji or name.");
135
+ let raw = trimmed.toLowerCase();
136
+ if (raw.startsWith("-")) raw = raw.slice(1);
137
+ const aliased = REACTION_ALIASES.get(raw) ?? raw;
138
+ const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased;
139
+ if (!REACTION_TYPES.has(mapped)) {
140
+ throw new Error(`Unsupported BlueBubbles reaction: ${trimmed}`);
141
+ }
142
+ return remove ? `-${mapped}` : mapped;
143
+ }
144
+
145
+ export async function sendBlueBubblesReaction(params: {
146
+ chatGuid: string;
147
+ messageGuid: string;
148
+ emoji: string;
149
+ remove?: boolean;
150
+ partIndex?: number;
151
+ opts?: BlueBubblesReactionOpts;
152
+ }): Promise<void> {
153
+ const chatGuid = params.chatGuid.trim();
154
+ const messageGuid = params.messageGuid.trim();
155
+ if (!chatGuid) throw new Error("BlueBubbles reaction requires chatGuid.");
156
+ if (!messageGuid) throw new Error("BlueBubbles reaction requires messageGuid.");
157
+ const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
158
+ const { baseUrl, password } = resolveAccount(params.opts ?? {});
159
+ const url = buildBlueBubblesApiUrl({
160
+ baseUrl,
161
+ path: "/api/v1/message/react",
162
+ password,
163
+ });
164
+ const payload = {
165
+ chatGuid,
166
+ selectedMessageGuid: messageGuid,
167
+ reaction,
168
+ partIndex: typeof params.partIndex === "number" ? params.partIndex : 0,
169
+ };
170
+ const res = await blueBubblesFetchWithTimeout(
171
+ url,
172
+ {
173
+ method: "POST",
174
+ headers: { "Content-Type": "application/json" },
175
+ body: JSON.stringify(payload),
176
+ },
177
+ params.opts?.timeoutMs,
178
+ );
179
+ if (!res.ok) {
180
+ const errorText = await res.text();
181
+ throw new Error(`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`);
182
+ }
183
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setBlueBubblesRuntime(next: PluginRuntime): void {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getBlueBubblesRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("BlueBubbles runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }