@openclaw/bluebubbles 2026.2.21 → 2026.2.22

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 CHANGED
@@ -26,6 +26,41 @@ function assertPrivateApiEnabled(accountId: string, feature: string): void {
26
26
  }
27
27
  }
28
28
 
29
+ function resolvePartIndex(partIndex: number | undefined): number {
30
+ return typeof partIndex === "number" ? partIndex : 0;
31
+ }
32
+
33
+ async function sendPrivateApiJsonRequest(params: {
34
+ opts: BlueBubblesChatOpts;
35
+ feature: string;
36
+ action: string;
37
+ path: string;
38
+ method: "POST" | "PUT" | "DELETE";
39
+ payload?: unknown;
40
+ }): Promise<void> {
41
+ const { baseUrl, password, accountId } = resolveAccount(params.opts);
42
+ assertPrivateApiEnabled(accountId, params.feature);
43
+ const url = buildBlueBubblesApiUrl({
44
+ baseUrl,
45
+ path: params.path,
46
+ password,
47
+ });
48
+
49
+ const request: RequestInit = { method: params.method };
50
+ if (params.payload !== undefined) {
51
+ request.headers = { "Content-Type": "application/json" };
52
+ request.body = JSON.stringify(params.payload);
53
+ }
54
+
55
+ const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs);
56
+ if (!res.ok) {
57
+ const errorText = await res.text().catch(() => "");
58
+ throw new Error(
59
+ `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`,
60
+ );
61
+ }
62
+ }
63
+
29
64
  export async function markBlueBubblesChatRead(
30
65
  chatGuid: string,
31
66
  opts: BlueBubblesChatOpts = {},
@@ -97,34 +132,18 @@ export async function editBlueBubblesMessage(
97
132
  throw new Error("BlueBubbles edit requires newText");
98
133
  }
99
134
 
100
- const { baseUrl, password, accountId } = resolveAccount(opts);
101
- assertPrivateApiEnabled(accountId, "edit");
102
- const url = buildBlueBubblesApiUrl({
103
- baseUrl,
135
+ await sendPrivateApiJsonRequest({
136
+ opts,
137
+ feature: "edit",
138
+ action: "edit",
139
+ method: "POST",
104
140
  path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`,
105
- password,
106
- });
107
-
108
- const payload = {
109
- editedMessage: trimmedText,
110
- backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
111
- partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
112
- };
113
-
114
- const res = await blueBubblesFetchWithTimeout(
115
- url,
116
- {
117
- method: "POST",
118
- headers: { "Content-Type": "application/json" },
119
- body: JSON.stringify(payload),
141
+ payload: {
142
+ editedMessage: trimmedText,
143
+ backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
144
+ partIndex: resolvePartIndex(opts.partIndex),
120
145
  },
121
- opts.timeoutMs,
122
- );
123
-
124
- if (!res.ok) {
125
- const errorText = await res.text().catch(() => "");
126
- throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`);
127
- }
146
+ });
128
147
  }
129
148
 
130
149
  /**
@@ -140,32 +159,14 @@ export async function unsendBlueBubblesMessage(
140
159
  throw new Error("BlueBubbles unsend requires messageGuid");
141
160
  }
142
161
 
143
- const { baseUrl, password, accountId } = resolveAccount(opts);
144
- assertPrivateApiEnabled(accountId, "unsend");
145
- const url = buildBlueBubblesApiUrl({
146
- baseUrl,
162
+ await sendPrivateApiJsonRequest({
163
+ opts,
164
+ feature: "unsend",
165
+ action: "unsend",
166
+ method: "POST",
147
167
  path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
148
- password,
168
+ payload: { partIndex: resolvePartIndex(opts.partIndex) },
149
169
  });
150
-
151
- const payload = {
152
- partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
153
- };
154
-
155
- const res = await blueBubblesFetchWithTimeout(
156
- url,
157
- {
158
- method: "POST",
159
- headers: { "Content-Type": "application/json" },
160
- body: JSON.stringify(payload),
161
- },
162
- opts.timeoutMs,
163
- );
164
-
165
- if (!res.ok) {
166
- const errorText = await res.text().catch(() => "");
167
- throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`);
168
- }
169
170
  }
170
171
 
171
172
  /**
@@ -181,28 +182,14 @@ export async function renameBlueBubblesChat(
181
182
  throw new Error("BlueBubbles rename requires chatGuid");
182
183
  }
183
184
 
184
- const { baseUrl, password, accountId } = resolveAccount(opts);
185
- assertPrivateApiEnabled(accountId, "renameGroup");
186
- const url = buildBlueBubblesApiUrl({
187
- baseUrl,
185
+ await sendPrivateApiJsonRequest({
186
+ opts,
187
+ feature: "renameGroup",
188
+ action: "rename",
189
+ method: "PUT",
188
190
  path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
189
- password,
191
+ payload: { displayName },
190
192
  });
191
-
192
- const res = await blueBubblesFetchWithTimeout(
193
- url,
194
- {
195
- method: "PUT",
196
- headers: { "Content-Type": "application/json" },
197
- body: JSON.stringify({ displayName }),
198
- },
199
- opts.timeoutMs,
200
- );
201
-
202
- if (!res.ok) {
203
- const errorText = await res.text().catch(() => "");
204
- throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`);
205
- }
206
193
  }
207
194
 
208
195
  /**
@@ -222,28 +209,14 @@ export async function addBlueBubblesParticipant(
222
209
  throw new Error("BlueBubbles addParticipant requires address");
223
210
  }
224
211
 
225
- const { baseUrl, password, accountId } = resolveAccount(opts);
226
- assertPrivateApiEnabled(accountId, "addParticipant");
227
- const url = buildBlueBubblesApiUrl({
228
- baseUrl,
212
+ await sendPrivateApiJsonRequest({
213
+ opts,
214
+ feature: "addParticipant",
215
+ action: "addParticipant",
216
+ method: "POST",
229
217
  path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
230
- password,
218
+ payload: { address: trimmedAddress },
231
219
  });
232
-
233
- const res = await blueBubblesFetchWithTimeout(
234
- url,
235
- {
236
- method: "POST",
237
- headers: { "Content-Type": "application/json" },
238
- body: JSON.stringify({ address: trimmedAddress }),
239
- },
240
- opts.timeoutMs,
241
- );
242
-
243
- if (!res.ok) {
244
- const errorText = await res.text().catch(() => "");
245
- throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`);
246
- }
247
220
  }
248
221
 
249
222
  /**
@@ -263,30 +236,14 @@ export async function removeBlueBubblesParticipant(
263
236
  throw new Error("BlueBubbles removeParticipant requires address");
264
237
  }
265
238
 
266
- const { baseUrl, password, accountId } = resolveAccount(opts);
267
- assertPrivateApiEnabled(accountId, "removeParticipant");
268
- const url = buildBlueBubblesApiUrl({
269
- baseUrl,
239
+ await sendPrivateApiJsonRequest({
240
+ opts,
241
+ feature: "removeParticipant",
242
+ action: "removeParticipant",
243
+ method: "DELETE",
270
244
  path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
271
- password,
245
+ payload: { address: trimmedAddress },
272
246
  });
273
-
274
- const res = await blueBubblesFetchWithTimeout(
275
- url,
276
- {
277
- method: "DELETE",
278
- headers: { "Content-Type": "application/json" },
279
- body: JSON.stringify({ address: trimmedAddress }),
280
- },
281
- opts.timeoutMs,
282
- );
283
-
284
- if (!res.ok) {
285
- const errorText = await res.text().catch(() => "");
286
- throw new Error(
287
- `BlueBubbles removeParticipant failed (${res.status}): ${errorText || "unknown"}`,
288
- );
289
- }
290
247
  }
291
248
 
292
249
  /**
@@ -301,20 +258,13 @@ export async function leaveBlueBubblesChat(
301
258
  throw new Error("BlueBubbles leaveChat requires chatGuid");
302
259
  }
303
260
 
304
- const { baseUrl, password, accountId } = resolveAccount(opts);
305
- assertPrivateApiEnabled(accountId, "leaveGroup");
306
- const url = buildBlueBubblesApiUrl({
307
- baseUrl,
261
+ await sendPrivateApiJsonRequest({
262
+ opts,
263
+ feature: "leaveGroup",
264
+ action: "leaveChat",
265
+ method: "POST",
308
266
  path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`,
309
- password,
310
267
  });
311
-
312
- const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
313
-
314
- if (!res.ok) {
315
- const errorText = await res.text().catch(() => "");
316
- throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`);
317
- }
318
268
  }
319
269
 
320
270
  /**
package/src/history.ts ADDED
@@ -0,0 +1,177 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
3
+ import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
4
+
5
+ export type BlueBubblesHistoryEntry = {
6
+ sender: string;
7
+ body: string;
8
+ timestamp?: number;
9
+ messageId?: string;
10
+ };
11
+
12
+ export type BlueBubblesHistoryFetchResult = {
13
+ entries: BlueBubblesHistoryEntry[];
14
+ /**
15
+ * True when at least one API path returned a recognized response shape.
16
+ * False means all attempts failed or returned unusable data.
17
+ */
18
+ resolved: boolean;
19
+ };
20
+
21
+ export type BlueBubblesMessageData = {
22
+ guid?: string;
23
+ text?: string;
24
+ handle_id?: string;
25
+ is_from_me?: boolean;
26
+ date_created?: number;
27
+ date_delivered?: number;
28
+ associated_message_guid?: string;
29
+ sender?: {
30
+ address?: string;
31
+ display_name?: string;
32
+ };
33
+ };
34
+
35
+ export type BlueBubblesChatOpts = {
36
+ serverUrl?: string;
37
+ password?: string;
38
+ accountId?: string;
39
+ timeoutMs?: number;
40
+ cfg?: OpenClawConfig;
41
+ };
42
+
43
+ function resolveAccount(params: BlueBubblesChatOpts) {
44
+ return resolveBlueBubblesServerAccount(params);
45
+ }
46
+
47
+ const MAX_HISTORY_FETCH_LIMIT = 100;
48
+ const HISTORY_SCAN_MULTIPLIER = 8;
49
+ const MAX_HISTORY_SCAN_MESSAGES = 500;
50
+ const MAX_HISTORY_BODY_CHARS = 2_000;
51
+
52
+ function clampHistoryLimit(limit: number): number {
53
+ if (!Number.isFinite(limit)) {
54
+ return 0;
55
+ }
56
+ const normalized = Math.floor(limit);
57
+ if (normalized <= 0) {
58
+ return 0;
59
+ }
60
+ return Math.min(normalized, MAX_HISTORY_FETCH_LIMIT);
61
+ }
62
+
63
+ function truncateHistoryBody(text: string): string {
64
+ if (text.length <= MAX_HISTORY_BODY_CHARS) {
65
+ return text;
66
+ }
67
+ return `${text.slice(0, MAX_HISTORY_BODY_CHARS).trimEnd()}...`;
68
+ }
69
+
70
+ /**
71
+ * Fetch message history from BlueBubbles API for a specific chat.
72
+ * This provides the initial backfill for both group chats and DMs.
73
+ */
74
+ export async function fetchBlueBubblesHistory(
75
+ chatIdentifier: string,
76
+ limit: number,
77
+ opts: BlueBubblesChatOpts = {},
78
+ ): Promise<BlueBubblesHistoryFetchResult> {
79
+ const effectiveLimit = clampHistoryLimit(limit);
80
+ if (!chatIdentifier.trim() || effectiveLimit <= 0) {
81
+ return { entries: [], resolved: true };
82
+ }
83
+
84
+ let baseUrl: string;
85
+ let password: string;
86
+ try {
87
+ ({ baseUrl, password } = resolveAccount(opts));
88
+ } catch {
89
+ return { entries: [], resolved: false };
90
+ }
91
+
92
+ // Try different common API patterns for fetching messages
93
+ const possiblePaths = [
94
+ `/api/v1/chat/${encodeURIComponent(chatIdentifier)}/messages?limit=${effectiveLimit}&sort=DESC`,
95
+ `/api/v1/messages?chatGuid=${encodeURIComponent(chatIdentifier)}&limit=${effectiveLimit}`,
96
+ `/api/v1/chat/${encodeURIComponent(chatIdentifier)}/message?limit=${effectiveLimit}`,
97
+ ];
98
+
99
+ for (const path of possiblePaths) {
100
+ try {
101
+ const url = buildBlueBubblesApiUrl({ baseUrl, path, password });
102
+ const res = await blueBubblesFetchWithTimeout(
103
+ url,
104
+ { method: "GET" },
105
+ opts.timeoutMs ?? 10000,
106
+ );
107
+
108
+ if (!res.ok) {
109
+ continue; // Try next path
110
+ }
111
+
112
+ const data = await res.json().catch(() => null);
113
+ if (!data) {
114
+ continue;
115
+ }
116
+
117
+ // Handle different response structures
118
+ let messages: unknown[] = [];
119
+ if (Array.isArray(data)) {
120
+ messages = data;
121
+ } else if (data.data && Array.isArray(data.data)) {
122
+ messages = data.data;
123
+ } else if (data.messages && Array.isArray(data.messages)) {
124
+ messages = data.messages;
125
+ } else {
126
+ continue;
127
+ }
128
+
129
+ const historyEntries: BlueBubblesHistoryEntry[] = [];
130
+
131
+ const maxScannedMessages = Math.min(
132
+ Math.max(effectiveLimit * HISTORY_SCAN_MULTIPLIER, effectiveLimit),
133
+ MAX_HISTORY_SCAN_MESSAGES,
134
+ );
135
+ for (let i = 0; i < messages.length && i < maxScannedMessages; i++) {
136
+ const item = messages[i];
137
+ const msg = item as BlueBubblesMessageData;
138
+
139
+ // Skip messages without text content
140
+ const text = msg.text?.trim();
141
+ if (!text) {
142
+ continue;
143
+ }
144
+
145
+ const sender = msg.is_from_me
146
+ ? "me"
147
+ : msg.sender?.display_name || msg.sender?.address || msg.handle_id || "Unknown";
148
+ const timestamp = msg.date_created || msg.date_delivered;
149
+
150
+ historyEntries.push({
151
+ sender,
152
+ body: truncateHistoryBody(text),
153
+ timestamp,
154
+ messageId: msg.guid,
155
+ });
156
+ }
157
+
158
+ // Sort by timestamp (oldest first for context)
159
+ historyEntries.sort((a, b) => {
160
+ const aTime = a.timestamp || 0;
161
+ const bTime = b.timestamp || 0;
162
+ return aTime - bTime;
163
+ });
164
+
165
+ return {
166
+ entries: historyEntries.slice(0, effectiveLimit), // Ensure we don't exceed the requested limit
167
+ resolved: true,
168
+ };
169
+ } catch (error) {
170
+ // Continue to next path
171
+ continue;
172
+ }
173
+ }
174
+
175
+ // If none of the API paths worked, return empty history
176
+ return { entries: [], resolved: false };
177
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
3
+
4
+ describe("normalizeWebhookMessage", () => {
5
+ it("falls back to DM chatGuid handle when sender handle is missing", () => {
6
+ const result = normalizeWebhookMessage({
7
+ type: "new-message",
8
+ data: {
9
+ guid: "msg-1",
10
+ text: "hello",
11
+ isGroup: false,
12
+ isFromMe: false,
13
+ handle: null,
14
+ chatGuid: "iMessage;-;+15551234567",
15
+ },
16
+ });
17
+
18
+ expect(result).not.toBeNull();
19
+ expect(result?.senderId).toBe("+15551234567");
20
+ expect(result?.chatGuid).toBe("iMessage;-;+15551234567");
21
+ });
22
+
23
+ it("does not infer sender from group chatGuid when sender handle is missing", () => {
24
+ const result = normalizeWebhookMessage({
25
+ type: "new-message",
26
+ data: {
27
+ guid: "msg-1",
28
+ text: "hello group",
29
+ isGroup: true,
30
+ isFromMe: false,
31
+ handle: null,
32
+ chatGuid: "iMessage;+;chat123456",
33
+ },
34
+ });
35
+
36
+ expect(result).toBeNull();
37
+ });
38
+
39
+ it("accepts array-wrapped payload data", () => {
40
+ const result = normalizeWebhookMessage({
41
+ type: "new-message",
42
+ data: [
43
+ {
44
+ guid: "msg-1",
45
+ text: "hello",
46
+ handle: { address: "+15551234567" },
47
+ isGroup: false,
48
+ isFromMe: false,
49
+ },
50
+ ],
51
+ });
52
+
53
+ expect(result).not.toBeNull();
54
+ expect(result?.senderId).toBe("+15551234567");
55
+ });
56
+ });
57
+
58
+ describe("normalizeWebhookReaction", () => {
59
+ it("falls back to DM chatGuid handle when reaction sender handle is missing", () => {
60
+ const result = normalizeWebhookReaction({
61
+ type: "updated-message",
62
+ data: {
63
+ guid: "msg-2",
64
+ associatedMessageGuid: "p:0/msg-1",
65
+ associatedMessageType: 2000,
66
+ isGroup: false,
67
+ isFromMe: false,
68
+ handle: null,
69
+ chatGuid: "iMessage;-;+15551234567",
70
+ },
71
+ });
72
+
73
+ expect(result).not.toBeNull();
74
+ expect(result?.senderId).toBe("+15551234567");
75
+ expect(result?.messageId).toBe("p:0/msg-1");
76
+ expect(result?.action).toBe("added");
77
+ });
78
+ });
@@ -1,4 +1,4 @@
1
- import { normalizeBlueBubblesHandle } from "./targets.js";
1
+ import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
2
2
  import type { BlueBubblesAttachment } from "./types.js";
3
3
 
4
4
  function asRecord(value: unknown): Record<string, unknown> | null {
@@ -629,18 +629,42 @@ export function parseTapbackText(params: {
629
629
  }
630
630
 
631
631
  function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
632
+ const parseRecord = (value: unknown): Record<string, unknown> | null => {
633
+ const record = asRecord(value);
634
+ if (record) {
635
+ return record;
636
+ }
637
+ if (Array.isArray(value)) {
638
+ for (const entry of value) {
639
+ const parsedEntry = parseRecord(entry);
640
+ if (parsedEntry) {
641
+ return parsedEntry;
642
+ }
643
+ }
644
+ return null;
645
+ }
646
+ if (typeof value !== "string") {
647
+ return null;
648
+ }
649
+ const trimmed = value.trim();
650
+ if (!trimmed) {
651
+ return null;
652
+ }
653
+ try {
654
+ return parseRecord(JSON.parse(trimmed));
655
+ } catch {
656
+ return null;
657
+ }
658
+ };
659
+
632
660
  const dataRaw = payload.data ?? payload.payload ?? payload.event;
633
- const data =
634
- asRecord(dataRaw) ??
635
- (typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null);
661
+ const data = parseRecord(dataRaw);
636
662
  const messageRaw = payload.message ?? data?.message ?? data;
637
- const message =
638
- asRecord(messageRaw) ??
639
- (typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null);
640
- if (!message) {
641
- return null;
663
+ const message = parseRecord(messageRaw);
664
+ if (message) {
665
+ return message;
642
666
  }
643
- return message;
667
+ return null;
644
668
  }
645
669
 
646
670
  export function normalizeWebhookMessage(
@@ -700,7 +724,10 @@ export function normalizeWebhookMessage(
700
724
  : timestampRaw * 1000
701
725
  : undefined;
702
726
 
703
- const normalizedSender = normalizeBlueBubblesHandle(senderId);
727
+ // BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender.
728
+ const senderFallbackFromChatGuid =
729
+ !senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
730
+ const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
704
731
  if (!normalizedSender) {
705
732
  return null;
706
733
  }
@@ -774,7 +801,9 @@ export function normalizeWebhookReaction(
774
801
  : timestampRaw * 1000
775
802
  : undefined;
776
803
 
777
- const normalizedSender = normalizeBlueBubblesHandle(senderId);
804
+ const senderFallbackFromChatGuid =
805
+ !senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
806
+ const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
778
807
  if (!normalizedSender) {
779
808
  return null;
780
809
  }