@openclaw/bluebubbles 2026.2.12 → 2026.2.14
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/accounts.ts +1 -1
- package/src/actions.test.ts +52 -0
- package/src/actions.ts +34 -1
- package/src/attachments.test.ts +32 -0
- package/src/attachments.ts +11 -53
- package/src/chat.test.ts +40 -0
- package/src/chat.ts +32 -10
- package/src/config-schema.ts +1 -0
- package/src/media-send.test.ts +256 -0
- package/src/media-send.ts +150 -7
- package/src/monitor-normalize.ts +796 -0
- package/src/monitor-processing.ts +1007 -0
- package/src/monitor-reply-cache.ts +185 -0
- package/src/monitor-shared.ts +51 -0
- package/src/monitor.test.ts +330 -5
- package/src/monitor.ts +132 -2040
- package/src/probe.ts +12 -0
- package/src/reactions.ts +8 -2
- package/src/send-helpers.ts +53 -0
- package/src/send.test.ts +47 -0
- package/src/send.ts +18 -62
- package/src/targets.ts +50 -84
- package/src/types.ts +7 -1
|
@@ -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
|
@@ -67,6 +67,7 @@ const mockResolveEnvelopeFormatOptions = vi.fn(() => ({
|
|
|
67
67
|
template: "channel+name+time",
|
|
68
68
|
}));
|
|
69
69
|
const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
|
70
|
+
const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
|
70
71
|
const mockChunkMarkdownText = vi.fn((text: string) => [text]);
|
|
71
72
|
|
|
72
73
|
function createMockRuntime(): PluginRuntime {
|
|
@@ -124,12 +125,13 @@ function createMockRuntime(): PluginRuntime {
|
|
|
124
125
|
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
|
|
125
126
|
dispatchReplyFromConfig:
|
|
126
127
|
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
|
|
127
|
-
finalizeInboundContext:
|
|
128
|
-
|
|
128
|
+
finalizeInboundContext: vi.fn(
|
|
129
|
+
(ctx: Record<string, unknown>) => ctx,
|
|
130
|
+
) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
|
129
131
|
formatAgentEnvelope:
|
|
130
132
|
mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
|
|
131
133
|
formatInboundEnvelope:
|
|
132
|
-
|
|
134
|
+
mockFormatInboundEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
|
|
133
135
|
resolveEnvelopeFormatOptions:
|
|
134
136
|
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
|
135
137
|
},
|
|
@@ -254,6 +256,9 @@ function createMockRequest(
|
|
|
254
256
|
body: unknown,
|
|
255
257
|
headers: Record<string, string> = {},
|
|
256
258
|
): IncomingMessage {
|
|
259
|
+
if (headers.host === undefined) {
|
|
260
|
+
headers.host = "localhost";
|
|
261
|
+
}
|
|
257
262
|
const parsedUrl = new URL(url, "http://localhost");
|
|
258
263
|
const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
|
|
259
264
|
const hasAuthHeader =
|
|
@@ -404,7 +409,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
404
409
|
expect(res.statusCode).toBe(400);
|
|
405
410
|
});
|
|
406
411
|
|
|
407
|
-
it("returns
|
|
412
|
+
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
|
|
408
413
|
vi.useFakeTimers();
|
|
409
414
|
try {
|
|
410
415
|
const account = createMockAccount();
|
|
@@ -439,7 +444,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
439
444
|
|
|
440
445
|
const handled = await handledPromise;
|
|
441
446
|
expect(handled).toBe(true);
|
|
442
|
-
expect(res.statusCode).toBe(
|
|
447
|
+
expect(res.statusCode).toBe(408);
|
|
443
448
|
expect(req.destroy).toHaveBeenCalled();
|
|
444
449
|
} finally {
|
|
445
450
|
vi.useRealTimers();
|
|
@@ -557,6 +562,114 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
557
562
|
expect(res.statusCode).toBe(401);
|
|
558
563
|
});
|
|
559
564
|
|
|
565
|
+
it("rejects ambiguous routing when multiple targets match the same password", async () => {
|
|
566
|
+
const accountA = createMockAccount({ password: "secret-token" });
|
|
567
|
+
const accountB = createMockAccount({ password: "secret-token" });
|
|
568
|
+
const config: OpenClawConfig = {};
|
|
569
|
+
const core = createMockRuntime();
|
|
570
|
+
setBlueBubblesRuntime(core);
|
|
571
|
+
|
|
572
|
+
const sinkA = vi.fn();
|
|
573
|
+
const sinkB = vi.fn();
|
|
574
|
+
|
|
575
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
|
576
|
+
type: "new-message",
|
|
577
|
+
data: {
|
|
578
|
+
text: "hello",
|
|
579
|
+
handle: { address: "+15551234567" },
|
|
580
|
+
isGroup: false,
|
|
581
|
+
isFromMe: false,
|
|
582
|
+
guid: "msg-1",
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
586
|
+
remoteAddress: "192.168.1.100",
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const unregisterA = registerBlueBubblesWebhookTarget({
|
|
590
|
+
account: accountA,
|
|
591
|
+
config,
|
|
592
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
593
|
+
core,
|
|
594
|
+
path: "/bluebubbles-webhook",
|
|
595
|
+
statusSink: sinkA,
|
|
596
|
+
});
|
|
597
|
+
const unregisterB = registerBlueBubblesWebhookTarget({
|
|
598
|
+
account: accountB,
|
|
599
|
+
config,
|
|
600
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
601
|
+
core,
|
|
602
|
+
path: "/bluebubbles-webhook",
|
|
603
|
+
statusSink: sinkB,
|
|
604
|
+
});
|
|
605
|
+
unregister = () => {
|
|
606
|
+
unregisterA();
|
|
607
|
+
unregisterB();
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const res = createMockResponse();
|
|
611
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
612
|
+
|
|
613
|
+
expect(handled).toBe(true);
|
|
614
|
+
expect(res.statusCode).toBe(401);
|
|
615
|
+
expect(sinkA).not.toHaveBeenCalled();
|
|
616
|
+
expect(sinkB).not.toHaveBeenCalled();
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it("does not route to passwordless targets when a password-authenticated target matches", async () => {
|
|
620
|
+
const accountStrict = createMockAccount({ password: "secret-token" });
|
|
621
|
+
const accountFallback = createMockAccount({ password: undefined });
|
|
622
|
+
const config: OpenClawConfig = {};
|
|
623
|
+
const core = createMockRuntime();
|
|
624
|
+
setBlueBubblesRuntime(core);
|
|
625
|
+
|
|
626
|
+
const sinkStrict = vi.fn();
|
|
627
|
+
const sinkFallback = vi.fn();
|
|
628
|
+
|
|
629
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
|
630
|
+
type: "new-message",
|
|
631
|
+
data: {
|
|
632
|
+
text: "hello",
|
|
633
|
+
handle: { address: "+15551234567" },
|
|
634
|
+
isGroup: false,
|
|
635
|
+
isFromMe: false,
|
|
636
|
+
guid: "msg-1",
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
640
|
+
remoteAddress: "192.168.1.100",
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const unregisterStrict = registerBlueBubblesWebhookTarget({
|
|
644
|
+
account: accountStrict,
|
|
645
|
+
config,
|
|
646
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
647
|
+
core,
|
|
648
|
+
path: "/bluebubbles-webhook",
|
|
649
|
+
statusSink: sinkStrict,
|
|
650
|
+
});
|
|
651
|
+
const unregisterFallback = registerBlueBubblesWebhookTarget({
|
|
652
|
+
account: accountFallback,
|
|
653
|
+
config,
|
|
654
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
655
|
+
core,
|
|
656
|
+
path: "/bluebubbles-webhook",
|
|
657
|
+
statusSink: sinkFallback,
|
|
658
|
+
});
|
|
659
|
+
unregister = () => {
|
|
660
|
+
unregisterStrict();
|
|
661
|
+
unregisterFallback();
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
const res = createMockResponse();
|
|
665
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
666
|
+
|
|
667
|
+
expect(handled).toBe(true);
|
|
668
|
+
expect(res.statusCode).toBe(200);
|
|
669
|
+
expect(sinkStrict).toHaveBeenCalledTimes(1);
|
|
670
|
+
expect(sinkFallback).not.toHaveBeenCalled();
|
|
671
|
+
});
|
|
672
|
+
|
|
560
673
|
it("requires authentication for loopback requests when password is configured", async () => {
|
|
561
674
|
const account = createMockAccount({ password: "secret-token" });
|
|
562
675
|
const config: OpenClawConfig = {};
|
|
@@ -594,6 +707,79 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
594
707
|
}
|
|
595
708
|
});
|
|
596
709
|
|
|
710
|
+
it("rejects passwordless targets when the request looks proxied (has forwarding headers)", async () => {
|
|
711
|
+
const account = createMockAccount({ password: undefined });
|
|
712
|
+
const config: OpenClawConfig = {};
|
|
713
|
+
const core = createMockRuntime();
|
|
714
|
+
setBlueBubblesRuntime(core);
|
|
715
|
+
|
|
716
|
+
const req = createMockRequest(
|
|
717
|
+
"POST",
|
|
718
|
+
"/bluebubbles-webhook",
|
|
719
|
+
{
|
|
720
|
+
type: "new-message",
|
|
721
|
+
data: {
|
|
722
|
+
text: "hello",
|
|
723
|
+
handle: { address: "+15551234567" },
|
|
724
|
+
isGroup: false,
|
|
725
|
+
isFromMe: false,
|
|
726
|
+
guid: "msg-1",
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
{ "x-forwarded-for": "203.0.113.10", host: "localhost" },
|
|
730
|
+
);
|
|
731
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
732
|
+
remoteAddress: "127.0.0.1",
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
736
|
+
account,
|
|
737
|
+
config,
|
|
738
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
739
|
+
core,
|
|
740
|
+
path: "/bluebubbles-webhook",
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
const res = createMockResponse();
|
|
744
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
745
|
+
expect(handled).toBe(true);
|
|
746
|
+
expect(res.statusCode).toBe(401);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("accepts passwordless targets for direct localhost loopback requests (no forwarding headers)", async () => {
|
|
750
|
+
const account = createMockAccount({ password: undefined });
|
|
751
|
+
const config: OpenClawConfig = {};
|
|
752
|
+
const core = createMockRuntime();
|
|
753
|
+
setBlueBubblesRuntime(core);
|
|
754
|
+
|
|
755
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", {
|
|
756
|
+
type: "new-message",
|
|
757
|
+
data: {
|
|
758
|
+
text: "hello",
|
|
759
|
+
handle: { address: "+15551234567" },
|
|
760
|
+
isGroup: false,
|
|
761
|
+
isFromMe: false,
|
|
762
|
+
guid: "msg-1",
|
|
763
|
+
},
|
|
764
|
+
});
|
|
765
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
766
|
+
remoteAddress: "127.0.0.1",
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
770
|
+
account,
|
|
771
|
+
config,
|
|
772
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
773
|
+
core,
|
|
774
|
+
path: "/bluebubbles-webhook",
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
const res = createMockResponse();
|
|
778
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
779
|
+
expect(handled).toBe(true);
|
|
780
|
+
expect(res.statusCode).toBe(200);
|
|
781
|
+
});
|
|
782
|
+
|
|
597
783
|
it("ignores unregistered webhook paths", async () => {
|
|
598
784
|
const req = createMockRequest("POST", "/unregistered-path", {});
|
|
599
785
|
const res = createMockResponse();
|
|
@@ -1261,6 +1447,145 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
1261
1447
|
});
|
|
1262
1448
|
});
|
|
1263
1449
|
|
|
1450
|
+
describe("group sender identity in envelope", () => {
|
|
1451
|
+
it("includes sender in envelope body and group label as from for group messages", async () => {
|
|
1452
|
+
const account = createMockAccount({ groupPolicy: "open" });
|
|
1453
|
+
const config: OpenClawConfig = {};
|
|
1454
|
+
const core = createMockRuntime();
|
|
1455
|
+
setBlueBubblesRuntime(core);
|
|
1456
|
+
|
|
1457
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
1458
|
+
account,
|
|
1459
|
+
config,
|
|
1460
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
1461
|
+
core,
|
|
1462
|
+
path: "/bluebubbles-webhook",
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
const payload = {
|
|
1466
|
+
type: "new-message",
|
|
1467
|
+
data: {
|
|
1468
|
+
text: "hello everyone",
|
|
1469
|
+
handle: { address: "+15551234567" },
|
|
1470
|
+
senderName: "Alice",
|
|
1471
|
+
isGroup: true,
|
|
1472
|
+
isFromMe: false,
|
|
1473
|
+
guid: "msg-1",
|
|
1474
|
+
chatGuid: "iMessage;+;chat123456",
|
|
1475
|
+
chatName: "Family Chat",
|
|
1476
|
+
date: Date.now(),
|
|
1477
|
+
},
|
|
1478
|
+
};
|
|
1479
|
+
|
|
1480
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
1481
|
+
const res = createMockResponse();
|
|
1482
|
+
|
|
1483
|
+
await handleBlueBubblesWebhookRequest(req, res);
|
|
1484
|
+
await flushAsync();
|
|
1485
|
+
|
|
1486
|
+
// formatInboundEnvelope should be called with group label + id as from, and sender info
|
|
1487
|
+
expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
|
|
1488
|
+
expect.objectContaining({
|
|
1489
|
+
from: "Family Chat id:iMessage;+;chat123456",
|
|
1490
|
+
chatType: "group",
|
|
1491
|
+
sender: { name: "Alice", id: "+15551234567" },
|
|
1492
|
+
}),
|
|
1493
|
+
);
|
|
1494
|
+
// ConversationLabel should be the group label + id, not the sender
|
|
1495
|
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
|
1496
|
+
expect(callArgs.ctx.ConversationLabel).toBe("Family Chat id:iMessage;+;chat123456");
|
|
1497
|
+
expect(callArgs.ctx.SenderName).toBe("Alice");
|
|
1498
|
+
// BodyForAgent should be raw text, not the envelope-formatted body
|
|
1499
|
+
expect(callArgs.ctx.BodyForAgent).toBe("hello everyone");
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
it("falls back to group:peerId when chatName is missing", async () => {
|
|
1503
|
+
const account = createMockAccount({ groupPolicy: "open" });
|
|
1504
|
+
const config: OpenClawConfig = {};
|
|
1505
|
+
const core = createMockRuntime();
|
|
1506
|
+
setBlueBubblesRuntime(core);
|
|
1507
|
+
|
|
1508
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
1509
|
+
account,
|
|
1510
|
+
config,
|
|
1511
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
1512
|
+
core,
|
|
1513
|
+
path: "/bluebubbles-webhook",
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
const payload = {
|
|
1517
|
+
type: "new-message",
|
|
1518
|
+
data: {
|
|
1519
|
+
text: "hello",
|
|
1520
|
+
handle: { address: "+15551234567" },
|
|
1521
|
+
isGroup: true,
|
|
1522
|
+
isFromMe: false,
|
|
1523
|
+
guid: "msg-1",
|
|
1524
|
+
chatGuid: "iMessage;+;chat123456",
|
|
1525
|
+
date: Date.now(),
|
|
1526
|
+
},
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
1530
|
+
const res = createMockResponse();
|
|
1531
|
+
|
|
1532
|
+
await handleBlueBubblesWebhookRequest(req, res);
|
|
1533
|
+
await flushAsync();
|
|
1534
|
+
|
|
1535
|
+
expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
|
|
1536
|
+
expect.objectContaining({
|
|
1537
|
+
from: expect.stringMatching(/^Group id:/),
|
|
1538
|
+
chatType: "group",
|
|
1539
|
+
sender: { name: undefined, id: "+15551234567" },
|
|
1540
|
+
}),
|
|
1541
|
+
);
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
it("uses sender as from label for DM messages", async () => {
|
|
1545
|
+
const account = createMockAccount();
|
|
1546
|
+
const config: OpenClawConfig = {};
|
|
1547
|
+
const core = createMockRuntime();
|
|
1548
|
+
setBlueBubblesRuntime(core);
|
|
1549
|
+
|
|
1550
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
1551
|
+
account,
|
|
1552
|
+
config,
|
|
1553
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
1554
|
+
core,
|
|
1555
|
+
path: "/bluebubbles-webhook",
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
const payload = {
|
|
1559
|
+
type: "new-message",
|
|
1560
|
+
data: {
|
|
1561
|
+
text: "hello",
|
|
1562
|
+
handle: { address: "+15551234567" },
|
|
1563
|
+
senderName: "Alice",
|
|
1564
|
+
isGroup: false,
|
|
1565
|
+
isFromMe: false,
|
|
1566
|
+
guid: "msg-1",
|
|
1567
|
+
date: Date.now(),
|
|
1568
|
+
},
|
|
1569
|
+
};
|
|
1570
|
+
|
|
1571
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
1572
|
+
const res = createMockResponse();
|
|
1573
|
+
|
|
1574
|
+
await handleBlueBubblesWebhookRequest(req, res);
|
|
1575
|
+
await flushAsync();
|
|
1576
|
+
|
|
1577
|
+
expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
|
|
1578
|
+
expect.objectContaining({
|
|
1579
|
+
from: "Alice id:+15551234567",
|
|
1580
|
+
chatType: "direct",
|
|
1581
|
+
sender: { name: "Alice", id: "+15551234567" },
|
|
1582
|
+
}),
|
|
1583
|
+
);
|
|
1584
|
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
|
1585
|
+
expect(callArgs.ctx.ConversationLabel).toBe("Alice id:+15551234567");
|
|
1586
|
+
});
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1264
1589
|
describe("inbound debouncing", () => {
|
|
1265
1590
|
it("coalesces text-only then attachment webhook events by messageId", async () => {
|
|
1266
1591
|
vi.useFakeTimers();
|