@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.
@@ -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
+ }
@@ -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 = 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 400 when request body times out (Slow-Loris protection)", async () => {
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(400);
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("allows localhost requests without authentication", async () => {
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
- const req = createMockRequest("POST", "/bluebubbles-webhook", {
556
- type: "new-message",
557
- data: {
558
- text: "hello",
559
- handle: { address: "+15551234567" },
560
- isGroup: false,
561
- isFromMe: false,
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
- const res = createMockResponse();
579
- const handled = await handleBlueBubblesWebhookRequest(req, res);
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
- expect(handled).toBe(true);
582
- expect(res.statusCode).toBe(200);
593
+ loopbackUnregister();
594
+ }
583
595
  });
584
596
 
585
597
  it("ignores unregistered webhook paths", async () => {