@openclaw/bluebubbles 2026.2.9 → 2026.2.13
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/monitor-normalize.ts +842 -0
- package/src/monitor-processing.ts +979 -0
- package/src/monitor-reply-cache.ts +185 -0
- package/src/monitor-shared.ts +51 -0
- package/src/monitor.test.ts +42 -30
- package/src/monitor.ts +25 -2029
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
const REPLY_CACHE_MAX = 2000;
|
|
2
|
+
const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
3
|
+
|
|
4
|
+
type BlueBubblesReplyCacheEntry = {
|
|
5
|
+
accountId: string;
|
|
6
|
+
messageId: string;
|
|
7
|
+
shortId: string;
|
|
8
|
+
chatGuid?: string;
|
|
9
|
+
chatIdentifier?: string;
|
|
10
|
+
chatId?: number;
|
|
11
|
+
senderLabel?: string;
|
|
12
|
+
body?: string;
|
|
13
|
+
timestamp: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
|
|
17
|
+
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
|
|
18
|
+
|
|
19
|
+
// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
|
|
20
|
+
const blueBubblesShortIdToUuid = new Map<string, string>();
|
|
21
|
+
const blueBubblesUuidToShortId = new Map<string, string>();
|
|
22
|
+
let blueBubblesShortIdCounter = 0;
|
|
23
|
+
|
|
24
|
+
function trimOrUndefined(value?: string | null): string | undefined {
|
|
25
|
+
const trimmed = value?.trim();
|
|
26
|
+
return trimmed ? trimmed : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function generateShortId(): string {
|
|
30
|
+
blueBubblesShortIdCounter += 1;
|
|
31
|
+
return String(blueBubblesShortIdCounter);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function rememberBlueBubblesReplyCache(
|
|
35
|
+
entry: Omit<BlueBubblesReplyCacheEntry, "shortId">,
|
|
36
|
+
): BlueBubblesReplyCacheEntry {
|
|
37
|
+
const messageId = entry.messageId.trim();
|
|
38
|
+
if (!messageId) {
|
|
39
|
+
return { ...entry, shortId: "" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check if we already have a short ID for this GUID
|
|
43
|
+
let shortId = blueBubblesUuidToShortId.get(messageId);
|
|
44
|
+
if (!shortId) {
|
|
45
|
+
shortId = generateShortId();
|
|
46
|
+
blueBubblesShortIdToUuid.set(shortId, messageId);
|
|
47
|
+
blueBubblesUuidToShortId.set(messageId, shortId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
|
|
51
|
+
|
|
52
|
+
// Refresh insertion order.
|
|
53
|
+
blueBubblesReplyCacheByMessageId.delete(messageId);
|
|
54
|
+
blueBubblesReplyCacheByMessageId.set(messageId, fullEntry);
|
|
55
|
+
|
|
56
|
+
// Opportunistic prune.
|
|
57
|
+
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
|
|
58
|
+
for (const [key, value] of blueBubblesReplyCacheByMessageId) {
|
|
59
|
+
if (value.timestamp < cutoff) {
|
|
60
|
+
blueBubblesReplyCacheByMessageId.delete(key);
|
|
61
|
+
// Clean up short ID mappings for expired entries
|
|
62
|
+
if (value.shortId) {
|
|
63
|
+
blueBubblesShortIdToUuid.delete(value.shortId);
|
|
64
|
+
blueBubblesUuidToShortId.delete(key);
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
|
|
71
|
+
const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
|
|
72
|
+
if (!oldest) {
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
|
|
76
|
+
blueBubblesReplyCacheByMessageId.delete(oldest);
|
|
77
|
+
// Clean up short ID mappings for evicted entries
|
|
78
|
+
if (oldEntry?.shortId) {
|
|
79
|
+
blueBubblesShortIdToUuid.delete(oldEntry.shortId);
|
|
80
|
+
blueBubblesUuidToShortId.delete(oldest);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return fullEntry;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
|
|
89
|
+
* Returns the input unchanged if it's already a GUID or not found in the mapping.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveBlueBubblesMessageId(
|
|
92
|
+
shortOrUuid: string,
|
|
93
|
+
opts?: { requireKnownShortId?: boolean },
|
|
94
|
+
): string {
|
|
95
|
+
const trimmed = shortOrUuid.trim();
|
|
96
|
+
if (!trimmed) {
|
|
97
|
+
return trimmed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// If it looks like a short ID (numeric), try to resolve it
|
|
101
|
+
if (/^\d+$/.test(trimmed)) {
|
|
102
|
+
const uuid = blueBubblesShortIdToUuid.get(trimmed);
|
|
103
|
+
if (uuid) {
|
|
104
|
+
return uuid;
|
|
105
|
+
}
|
|
106
|
+
if (opts?.requireKnownShortId) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Return as-is (either already a UUID or not found)
|
|
114
|
+
return trimmed;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resets the short ID state. Only use in tests.
|
|
119
|
+
* @internal
|
|
120
|
+
*/
|
|
121
|
+
export function _resetBlueBubblesShortIdState(): void {
|
|
122
|
+
blueBubblesShortIdToUuid.clear();
|
|
123
|
+
blueBubblesUuidToShortId.clear();
|
|
124
|
+
blueBubblesReplyCacheByMessageId.clear();
|
|
125
|
+
blueBubblesShortIdCounter = 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Gets the short ID for a message GUID, if one exists.
|
|
130
|
+
*/
|
|
131
|
+
export function getShortIdForUuid(uuid: string): string | undefined {
|
|
132
|
+
return blueBubblesUuidToShortId.get(uuid.trim());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function resolveReplyContextFromCache(params: {
|
|
136
|
+
accountId: string;
|
|
137
|
+
replyToId: string;
|
|
138
|
+
chatGuid?: string;
|
|
139
|
+
chatIdentifier?: string;
|
|
140
|
+
chatId?: number;
|
|
141
|
+
}): BlueBubblesReplyCacheEntry | null {
|
|
142
|
+
const replyToId = params.replyToId.trim();
|
|
143
|
+
if (!replyToId) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
|
|
148
|
+
if (!cached) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
if (cached.accountId !== params.accountId) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
|
|
156
|
+
if (cached.timestamp < cutoff) {
|
|
157
|
+
blueBubblesReplyCacheByMessageId.delete(replyToId);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const chatGuid = trimOrUndefined(params.chatGuid);
|
|
162
|
+
const chatIdentifier = trimOrUndefined(params.chatIdentifier);
|
|
163
|
+
const cachedChatGuid = trimOrUndefined(cached.chatGuid);
|
|
164
|
+
const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier);
|
|
165
|
+
const chatId = typeof params.chatId === "number" ? params.chatId : undefined;
|
|
166
|
+
const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
|
|
167
|
+
|
|
168
|
+
// Avoid cross-chat collisions if we have identifiers.
|
|
169
|
+
if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
if (
|
|
173
|
+
!chatGuid &&
|
|
174
|
+
chatIdentifier &&
|
|
175
|
+
cachedChatIdentifier &&
|
|
176
|
+
chatIdentifier !== cachedChatIdentifier
|
|
177
|
+
) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return cached;
|
|
185
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
|
3
|
+
import type { BlueBubblesAccountConfig } from "./types.js";
|
|
4
|
+
import { getBlueBubblesRuntime } from "./runtime.js";
|
|
5
|
+
|
|
6
|
+
export type BlueBubblesRuntimeEnv = {
|
|
7
|
+
log?: (message: string) => void;
|
|
8
|
+
error?: (message: string) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type BlueBubblesMonitorOptions = {
|
|
12
|
+
account: ResolvedBlueBubblesAccount;
|
|
13
|
+
config: OpenClawConfig;
|
|
14
|
+
runtime: BlueBubblesRuntimeEnv;
|
|
15
|
+
abortSignal: AbortSignal;
|
|
16
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
17
|
+
webhookPath?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
|
|
21
|
+
|
|
22
|
+
export type WebhookTarget = {
|
|
23
|
+
account: ResolvedBlueBubblesAccount;
|
|
24
|
+
config: OpenClawConfig;
|
|
25
|
+
runtime: BlueBubblesRuntimeEnv;
|
|
26
|
+
core: BlueBubblesCoreRuntime;
|
|
27
|
+
path: string;
|
|
28
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
|
|
32
|
+
|
|
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
|
+
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
|
|
46
|
+
const raw = config?.webhookPath?.trim();
|
|
47
|
+
if (raw) {
|
|
48
|
+
return normalizeWebhookPath(raw);
|
|
49
|
+
}
|
|
50
|
+
return DEFAULT_WEBHOOK_PATH;
|
|
51
|
+
}
|
package/src/monitor.test.ts
CHANGED
|
@@ -254,9 +254,20 @@ function createMockRequest(
|
|
|
254
254
|
body: unknown,
|
|
255
255
|
headers: Record<string, string> = {},
|
|
256
256
|
): IncomingMessage {
|
|
257
|
+
const parsedUrl = new URL(url, "http://localhost");
|
|
258
|
+
const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
|
|
259
|
+
const hasAuthHeader =
|
|
260
|
+
headers["x-guid"] !== undefined ||
|
|
261
|
+
headers["x-password"] !== undefined ||
|
|
262
|
+
headers["x-bluebubbles-guid"] !== undefined ||
|
|
263
|
+
headers.authorization !== undefined;
|
|
264
|
+
if (!hasAuthQuery && !hasAuthHeader) {
|
|
265
|
+
parsedUrl.searchParams.set("password", "test-password");
|
|
266
|
+
}
|
|
267
|
+
|
|
257
268
|
const req = new EventEmitter() as IncomingMessage;
|
|
258
269
|
req.method = method;
|
|
259
|
-
req.url =
|
|
270
|
+
req.url = `${parsedUrl.pathname}${parsedUrl.search}`;
|
|
260
271
|
req.headers = headers;
|
|
261
272
|
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
|
|
262
273
|
|
|
@@ -393,7 +404,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
393
404
|
expect(res.statusCode).toBe(400);
|
|
394
405
|
});
|
|
395
406
|
|
|
396
|
-
it("returns
|
|
407
|
+
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
|
|
397
408
|
vi.useFakeTimers();
|
|
398
409
|
try {
|
|
399
410
|
const account = createMockAccount();
|
|
@@ -428,7 +439,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
428
439
|
|
|
429
440
|
const handled = await handledPromise;
|
|
430
441
|
expect(handled).toBe(true);
|
|
431
|
-
expect(res.statusCode).toBe(
|
|
442
|
+
expect(res.statusCode).toBe(408);
|
|
432
443
|
expect(req.destroy).toHaveBeenCalled();
|
|
433
444
|
} finally {
|
|
434
445
|
vi.useRealTimers();
|
|
@@ -546,40 +557,41 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
546
557
|
expect(res.statusCode).toBe(401);
|
|
547
558
|
});
|
|
548
559
|
|
|
549
|
-
it("
|
|
560
|
+
it("requires authentication for loopback requests when password is configured", async () => {
|
|
550
561
|
const account = createMockAccount({ password: "secret-token" });
|
|
551
562
|
const config: OpenClawConfig = {};
|
|
552
563
|
const core = createMockRuntime();
|
|
553
564
|
setBlueBubblesRuntime(core);
|
|
565
|
+
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
|
|
566
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", {
|
|
567
|
+
type: "new-message",
|
|
568
|
+
data: {
|
|
569
|
+
text: "hello",
|
|
570
|
+
handle: { address: "+15551234567" },
|
|
571
|
+
isGroup: false,
|
|
572
|
+
isFromMe: false,
|
|
573
|
+
guid: "msg-1",
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
577
|
+
remoteAddress,
|
|
578
|
+
};
|
|
554
579
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
guid: "msg-1",
|
|
563
|
-
},
|
|
564
|
-
});
|
|
565
|
-
// Localhost address
|
|
566
|
-
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
567
|
-
remoteAddress: "127.0.0.1",
|
|
568
|
-
};
|
|
569
|
-
|
|
570
|
-
unregister = registerBlueBubblesWebhookTarget({
|
|
571
|
-
account,
|
|
572
|
-
config,
|
|
573
|
-
runtime: { log: vi.fn(), error: vi.fn() },
|
|
574
|
-
core,
|
|
575
|
-
path: "/bluebubbles-webhook",
|
|
576
|
-
});
|
|
580
|
+
const loopbackUnregister = registerBlueBubblesWebhookTarget({
|
|
581
|
+
account,
|
|
582
|
+
config,
|
|
583
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
584
|
+
core,
|
|
585
|
+
path: "/bluebubbles-webhook",
|
|
586
|
+
});
|
|
577
587
|
|
|
578
|
-
|
|
579
|
-
|
|
588
|
+
const res = createMockResponse();
|
|
589
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
590
|
+
expect(handled).toBe(true);
|
|
591
|
+
expect(res.statusCode).toBe(401);
|
|
580
592
|
|
|
581
|
-
|
|
582
|
-
|
|
593
|
+
loopbackUnregister();
|
|
594
|
+
}
|
|
583
595
|
});
|
|
584
596
|
|
|
585
597
|
it("ignores unregistered webhook paths", async () => {
|