@openclaw/bluebubbles 2026.2.25 → 2026.3.2
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/README.md +1 -1
- package/index.ts +0 -2
- package/package.json +1 -1
- package/src/account-resolve.ts +19 -2
- package/src/accounts.test.ts +25 -0
- package/src/accounts.ts +18 -5
- package/src/actions.ts +4 -19
- package/src/attachments.test.ts +20 -2
- package/src/attachments.ts +15 -1
- package/src/channel.ts +3 -10
- package/src/config-schema.test.ts +12 -0
- package/src/config-schema.ts +5 -3
- package/src/monitor-debounce.ts +205 -0
- package/src/monitor-processing.ts +43 -22
- package/src/monitor.test.ts +87 -717
- package/src/monitor.ts +157 -364
- package/src/monitor.webhook-auth.test.ts +862 -0
- package/src/monitor.webhook-route.test.ts +44 -0
- package/src/onboarding.secret-input.test.ts +81 -0
- package/src/onboarding.ts +8 -2
- package/src/probe.ts +5 -4
- package/src/secret-input.ts +19 -0
- package/src/send-helpers.ts +34 -22
- package/src/send.test.ts +24 -0
- package/src/send.ts +7 -2
- package/src/targets.ts +2 -5
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ If you’re looking for **how to use BlueBubbles as an agent/tool user**, see:
|
|
|
10
10
|
|
|
11
11
|
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
|
|
12
12
|
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
|
|
13
|
-
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `
|
|
13
|
+
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register per-account route via `registerPluginHttpRoute`).
|
|
14
14
|
- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`.
|
|
15
15
|
- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`).
|
|
16
16
|
- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`.
|
package/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
2
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
3
|
import { bluebubblesPlugin } from "./src/channel.js";
|
|
4
|
-
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
|
|
5
4
|
import { setBlueBubblesRuntime } from "./src/runtime.js";
|
|
6
5
|
|
|
7
6
|
const plugin = {
|
|
@@ -12,7 +11,6 @@ const plugin = {
|
|
|
12
11
|
register(api: OpenClawPluginApi) {
|
|
13
12
|
setBlueBubblesRuntime(api.runtime);
|
|
14
13
|
api.registerChannel({ plugin: bluebubblesPlugin });
|
|
15
|
-
api.registerHttpHandler(handleBlueBubblesWebhookRequest);
|
|
16
14
|
},
|
|
17
15
|
};
|
|
18
16
|
|
package/package.json
CHANGED
package/src/account-resolve.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
2
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
|
3
|
+
import { normalizeResolvedSecretInputString } from "./secret-input.js";
|
|
3
4
|
|
|
4
5
|
export type BlueBubblesAccountResolveOpts = {
|
|
5
6
|
serverUrl?: string;
|
|
@@ -18,8 +19,24 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
|
|
|
18
19
|
cfg: params.cfg ?? {},
|
|
19
20
|
accountId: params.accountId,
|
|
20
21
|
});
|
|
21
|
-
const baseUrl =
|
|
22
|
-
|
|
22
|
+
const baseUrl =
|
|
23
|
+
normalizeResolvedSecretInputString({
|
|
24
|
+
value: params.serverUrl,
|
|
25
|
+
path: "channels.bluebubbles.serverUrl",
|
|
26
|
+
}) ||
|
|
27
|
+
normalizeResolvedSecretInputString({
|
|
28
|
+
value: account.config.serverUrl,
|
|
29
|
+
path: `channels.bluebubbles.accounts.${account.accountId}.serverUrl`,
|
|
30
|
+
});
|
|
31
|
+
const password =
|
|
32
|
+
normalizeResolvedSecretInputString({
|
|
33
|
+
value: params.password,
|
|
34
|
+
path: "channels.bluebubbles.password",
|
|
35
|
+
}) ||
|
|
36
|
+
normalizeResolvedSecretInputString({
|
|
37
|
+
value: account.config.password,
|
|
38
|
+
path: `channels.bluebubbles.accounts.${account.accountId}.password`,
|
|
39
|
+
});
|
|
23
40
|
if (!baseUrl) {
|
|
24
41
|
throw new Error("BlueBubbles serverUrl is required");
|
|
25
42
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveBlueBubblesAccount } from "./accounts.js";
|
|
3
|
+
|
|
4
|
+
describe("resolveBlueBubblesAccount", () => {
|
|
5
|
+
it("treats SecretRef passwords as configured when serverUrl exists", () => {
|
|
6
|
+
const resolved = resolveBlueBubblesAccount({
|
|
7
|
+
cfg: {
|
|
8
|
+
channels: {
|
|
9
|
+
bluebubbles: {
|
|
10
|
+
enabled: true,
|
|
11
|
+
serverUrl: "http://localhost:1234",
|
|
12
|
+
password: {
|
|
13
|
+
source: "env",
|
|
14
|
+
provider: "default",
|
|
15
|
+
id: "BLUEBUBBLES_PASSWORD",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(resolved.configured).toBe(true);
|
|
23
|
+
expect(resolved.baseUrl).toBe("http://localhost:1234");
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/accounts.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_ACCOUNT_ID,
|
|
4
|
+
normalizeAccountId,
|
|
5
|
+
normalizeOptionalAccountId,
|
|
6
|
+
} from "openclaw/plugin-sdk/account-id";
|
|
7
|
+
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
|
|
3
8
|
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
|
|
4
9
|
|
|
5
10
|
export type ResolvedBlueBubblesAccount = {
|
|
@@ -28,6 +33,13 @@ export function listBlueBubblesAccountIds(cfg: OpenClawConfig): string[] {
|
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
export function resolveDefaultBlueBubblesAccountId(cfg: OpenClawConfig): string {
|
|
36
|
+
const preferred = normalizeOptionalAccountId(cfg.channels?.bluebubbles?.defaultAccount);
|
|
37
|
+
if (
|
|
38
|
+
preferred &&
|
|
39
|
+
listBlueBubblesAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
|
|
40
|
+
) {
|
|
41
|
+
return preferred;
|
|
42
|
+
}
|
|
31
43
|
const ids = listBlueBubblesAccountIds(cfg);
|
|
32
44
|
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
|
33
45
|
return DEFAULT_ACCOUNT_ID;
|
|
@@ -52,8 +64,9 @@ function mergeBlueBubblesAccountConfig(
|
|
|
52
64
|
): BlueBubblesAccountConfig {
|
|
53
65
|
const base = (cfg.channels?.bluebubbles ?? {}) as BlueBubblesAccountConfig & {
|
|
54
66
|
accounts?: unknown;
|
|
67
|
+
defaultAccount?: unknown;
|
|
55
68
|
};
|
|
56
|
-
const { accounts: _ignored, ...rest } = base;
|
|
69
|
+
const { accounts: _ignored, defaultAccount: _ignoredDefaultAccount, ...rest } = base;
|
|
57
70
|
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
|
58
71
|
const chunkMode = account.chunkMode ?? rest.chunkMode ?? "length";
|
|
59
72
|
return { ...rest, ...account, chunkMode };
|
|
@@ -67,9 +80,9 @@ export function resolveBlueBubblesAccount(params: {
|
|
|
67
80
|
const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
|
|
68
81
|
const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
|
|
69
82
|
const accountEnabled = merged.enabled !== false;
|
|
70
|
-
const serverUrl = merged.serverUrl
|
|
71
|
-
const password = merged.password
|
|
72
|
-
const configured = Boolean(serverUrl && password);
|
|
83
|
+
const serverUrl = normalizeSecretInputString(merged.serverUrl);
|
|
84
|
+
const password = normalizeSecretInputString(merged.password);
|
|
85
|
+
const configured = Boolean(serverUrl && hasConfiguredSecretInput(merged.password));
|
|
73
86
|
const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined;
|
|
74
87
|
return {
|
|
75
88
|
accountId,
|
package/src/actions.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
extractToolSend,
|
|
6
6
|
jsonResult,
|
|
7
7
|
readNumberParam,
|
|
8
|
+
readBooleanParam,
|
|
8
9
|
readReactionParams,
|
|
9
10
|
readStringParam,
|
|
10
11
|
type ChannelMessageActionAdapter,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
|
25
26
|
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
|
|
26
27
|
import { sendBlueBubblesReaction } from "./reactions.js";
|
|
28
|
+
import { normalizeSecretInputString } from "./secret-input.js";
|
|
27
29
|
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
|
28
30
|
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
|
29
31
|
import type { BlueBubblesSendTarget } from "./types.js";
|
|
@@ -52,23 +54,6 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
|
|
|
52
54
|
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
|
56
|
-
const raw = params[key];
|
|
57
|
-
if (typeof raw === "boolean") {
|
|
58
|
-
return raw;
|
|
59
|
-
}
|
|
60
|
-
if (typeof raw === "string") {
|
|
61
|
-
const trimmed = raw.trim().toLowerCase();
|
|
62
|
-
if (trimmed === "true") {
|
|
63
|
-
return true;
|
|
64
|
-
}
|
|
65
|
-
if (trimmed === "false") {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return undefined;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
57
|
/** Supported action names for BlueBubbles */
|
|
73
58
|
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
|
74
59
|
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
|
|
@@ -118,8 +103,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
118
103
|
cfg: cfg,
|
|
119
104
|
accountId: accountId ?? undefined,
|
|
120
105
|
});
|
|
121
|
-
const baseUrl = account.config.serverUrl
|
|
122
|
-
const password = account.config.password
|
|
106
|
+
const baseUrl = normalizeSecretInputString(account.config.serverUrl);
|
|
107
|
+
const password = normalizeSecretInputString(account.config.password);
|
|
123
108
|
const opts = { cfg: cfg, accountId: accountId ?? undefined };
|
|
124
109
|
const assertPrivateApiEnabled = () => {
|
|
125
110
|
if (getCachedBlueBubblesPrivateApiStatus(account.accountId) === false) {
|
package/src/attachments.test.ts
CHANGED
|
@@ -294,7 +294,7 @@ describe("downloadBlueBubblesAttachment", () => {
|
|
|
294
294
|
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
|
|
295
295
|
});
|
|
296
296
|
|
|
297
|
-
it("
|
|
297
|
+
it("auto-allowlists serverUrl hostname when allowPrivateNetwork is not set", async () => {
|
|
298
298
|
const mockBuffer = new Uint8Array([1]);
|
|
299
299
|
mockFetch.mockResolvedValueOnce({
|
|
300
300
|
ok: true,
|
|
@@ -309,7 +309,25 @@ describe("downloadBlueBubblesAttachment", () => {
|
|
|
309
309
|
});
|
|
310
310
|
|
|
311
311
|
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
|
|
312
|
-
expect(fetchMediaArgs.ssrfPolicy).
|
|
312
|
+
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["localhost"] });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("auto-allowlists private IP serverUrl hostname when allowPrivateNetwork is not set", async () => {
|
|
316
|
+
const mockBuffer = new Uint8Array([1]);
|
|
317
|
+
mockFetch.mockResolvedValueOnce({
|
|
318
|
+
ok: true,
|
|
319
|
+
headers: new Headers(),
|
|
320
|
+
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const attachment: BlueBubblesAttachment = { guid: "att-private-ip" };
|
|
324
|
+
await downloadBlueBubblesAttachment(attachment, {
|
|
325
|
+
serverUrl: "http://192.168.1.5:1234",
|
|
326
|
+
password: "test",
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
|
|
330
|
+
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["192.168.1.5"] });
|
|
313
331
|
});
|
|
314
332
|
});
|
|
315
333
|
|
package/src/attachments.ts
CHANGED
|
@@ -62,6 +62,15 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
|
|
62
62
|
return resolveBlueBubblesServerAccount(params);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function safeExtractHostname(url: string): string | undefined {
|
|
66
|
+
try {
|
|
67
|
+
const hostname = new URL(url).hostname.trim();
|
|
68
|
+
return hostname || undefined;
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
65
74
|
type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
|
|
66
75
|
|
|
67
76
|
function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined {
|
|
@@ -89,12 +98,17 @@ export async function downloadBlueBubblesAttachment(
|
|
|
89
98
|
password,
|
|
90
99
|
});
|
|
91
100
|
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
|
|
101
|
+
const trustedHostname = safeExtractHostname(baseUrl);
|
|
92
102
|
try {
|
|
93
103
|
const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
|
|
94
104
|
url,
|
|
95
105
|
filePathHint: attachment.transferName ?? attachment.guid ?? "attachment",
|
|
96
106
|
maxBytes,
|
|
97
|
-
ssrfPolicy: allowPrivateNetwork
|
|
107
|
+
ssrfPolicy: allowPrivateNetwork
|
|
108
|
+
? { allowPrivateNetwork: true }
|
|
109
|
+
: trustedHostname
|
|
110
|
+
? { allowedHostnames: [trustedHostname] }
|
|
111
|
+
: undefined,
|
|
98
112
|
fetchImpl: async (input, init) =>
|
|
99
113
|
await blueBubblesFetchWithTimeout(
|
|
100
114
|
resolveRequestUrl(input),
|
package/src/channel.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "open
|
|
|
2
2
|
import {
|
|
3
3
|
applyAccountNameToChannelSection,
|
|
4
4
|
buildChannelConfigSchema,
|
|
5
|
+
buildProbeChannelStatusSummary,
|
|
5
6
|
collectBlueBubblesStatusIssues,
|
|
6
7
|
DEFAULT_ACCOUNT_ID,
|
|
7
8
|
deleteAccountFromConfigSection,
|
|
@@ -356,16 +357,8 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
356
357
|
lastError: null,
|
|
357
358
|
},
|
|
358
359
|
collectStatusIssues: collectBlueBubblesStatusIssues,
|
|
359
|
-
buildChannelSummary: ({ snapshot }) =>
|
|
360
|
-
|
|
361
|
-
baseUrl: snapshot.baseUrl ?? null,
|
|
362
|
-
running: snapshot.running ?? false,
|
|
363
|
-
lastStartAt: snapshot.lastStartAt ?? null,
|
|
364
|
-
lastStopAt: snapshot.lastStopAt ?? null,
|
|
365
|
-
lastError: snapshot.lastError ?? null,
|
|
366
|
-
probe: snapshot.probe,
|
|
367
|
-
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
368
|
-
}),
|
|
360
|
+
buildChannelSummary: ({ snapshot }) =>
|
|
361
|
+
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
|
|
369
362
|
probeAccount: async ({ account, timeoutMs }) =>
|
|
370
363
|
probeBlueBubbles({
|
|
371
364
|
baseUrl: account.baseUrl,
|
|
@@ -10,6 +10,18 @@ describe("BlueBubblesConfigSchema", () => {
|
|
|
10
10
|
expect(parsed.success).toBe(true);
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
it("accepts SecretRef password when serverUrl is set", () => {
|
|
14
|
+
const parsed = BlueBubblesConfigSchema.safeParse({
|
|
15
|
+
serverUrl: "http://localhost:1234",
|
|
16
|
+
password: {
|
|
17
|
+
source: "env",
|
|
18
|
+
provider: "default",
|
|
19
|
+
id: "BLUEBUBBLES_PASSWORD",
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
expect(parsed.success).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
13
25
|
it("requires password when top-level serverUrl is configured", () => {
|
|
14
26
|
const parsed = BlueBubblesConfigSchema.safeParse({
|
|
15
27
|
serverUrl: "http://localhost:1234",
|
package/src/config-schema.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
|
|
3
4
|
|
|
4
5
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
|
5
6
|
|
|
@@ -30,7 +31,7 @@ const bluebubblesAccountSchema = z
|
|
|
30
31
|
enabled: z.boolean().optional(),
|
|
31
32
|
markdown: MarkdownConfigSchema,
|
|
32
33
|
serverUrl: z.string().optional(),
|
|
33
|
-
password:
|
|
34
|
+
password: buildSecretInputSchema().optional(),
|
|
34
35
|
webhookPath: z.string().optional(),
|
|
35
36
|
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
|
36
37
|
allowFrom: z.array(allowFromEntry).optional(),
|
|
@@ -49,8 +50,8 @@ const bluebubblesAccountSchema = z
|
|
|
49
50
|
})
|
|
50
51
|
.superRefine((value, ctx) => {
|
|
51
52
|
const serverUrl = value.serverUrl?.trim() ?? "";
|
|
52
|
-
const
|
|
53
|
-
if (serverUrl && !
|
|
53
|
+
const passwordConfigured = hasConfiguredSecretInput(value.password);
|
|
54
|
+
if (serverUrl && !passwordConfigured) {
|
|
54
55
|
ctx.addIssue({
|
|
55
56
|
code: z.ZodIssueCode.custom,
|
|
56
57
|
path: ["password"],
|
|
@@ -61,5 +62,6 @@ const bluebubblesAccountSchema = z
|
|
|
61
62
|
|
|
62
63
|
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
|
|
63
64
|
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
|
|
65
|
+
defaultAccount: z.string().optional(),
|
|
64
66
|
actions: bluebubblesActionSchema,
|
|
65
67
|
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
|
|
3
|
+
import type { BlueBubblesCoreRuntime, WebhookTarget } from "./monitor-shared.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Entry type for debouncing inbound messages.
|
|
7
|
+
* Captures the normalized message and its target for later combined processing.
|
|
8
|
+
*/
|
|
9
|
+
type BlueBubblesDebounceEntry = {
|
|
10
|
+
message: NormalizedWebhookMessage;
|
|
11
|
+
target: WebhookTarget;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type BlueBubblesDebouncer = {
|
|
15
|
+
enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
|
|
16
|
+
flushKey: (key: string) => Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type BlueBubblesDebounceRegistry = {
|
|
20
|
+
getOrCreateDebouncer: (target: WebhookTarget) => BlueBubblesDebouncer;
|
|
21
|
+
removeDebouncer: (target: WebhookTarget) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default debounce window for inbound message coalescing (ms).
|
|
26
|
+
* This helps combine URL text + link preview balloon messages that BlueBubbles
|
|
27
|
+
* sends as separate webhook events when no explicit inbound debounce config exists.
|
|
28
|
+
*/
|
|
29
|
+
const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Combines multiple debounced messages into a single message for processing.
|
|
33
|
+
* Used when multiple webhook events arrive within the debounce window.
|
|
34
|
+
*/
|
|
35
|
+
function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
|
|
36
|
+
if (entries.length === 0) {
|
|
37
|
+
throw new Error("Cannot combine empty entries");
|
|
38
|
+
}
|
|
39
|
+
if (entries.length === 1) {
|
|
40
|
+
return entries[0].message;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Use the first message as the base (typically the text message)
|
|
44
|
+
const first = entries[0].message;
|
|
45
|
+
|
|
46
|
+
// Combine text from all entries, filtering out duplicates and empty strings
|
|
47
|
+
const seenTexts = new Set<string>();
|
|
48
|
+
const textParts: string[] = [];
|
|
49
|
+
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const text = entry.message.text.trim();
|
|
52
|
+
if (!text) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// Skip duplicate text (URL might be in both text message and balloon)
|
|
56
|
+
const normalizedText = text.toLowerCase();
|
|
57
|
+
if (seenTexts.has(normalizedText)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
seenTexts.add(normalizedText);
|
|
61
|
+
textParts.push(text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Merge attachments from all entries
|
|
65
|
+
const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
|
|
66
|
+
|
|
67
|
+
// Use the latest timestamp
|
|
68
|
+
const timestamps = entries
|
|
69
|
+
.map((e) => e.message.timestamp)
|
|
70
|
+
.filter((t): t is number => typeof t === "number");
|
|
71
|
+
const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
|
|
72
|
+
|
|
73
|
+
// Collect all message IDs for reference
|
|
74
|
+
const messageIds = entries
|
|
75
|
+
.map((e) => e.message.messageId)
|
|
76
|
+
.filter((id): id is string => Boolean(id));
|
|
77
|
+
|
|
78
|
+
// Prefer reply context from any entry that has it
|
|
79
|
+
const entryWithReply = entries.find((e) => e.message.replyToId);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
...first,
|
|
83
|
+
text: textParts.join(" "),
|
|
84
|
+
attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
|
|
85
|
+
timestamp: latestTimestamp,
|
|
86
|
+
// Use first message's ID as primary (for reply reference), but we've coalesced others
|
|
87
|
+
messageId: messageIds[0] ?? first.messageId,
|
|
88
|
+
// Preserve reply context if present
|
|
89
|
+
replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
|
|
90
|
+
replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
|
|
91
|
+
replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
|
|
92
|
+
// Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
|
|
93
|
+
balloonBundleId: undefined,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolveBlueBubblesDebounceMs(
|
|
98
|
+
config: OpenClawConfig,
|
|
99
|
+
core: BlueBubblesCoreRuntime,
|
|
100
|
+
): number {
|
|
101
|
+
const inbound = config.messages?.inbound;
|
|
102
|
+
const hasExplicitDebounce =
|
|
103
|
+
typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
|
|
104
|
+
if (!hasExplicitDebounce) {
|
|
105
|
+
return DEFAULT_INBOUND_DEBOUNCE_MS;
|
|
106
|
+
}
|
|
107
|
+
return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function createBlueBubblesDebounceRegistry(params: {
|
|
111
|
+
processMessage: (message: NormalizedWebhookMessage, target: WebhookTarget) => Promise<void>;
|
|
112
|
+
}): BlueBubblesDebounceRegistry {
|
|
113
|
+
const targetDebouncers = new Map<WebhookTarget, BlueBubblesDebouncer>();
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
getOrCreateDebouncer: (target) => {
|
|
117
|
+
const existing = targetDebouncers.get(target);
|
|
118
|
+
if (existing) {
|
|
119
|
+
return existing;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const { account, config, runtime, core } = target;
|
|
123
|
+
const debouncer = core.channel.debounce.createInboundDebouncer<BlueBubblesDebounceEntry>({
|
|
124
|
+
debounceMs: resolveBlueBubblesDebounceMs(config, core),
|
|
125
|
+
buildKey: (entry) => {
|
|
126
|
+
const msg = entry.message;
|
|
127
|
+
// Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
|
|
128
|
+
// same message (e.g., text-only then text+attachment).
|
|
129
|
+
//
|
|
130
|
+
// For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
|
|
131
|
+
// messageId than the originating text. When present, key by associatedMessageGuid
|
|
132
|
+
// to keep text + balloon coalescing working.
|
|
133
|
+
const balloonBundleId = msg.balloonBundleId?.trim();
|
|
134
|
+
const associatedMessageGuid = msg.associatedMessageGuid?.trim();
|
|
135
|
+
if (balloonBundleId && associatedMessageGuid) {
|
|
136
|
+
return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const messageId = msg.messageId?.trim();
|
|
140
|
+
if (messageId) {
|
|
141
|
+
return `bluebubbles:${account.accountId}:msg:${messageId}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const chatKey =
|
|
145
|
+
msg.chatGuid?.trim() ??
|
|
146
|
+
msg.chatIdentifier?.trim() ??
|
|
147
|
+
(msg.chatId ? String(msg.chatId) : "dm");
|
|
148
|
+
return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
|
|
149
|
+
},
|
|
150
|
+
shouldDebounce: (entry) => {
|
|
151
|
+
const msg = entry.message;
|
|
152
|
+
// Skip debouncing for from-me messages (they're just cached, not processed)
|
|
153
|
+
if (msg.fromMe) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
// Skip debouncing for control commands - process immediately
|
|
157
|
+
if (core.channel.text.hasControlCommand(msg.text, config)) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
// Debounce all other messages to coalesce rapid-fire webhook events
|
|
161
|
+
// (e.g., text+image arriving as separate webhooks for the same messageId)
|
|
162
|
+
return true;
|
|
163
|
+
},
|
|
164
|
+
onFlush: async (entries) => {
|
|
165
|
+
if (entries.length === 0) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Use target from first entry (all entries have same target due to key structure)
|
|
170
|
+
const flushTarget = entries[0].target;
|
|
171
|
+
|
|
172
|
+
if (entries.length === 1) {
|
|
173
|
+
// Single message - process normally
|
|
174
|
+
await params.processMessage(entries[0].message, flushTarget);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Multiple messages - combine and process
|
|
179
|
+
const combined = combineDebounceEntries(entries);
|
|
180
|
+
|
|
181
|
+
if (core.logging.shouldLogVerbose()) {
|
|
182
|
+
const count = entries.length;
|
|
183
|
+
const preview = combined.text.slice(0, 50);
|
|
184
|
+
runtime.log?.(
|
|
185
|
+
`[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await params.processMessage(combined, flushTarget);
|
|
190
|
+
},
|
|
191
|
+
onError: (err) => {
|
|
192
|
+
runtime.error?.(
|
|
193
|
+
`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`,
|
|
194
|
+
);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
targetDebouncers.set(target, debouncer);
|
|
199
|
+
return debouncer;
|
|
200
|
+
},
|
|
201
|
+
removeDebouncer: (target) => {
|
|
202
|
+
targetDebouncers.delete(target);
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|