@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.
@@ -0,0 +1,44 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
4
+ import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
5
+ import type { WebhookTarget } from "./monitor-shared.js";
6
+ import { registerBlueBubblesWebhookTarget } from "./monitor.js";
7
+
8
+ function createTarget(): WebhookTarget {
9
+ return {
10
+ account: { accountId: "default" } as WebhookTarget["account"],
11
+ config: {} as OpenClawConfig,
12
+ runtime: {},
13
+ core: {} as WebhookTarget["core"],
14
+ path: "/bluebubbles-webhook",
15
+ };
16
+ }
17
+
18
+ describe("registerBlueBubblesWebhookTarget", () => {
19
+ afterEach(() => {
20
+ setActivePluginRegistry(createEmptyPluginRegistry());
21
+ });
22
+
23
+ it("registers and unregisters plugin HTTP route at path boundaries", () => {
24
+ const registry = createEmptyPluginRegistry();
25
+ setActivePluginRegistry(registry);
26
+
27
+ const unregisterA = registerBlueBubblesWebhookTarget(createTarget());
28
+ const unregisterB = registerBlueBubblesWebhookTarget(createTarget());
29
+
30
+ expect(registry.httpRoutes).toHaveLength(1);
31
+ expect(registry.httpRoutes[0]).toEqual(
32
+ expect.objectContaining({
33
+ pluginId: "bluebubbles",
34
+ path: "/bluebubbles-webhook",
35
+ source: "bluebubbles-webhook",
36
+ }),
37
+ );
38
+
39
+ unregisterA();
40
+ expect(registry.httpRoutes).toHaveLength(1);
41
+ unregisterB();
42
+ expect(registry.httpRoutes).toHaveLength(0);
43
+ });
44
+ });
@@ -0,0 +1,81 @@
1
+ import type { WizardPrompter } from "openclaw/plugin-sdk";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ vi.mock("openclaw/plugin-sdk", () => ({
5
+ DEFAULT_ACCOUNT_ID: "default",
6
+ addWildcardAllowFrom: vi.fn(),
7
+ formatDocsLink: (_url: string, fallback: string) => fallback,
8
+ hasConfiguredSecretInput: (value: unknown) => {
9
+ if (typeof value === "string") {
10
+ return value.trim().length > 0;
11
+ }
12
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
13
+ return false;
14
+ }
15
+ const ref = value as { source?: unknown; provider?: unknown; id?: unknown };
16
+ const validSource = ref.source === "env" || ref.source === "file" || ref.source === "exec";
17
+ return (
18
+ validSource &&
19
+ typeof ref.provider === "string" &&
20
+ ref.provider.trim().length > 0 &&
21
+ typeof ref.id === "string" &&
22
+ ref.id.trim().length > 0
23
+ );
24
+ },
25
+ mergeAllowFromEntries: (_existing: unknown, entries: string[]) => entries,
26
+ normalizeSecretInputString: (value: unknown) => {
27
+ if (typeof value !== "string") {
28
+ return undefined;
29
+ }
30
+ const trimmed = value.trim();
31
+ return trimmed.length > 0 ? trimmed : undefined;
32
+ },
33
+ normalizeAccountId: (value?: string | null) =>
34
+ value && value.trim().length > 0 ? value : "default",
35
+ promptAccountId: vi.fn(),
36
+ }));
37
+
38
+ describe("bluebubbles onboarding SecretInput", () => {
39
+ it("preserves existing password SecretRef when user keeps current credential", async () => {
40
+ const { blueBubblesOnboardingAdapter } = await import("./onboarding.js");
41
+ type ConfigureContext = Parameters<
42
+ NonNullable<typeof blueBubblesOnboardingAdapter.configure>
43
+ >[0];
44
+ const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" };
45
+ const confirm = vi
46
+ .fn()
47
+ .mockResolvedValueOnce(true) // keep server URL
48
+ .mockResolvedValueOnce(true) // keep password SecretRef
49
+ .mockResolvedValueOnce(false); // keep default webhook path
50
+ const text = vi.fn();
51
+ const note = vi.fn();
52
+
53
+ const prompter = {
54
+ confirm,
55
+ text,
56
+ note,
57
+ } as unknown as WizardPrompter;
58
+
59
+ const context = {
60
+ cfg: {
61
+ channels: {
62
+ bluebubbles: {
63
+ enabled: true,
64
+ serverUrl: "http://127.0.0.1:1234",
65
+ password: passwordRef,
66
+ },
67
+ },
68
+ },
69
+ prompter,
70
+ runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
71
+ forceAllowFrom: false,
72
+ accountOverrides: {},
73
+ shouldPromptAccountIds: false,
74
+ } satisfies ConfigureContext;
75
+
76
+ const result = await blueBubblesOnboardingAdapter.configure(context);
77
+
78
+ expect(result.cfg.channels?.bluebubbles?.password).toEqual(passwordRef);
79
+ expect(text).not.toHaveBeenCalled();
80
+ });
81
+ });
package/src/onboarding.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  resolveBlueBubblesAccount,
19
19
  resolveDefaultBlueBubblesAccountId,
20
20
  } from "./accounts.js";
21
+ import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
21
22
  import { parseBlueBubblesAllowTarget } from "./targets.js";
22
23
  import { normalizeBlueBubblesServerUrl } from "./types.js";
23
24
 
@@ -222,8 +223,11 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
222
223
  }
223
224
 
224
225
  // Prompt for password
225
- let password = resolvedAccount.config.password?.trim();
226
- if (!password) {
226
+ const existingPassword = resolvedAccount.config.password;
227
+ const existingPasswordText = normalizeSecretInputString(existingPassword);
228
+ const hasConfiguredPassword = hasConfiguredSecretInput(existingPassword);
229
+ let password: unknown = existingPasswordText;
230
+ if (!hasConfiguredPassword) {
227
231
  await prompter.note(
228
232
  [
229
233
  "Enter the BlueBubbles server password.",
@@ -247,6 +251,8 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
247
251
  validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
248
252
  });
249
253
  password = String(entered).trim();
254
+ } else if (!existingPasswordText) {
255
+ password = existingPassword;
250
256
  }
251
257
  }
252
258
 
package/src/probe.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { BaseProbeResult } from "openclaw/plugin-sdk";
2
+ import { normalizeSecretInputString } from "./secret-input.js";
2
3
  import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
3
4
 
4
5
  export type BlueBubblesProbe = BaseProbeResult & {
@@ -35,8 +36,8 @@ export async function fetchBlueBubblesServerInfo(params: {
35
36
  accountId?: string;
36
37
  timeoutMs?: number;
37
38
  }): Promise<BlueBubblesServerInfo | null> {
38
- const baseUrl = params.baseUrl?.trim();
39
- const password = params.password?.trim();
39
+ const baseUrl = normalizeSecretInputString(params.baseUrl);
40
+ const password = normalizeSecretInputString(params.password);
40
41
  if (!baseUrl || !password) {
41
42
  return null;
42
43
  }
@@ -138,8 +139,8 @@ export async function probeBlueBubbles(params: {
138
139
  password?: string | null;
139
140
  timeoutMs?: number;
140
141
  }): Promise<BlueBubblesProbe> {
141
- const baseUrl = params.baseUrl?.trim();
142
- const password = params.password?.trim();
142
+ const baseUrl = normalizeSecretInputString(params.baseUrl);
143
+ const password = normalizeSecretInputString(params.password);
143
144
  if (!baseUrl) {
144
145
  return { ok: false, error: "serverUrl not configured" };
145
146
  }
@@ -0,0 +1,19 @@
1
+ import {
2
+ hasConfiguredSecretInput,
3
+ normalizeResolvedSecretInputString,
4
+ normalizeSecretInputString,
5
+ } from "openclaw/plugin-sdk";
6
+ import { z } from "zod";
7
+
8
+ export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
9
+
10
+ export function buildSecretInputSchema() {
11
+ return z.union([
12
+ z.string(),
13
+ z.object({
14
+ source: z.enum(["env", "file", "exec"]),
15
+ provider: z.string().min(1),
16
+ id: z.string().min(1),
17
+ }),
18
+ ]);
19
+ }
@@ -23,31 +23,43 @@ export function extractBlueBubblesMessageId(payload: unknown): string {
23
23
  if (!payload || typeof payload !== "object") {
24
24
  return "unknown";
25
25
  }
26
- const record = payload as Record<string, unknown>;
27
- const data =
28
- record.data && typeof record.data === "object"
29
- ? (record.data as Record<string, unknown>)
26
+
27
+ const asRecord = (value: unknown): Record<string, unknown> | null =>
28
+ value && typeof value === "object" && !Array.isArray(value)
29
+ ? (value as Record<string, unknown>)
30
30
  : null;
31
- const candidates = [
32
- record.messageId,
33
- record.messageGuid,
34
- record.message_guid,
35
- record.guid,
36
- record.id,
37
- data?.messageId,
38
- data?.messageGuid,
39
- data?.message_guid,
40
- data?.message_id,
41
- data?.guid,
42
- data?.id,
43
- ];
44
- for (const candidate of candidates) {
45
- if (typeof candidate === "string" && candidate.trim()) {
46
- return candidate.trim();
31
+
32
+ const record = payload as Record<string, unknown>;
33
+ const dataRecord = asRecord(record.data);
34
+ const resultRecord = asRecord(record.result);
35
+ const payloadRecord = asRecord(record.payload);
36
+ const messageRecord = asRecord(record.message);
37
+ const dataArrayFirst = Array.isArray(record.data) ? asRecord(record.data[0]) : null;
38
+
39
+ const roots = [record, dataRecord, resultRecord, payloadRecord, messageRecord, dataArrayFirst];
40
+
41
+ for (const root of roots) {
42
+ if (!root) {
43
+ continue;
47
44
  }
48
- if (typeof candidate === "number" && Number.isFinite(candidate)) {
49
- return String(candidate);
45
+ const candidates = [
46
+ root.message_id,
47
+ root.messageId,
48
+ root.messageGuid,
49
+ root.message_guid,
50
+ root.guid,
51
+ root.id,
52
+ root.uuid,
53
+ ];
54
+ for (const candidate of candidates) {
55
+ if (typeof candidate === "string" && candidate.trim()) {
56
+ return candidate.trim();
57
+ }
58
+ if (typeof candidate === "number" && Number.isFinite(candidate)) {
59
+ return String(candidate);
60
+ }
50
61
  }
51
62
  }
63
+
52
64
  return "unknown";
53
65
  }
package/src/send.test.ts CHANGED
@@ -721,6 +721,30 @@ describe("send", () => {
721
721
  expect(result.messageId).toBe("msg-guid-789");
722
722
  });
723
723
 
724
+ it("extracts top-level message_id from response payload", async () => {
725
+ mockResolvedHandleTarget();
726
+ mockSendResponse({ message_id: "bb-msg-321" });
727
+
728
+ const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
729
+ serverUrl: "http://localhost:1234",
730
+ password: "test",
731
+ });
732
+
733
+ expect(result.messageId).toBe("bb-msg-321");
734
+ });
735
+
736
+ it("extracts nested result.message_id from response payload", async () => {
737
+ mockResolvedHandleTarget();
738
+ mockSendResponse({ result: { message_id: "bb-msg-654" } });
739
+
740
+ const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
741
+ serverUrl: "http://localhost:1234",
742
+ password: "test",
743
+ });
744
+
745
+ expect(result.messageId).toBe("bb-msg-654");
746
+ });
747
+
724
748
  it("resolves credentials from config", async () => {
725
749
  mockResolvedHandleTarget();
726
750
  mockSendResponse({ data: { guid: "msg-123" } });
package/src/send.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  isBlueBubblesPrivateApiStatusEnabled,
8
8
  } from "./probe.js";
9
9
  import { warnBlueBubbles } from "./runtime.js";
10
+ import { normalizeSecretInputString } from "./secret-input.js";
10
11
  import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
11
12
  import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
12
13
  import {
@@ -372,8 +373,12 @@ export async function sendMessageBlueBubbles(
372
373
  cfg: opts.cfg ?? {},
373
374
  accountId: opts.accountId,
374
375
  });
375
- const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim();
376
- const password = opts.password?.trim() || account.config.password?.trim();
376
+ const baseUrl =
377
+ normalizeSecretInputString(opts.serverUrl) ||
378
+ normalizeSecretInputString(account.config.serverUrl);
379
+ const password =
380
+ normalizeSecretInputString(opts.password) ||
381
+ normalizeSecretInputString(account.config.password);
377
382
  if (!baseUrl) {
378
383
  throw new Error("BlueBubbles serverUrl is required");
379
384
  }
package/src/targets.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  isAllowedParsedChatSender,
3
3
  parseChatAllowTargetPrefixes,
4
4
  parseChatTargetPrefixesOrThrow,
5
+ type ParsedChatTarget,
5
6
  resolveServicePrefixedAllowTarget,
6
7
  resolveServicePrefixedTarget,
7
8
  } from "openclaw/plugin-sdk";
@@ -14,11 +15,7 @@ export type BlueBubblesTarget =
14
15
  | { kind: "chat_identifier"; chatIdentifier: string }
15
16
  | { kind: "handle"; to: string; service: BlueBubblesService };
16
17
 
17
- export type BlueBubblesAllowTarget =
18
- | { kind: "chat_id"; chatId: number }
19
- | { kind: "chat_guid"; chatGuid: string }
20
- | { kind: "chat_identifier"; chatIdentifier: string }
21
- | { kind: "handle"; handle: string };
18
+ export type BlueBubblesAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string };
22
19
 
23
20
  const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"];
24
21
  const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"];
package/src/types.ts CHANGED
@@ -75,6 +75,8 @@ export type BlueBubblesActionConfig = {
75
75
  export type BlueBubblesConfig = {
76
76
  /** Optional per-account BlueBubbles configuration (multi-account). */
77
77
  accounts?: Record<string, BlueBubblesAccountConfig>;
78
+ /** Optional default account id when multiple accounts are configured. */
79
+ defaultAccount?: string;
78
80
  /** Per-action tool gating (default: true for all). */
79
81
  actions?: BlueBubblesActionConfig;
80
82
  } & BlueBubblesAccountConfig;