@openclaw/bluebubbles 2026.2.12 → 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 +2 -2
- package/src/monitor.ts +25 -2025
|
@@ -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
|
@@ -404,7 +404,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
404
404
|
expect(res.statusCode).toBe(400);
|
|
405
405
|
});
|
|
406
406
|
|
|
407
|
-
it("returns
|
|
407
|
+
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
|
|
408
408
|
vi.useFakeTimers();
|
|
409
409
|
try {
|
|
410
410
|
const account = createMockAccount();
|
|
@@ -439,7 +439,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
439
439
|
|
|
440
440
|
const handled = await handledPromise;
|
|
441
441
|
expect(handled).toBe(true);
|
|
442
|
-
expect(res.statusCode).toBe(
|
|
442
|
+
expect(res.statusCode).toBe(408);
|
|
443
443
|
expect(req.destroy).toHaveBeenCalled();
|
|
444
444
|
} finally {
|
|
445
445
|
vi.useRealTimers();
|