@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
|
@@ -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
|
-
|
|
226
|
-
|
|
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
|
|
39
|
-
const password = params.password
|
|
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
|
|
142
|
-
const password = params.password
|
|
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
|
+
}
|
package/src/send-helpers.ts
CHANGED
|
@@ -23,31 +23,43 @@ export function extractBlueBubblesMessageId(payload: unknown): string {
|
|
|
23
23
|
if (!payload || typeof payload !== "object") {
|
|
24
24
|
return "unknown";
|
|
25
25
|
}
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
? (
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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 =
|
|
376
|
-
|
|
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;
|