@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.
@@ -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
+ }
@@ -404,7 +404,7 @@ describe("BlueBubbles webhook monitor", () => {
404
404
  expect(res.statusCode).toBe(400);
405
405
  });
406
406
 
407
- it("returns 400 when request body times out (Slow-Loris protection)", async () => {
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(400);
442
+ expect(res.statusCode).toBe(408);
443
443
  expect(req.destroy).toHaveBeenCalled();
444
444
  } finally {
445
445
  vi.useRealTimers();