@openclaw/bluebubbles 2026.3.1 → 2026.3.7

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 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 `api.registerHttpHandler`).
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
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/bluebubbles";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/bluebubbles";
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
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@openclaw/bluebubbles",
3
- "version": "2026.3.1",
3
+ "version": "2026.3.7",
4
4
  "description": "OpenClaw BlueBubbles channel plugin",
5
5
  "type": "module",
6
+ "dependencies": {
7
+ "zod": "^4.3.6"
8
+ },
6
9
  "openclaw": {
7
10
  "extensions": [
8
11
  "./index.ts"
@@ -1,5 +1,6 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
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 = params.serverUrl?.trim() || account.config.serverUrl?.trim();
22
- const password = params.password?.trim() || account.config.password?.trim();
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,9 +1,6 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
- import {
3
- DEFAULT_ACCOUNT_ID,
4
- normalizeAccountId,
5
- normalizeOptionalAccountId,
6
- } from "openclaw/plugin-sdk/account-id";
1
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
2
+ import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
3
+ import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
7
4
  import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
8
5
 
9
6
  export type ResolvedBlueBubblesAccount = {
@@ -15,36 +12,11 @@ export type ResolvedBlueBubblesAccount = {
15
12
  baseUrl?: string;
16
13
  };
17
14
 
18
- function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
19
- const accounts = cfg.channels?.bluebubbles?.accounts;
20
- if (!accounts || typeof accounts !== "object") {
21
- return [];
22
- }
23
- return Object.keys(accounts).filter(Boolean);
24
- }
25
-
26
- export function listBlueBubblesAccountIds(cfg: OpenClawConfig): string[] {
27
- const ids = listConfiguredAccountIds(cfg);
28
- if (ids.length === 0) {
29
- return [DEFAULT_ACCOUNT_ID];
30
- }
31
- return ids.toSorted((a, b) => a.localeCompare(b));
32
- }
33
-
34
- export function resolveDefaultBlueBubblesAccountId(cfg: OpenClawConfig): string {
35
- const preferred = normalizeOptionalAccountId(cfg.channels?.bluebubbles?.defaultAccount);
36
- if (
37
- preferred &&
38
- listBlueBubblesAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
39
- ) {
40
- return preferred;
41
- }
42
- const ids = listBlueBubblesAccountIds(cfg);
43
- if (ids.includes(DEFAULT_ACCOUNT_ID)) {
44
- return DEFAULT_ACCOUNT_ID;
45
- }
46
- return ids[0] ?? DEFAULT_ACCOUNT_ID;
47
- }
15
+ const {
16
+ listAccountIds: listBlueBubblesAccountIds,
17
+ resolveDefaultAccountId: resolveDefaultBlueBubblesAccountId,
18
+ } = createAccountListHelpers("bluebubbles");
19
+ export { listBlueBubblesAccountIds, resolveDefaultBlueBubblesAccountId };
48
20
 
49
21
  function resolveAccountConfig(
50
22
  cfg: OpenClawConfig,
@@ -79,9 +51,9 @@ export function resolveBlueBubblesAccount(params: {
79
51
  const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
80
52
  const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
81
53
  const accountEnabled = merged.enabled !== false;
82
- const serverUrl = merged.serverUrl?.trim();
83
- const password = merged.password?.trim();
84
- const configured = Boolean(serverUrl && password);
54
+ const serverUrl = normalizeSecretInputString(merged.serverUrl);
55
+ const password = normalizeSecretInputString(merged.password);
56
+ const configured = Boolean(serverUrl && hasConfiguredSecretInput(merged.password));
85
57
  const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined;
86
58
  return {
87
59
  accountId,
@@ -1,4 +1,4 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
2
2
  import { describe, expect, it, vi, beforeEach } from "vitest";
3
3
  import { bluebubblesMessageActions } from "./actions.js";
4
4
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
package/src/actions.ts CHANGED
@@ -5,11 +5,12 @@ import {
5
5
  extractToolSend,
6
6
  jsonResult,
7
7
  readNumberParam,
8
+ readBooleanParam,
8
9
  readReactionParams,
9
10
  readStringParam,
10
11
  type ChannelMessageActionAdapter,
11
12
  type ChannelMessageActionName,
12
- } from "openclaw/plugin-sdk";
13
+ } from "openclaw/plugin-sdk/bluebubbles";
13
14
  import { resolveBlueBubblesAccount } from "./accounts.js";
14
15
  import { sendBlueBubblesAttachment } from "./attachments.js";
15
16
  import {
@@ -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?.trim();
122
- const password = account.config.password?.trim();
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) {
@@ -1,4 +1,4 @@
1
- import type { PluginRuntime } from "openclaw/plugin-sdk";
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import "./test-mocks.js";
4
4
  import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import path from "node:path";
3
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
4
4
  import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
5
5
  import { postMultipartFormData } from "./multipart.js";
6
6
  import {
package/src/channel.ts CHANGED
@@ -1,18 +1,29 @@
1
- import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type {
2
+ ChannelAccountSnapshot,
3
+ ChannelPlugin,
4
+ OpenClawConfig,
5
+ } from "openclaw/plugin-sdk/bluebubbles";
2
6
  import {
3
7
  applyAccountNameToChannelSection,
4
8
  buildChannelConfigSchema,
9
+ buildComputedAccountStatusSnapshot,
10
+ buildProbeChannelStatusSummary,
5
11
  collectBlueBubblesStatusIssues,
6
12
  DEFAULT_ACCOUNT_ID,
7
13
  deleteAccountFromConfigSection,
8
- formatPairingApproveHint,
9
14
  migrateBaseNameToDefaultAccount,
10
15
  normalizeAccountId,
11
16
  PAIRING_APPROVED_MESSAGE,
12
17
  resolveBlueBubblesGroupRequireMention,
13
18
  resolveBlueBubblesGroupToolPolicy,
14
19
  setAccountEnabledInConfigSection,
15
- } from "openclaw/plugin-sdk";
20
+ } from "openclaw/plugin-sdk/bluebubbles";
21
+ import {
22
+ buildAccountScopedDmSecurityPolicy,
23
+ collectOpenGroupPolicyRestrictSendersWarnings,
24
+ formatNormalizedAllowFromEntries,
25
+ mapAllowFromEntries,
26
+ } from "openclaw/plugin-sdk/compat";
16
27
  import {
17
28
  listBlueBubblesAccountIds,
18
29
  type ResolvedBlueBubblesAccount,
@@ -20,6 +31,7 @@ import {
20
31
  resolveDefaultBlueBubblesAccountId,
21
32
  } from "./accounts.js";
22
33
  import { bluebubblesMessageActions } from "./actions.js";
34
+ import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
23
35
  import { BlueBubblesConfigSchema } from "./config-schema.js";
24
36
  import { sendBlueBubblesMedia } from "./media-send.js";
25
37
  import { resolveBlueBubblesMessageId } from "./monitor.js";
@@ -104,41 +116,37 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
104
116
  baseUrl: account.baseUrl,
105
117
  }),
106
118
  resolveAllowFrom: ({ cfg, accountId }) =>
107
- (resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
108
- String(entry),
109
- ),
119
+ mapAllowFromEntries(resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom),
110
120
  formatAllowFrom: ({ allowFrom }) =>
111
- allowFrom
112
- .map((entry) => String(entry).trim())
113
- .filter(Boolean)
114
- .map((entry) => entry.replace(/^bluebubbles:/i, ""))
115
- .map((entry) => normalizeBlueBubblesHandle(entry)),
121
+ formatNormalizedAllowFromEntries({
122
+ allowFrom,
123
+ normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
124
+ }),
116
125
  },
117
126
  actions: bluebubblesMessageActions,
118
127
  security: {
119
128
  resolveDmPolicy: ({ cfg, accountId, account }) => {
120
- const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
121
- const useAccountPath = Boolean(cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId]);
122
- const basePath = useAccountPath
123
- ? `channels.bluebubbles.accounts.${resolvedAccountId}.`
124
- : "channels.bluebubbles.";
125
- return {
126
- policy: account.config.dmPolicy ?? "pairing",
129
+ return buildAccountScopedDmSecurityPolicy({
130
+ cfg,
131
+ channelKey: "bluebubbles",
132
+ accountId,
133
+ fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
134
+ policy: account.config.dmPolicy,
127
135
  allowFrom: account.config.allowFrom ?? [],
128
- policyPath: `${basePath}dmPolicy`,
129
- allowFromPath: basePath,
130
- approveHint: formatPairingApproveHint("bluebubbles"),
136
+ policyPathSuffix: "dmPolicy",
131
137
  normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
132
- };
138
+ });
133
139
  },
134
140
  collectWarnings: ({ account }) => {
135
141
  const groupPolicy = account.config.groupPolicy ?? "allowlist";
136
- if (groupPolicy !== "open") {
137
- return [];
138
- }
139
- return [
140
- `- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`,
141
- ];
142
+ return collectOpenGroupPolicyRestrictSendersWarnings({
143
+ groupPolicy,
144
+ surface: "BlueBubbles groups",
145
+ openScope: "any member",
146
+ groupPolicyPath: "channels.bluebubbles.groupPolicy",
147
+ groupAllowFromPath: "channels.bluebubbles.groupAllowFrom",
148
+ mentionGated: false,
149
+ });
142
150
  },
143
151
  },
144
152
  messaging: {
@@ -249,41 +257,16 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
249
257
  channelKey: "bluebubbles",
250
258
  })
251
259
  : namedConfig;
252
- if (accountId === DEFAULT_ACCOUNT_ID) {
253
- return {
254
- ...next,
255
- channels: {
256
- ...next.channels,
257
- bluebubbles: {
258
- ...next.channels?.bluebubbles,
259
- enabled: true,
260
- ...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
261
- ...(input.password ? { password: input.password } : {}),
262
- ...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
263
- },
264
- },
265
- } as OpenClawConfig;
266
- }
267
- return {
268
- ...next,
269
- channels: {
270
- ...next.channels,
271
- bluebubbles: {
272
- ...next.channels?.bluebubbles,
273
- enabled: true,
274
- accounts: {
275
- ...next.channels?.bluebubbles?.accounts,
276
- [accountId]: {
277
- ...next.channels?.bluebubbles?.accounts?.[accountId],
278
- enabled: true,
279
- ...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
280
- ...(input.password ? { password: input.password } : {}),
281
- ...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
282
- },
283
- },
284
- },
260
+ return applyBlueBubblesConnectionConfig({
261
+ cfg: next,
262
+ accountId,
263
+ patch: {
264
+ serverUrl: input.httpUrl,
265
+ password: input.password,
266
+ webhookPath: input.webhookPath,
285
267
  },
286
- } as OpenClawConfig;
268
+ onlyDefinedFields: true,
269
+ });
287
270
  },
288
271
  },
289
272
  pairing: {
@@ -356,16 +339,8 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
356
339
  lastError: null,
357
340
  },
358
341
  collectStatusIssues: collectBlueBubblesStatusIssues,
359
- buildChannelSummary: ({ snapshot }) => ({
360
- configured: snapshot.configured ?? false,
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
- }),
342
+ buildChannelSummary: ({ snapshot }) =>
343
+ buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
369
344
  probeAccount: async ({ account, timeoutMs }) =>
370
345
  probeBlueBubbles({
371
346
  baseUrl: account.baseUrl,
@@ -375,20 +350,18 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
375
350
  buildAccountSnapshot: ({ account, runtime, probe }) => {
376
351
  const running = runtime?.running ?? false;
377
352
  const probeOk = (probe as BlueBubblesProbe | undefined)?.ok;
378
- return {
353
+ const base = buildComputedAccountStatusSnapshot({
379
354
  accountId: account.accountId,
380
355
  name: account.name,
381
356
  enabled: account.enabled,
382
357
  configured: account.configured,
358
+ runtime,
359
+ probe,
360
+ });
361
+ return {
362
+ ...base,
383
363
  baseUrl: account.baseUrl,
384
- running,
385
364
  connected: probeOk ?? running,
386
- lastStartAt: runtime?.lastStartAt ?? null,
387
- lastStopAt: runtime?.lastStopAt ?? null,
388
- lastError: runtime?.lastError ?? null,
389
- probe,
390
- lastInboundAt: runtime?.lastInboundAt ?? null,
391
- lastOutboundAt: runtime?.lastOutboundAt ?? null,
392
365
  };
393
366
  },
394
367
  },
package/src/chat.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import path from "node:path";
3
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
4
4
  import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
5
5
  import { postMultipartFormData } from "./multipart.js";
6
6
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
@@ -30,6 +30,39 @@ function resolvePartIndex(partIndex: number | undefined): number {
30
30
  return typeof partIndex === "number" ? partIndex : 0;
31
31
  }
32
32
 
33
+ async function sendBlueBubblesChatEndpointRequest(params: {
34
+ chatGuid: string;
35
+ opts: BlueBubblesChatOpts;
36
+ endpoint: "read" | "typing";
37
+ method: "POST" | "DELETE";
38
+ action: "read" | "typing";
39
+ }): Promise<void> {
40
+ const trimmed = params.chatGuid.trim();
41
+ if (!trimmed) {
42
+ return;
43
+ }
44
+ const { baseUrl, password, accountId } = resolveAccount(params.opts);
45
+ if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
46
+ return;
47
+ }
48
+ const url = buildBlueBubblesApiUrl({
49
+ baseUrl,
50
+ path: `/api/v1/chat/${encodeURIComponent(trimmed)}/${params.endpoint}`,
51
+ password,
52
+ });
53
+ const res = await blueBubblesFetchWithTimeout(
54
+ url,
55
+ { method: params.method },
56
+ params.opts.timeoutMs,
57
+ );
58
+ if (!res.ok) {
59
+ const errorText = await res.text().catch(() => "");
60
+ throw new Error(
61
+ `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`,
62
+ );
63
+ }
64
+ }
65
+
33
66
  async function sendPrivateApiJsonRequest(params: {
34
67
  opts: BlueBubblesChatOpts;
35
68
  feature: string;
@@ -65,24 +98,13 @@ export async function markBlueBubblesChatRead(
65
98
  chatGuid: string,
66
99
  opts: BlueBubblesChatOpts = {},
67
100
  ): Promise<void> {
68
- const trimmed = chatGuid.trim();
69
- if (!trimmed) {
70
- return;
71
- }
72
- const { baseUrl, password, accountId } = resolveAccount(opts);
73
- if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
74
- return;
75
- }
76
- const url = buildBlueBubblesApiUrl({
77
- baseUrl,
78
- path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
79
- password,
101
+ await sendBlueBubblesChatEndpointRequest({
102
+ chatGuid,
103
+ opts,
104
+ endpoint: "read",
105
+ method: "POST",
106
+ action: "read",
80
107
  });
81
- const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
82
- if (!res.ok) {
83
- const errorText = await res.text().catch(() => "");
84
- throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`);
85
- }
86
108
  }
87
109
 
88
110
  export async function sendBlueBubblesTyping(
@@ -90,28 +112,13 @@ export async function sendBlueBubblesTyping(
90
112
  typing: boolean,
91
113
  opts: BlueBubblesChatOpts = {},
92
114
  ): Promise<void> {
93
- const trimmed = chatGuid.trim();
94
- if (!trimmed) {
95
- return;
96
- }
97
- const { baseUrl, password, accountId } = resolveAccount(opts);
98
- if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
99
- return;
100
- }
101
- const url = buildBlueBubblesApiUrl({
102
- baseUrl,
103
- path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
104
- password,
115
+ await sendBlueBubblesChatEndpointRequest({
116
+ chatGuid,
117
+ opts,
118
+ endpoint: "typing",
119
+ method: typing ? "POST" : "DELETE",
120
+ action: "typing",
105
121
  });
106
- const res = await blueBubblesFetchWithTimeout(
107
- url,
108
- { method: typing ? "POST" : "DELETE" },
109
- opts.timeoutMs,
110
- );
111
- if (!res.ok) {
112
- const errorText = await res.text().catch(() => "");
113
- throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
114
- }
115
122
  }
116
123
 
117
124
  /**
@@ -0,0 +1,77 @@
1
+ import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
2
+
3
+ type BlueBubblesConfigPatch = {
4
+ serverUrl?: string;
5
+ password?: unknown;
6
+ webhookPath?: string;
7
+ };
8
+
9
+ type AccountEnabledMode = boolean | "preserve-or-true";
10
+
11
+ function normalizePatch(
12
+ patch: BlueBubblesConfigPatch,
13
+ onlyDefinedFields: boolean,
14
+ ): BlueBubblesConfigPatch {
15
+ if (!onlyDefinedFields) {
16
+ return patch;
17
+ }
18
+ const next: BlueBubblesConfigPatch = {};
19
+ if (patch.serverUrl !== undefined) {
20
+ next.serverUrl = patch.serverUrl;
21
+ }
22
+ if (patch.password !== undefined) {
23
+ next.password = patch.password;
24
+ }
25
+ if (patch.webhookPath !== undefined) {
26
+ next.webhookPath = patch.webhookPath;
27
+ }
28
+ return next;
29
+ }
30
+
31
+ export function applyBlueBubblesConnectionConfig(params: {
32
+ cfg: OpenClawConfig;
33
+ accountId: string;
34
+ patch: BlueBubblesConfigPatch;
35
+ onlyDefinedFields?: boolean;
36
+ accountEnabled?: AccountEnabledMode;
37
+ }): OpenClawConfig {
38
+ const patch = normalizePatch(params.patch, params.onlyDefinedFields === true);
39
+ if (params.accountId === DEFAULT_ACCOUNT_ID) {
40
+ return {
41
+ ...params.cfg,
42
+ channels: {
43
+ ...params.cfg.channels,
44
+ bluebubbles: {
45
+ ...params.cfg.channels?.bluebubbles,
46
+ enabled: true,
47
+ ...patch,
48
+ },
49
+ },
50
+ };
51
+ }
52
+
53
+ const currentAccount = params.cfg.channels?.bluebubbles?.accounts?.[params.accountId];
54
+ const enabled =
55
+ params.accountEnabled === "preserve-or-true"
56
+ ? (currentAccount?.enabled ?? true)
57
+ : (params.accountEnabled ?? true);
58
+
59
+ return {
60
+ ...params.cfg,
61
+ channels: {
62
+ ...params.cfg.channels,
63
+ bluebubbles: {
64
+ ...params.cfg.channels?.bluebubbles,
65
+ enabled: true,
66
+ accounts: {
67
+ ...params.cfg.channels?.bluebubbles?.accounts,
68
+ [params.accountId]: {
69
+ ...currentAccount,
70
+ enabled,
71
+ ...patch,
72
+ },
73
+ },
74
+ },
75
+ },
76
+ };
77
+ }
@@ -5,7 +5,19 @@ describe("BlueBubblesConfigSchema", () => {
5
5
  it("accepts account config when serverUrl and password are both set", () => {
6
6
  const parsed = BlueBubblesConfigSchema.safeParse({
7
7
  serverUrl: "http://localhost:1234",
8
- password: "secret",
8
+ password: "secret", // pragma: allowlist secret
9
+ });
10
+ expect(parsed.success).toBe(true);
11
+ });
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
+ },
9
21
  });
10
22
  expect(parsed.success).toBe(true);
11
23
  });