@openclaw/bluebubbles 2026.1.29

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,340 @@
1
+ import type {
2
+ ChannelOnboardingAdapter,
3
+ ChannelOnboardingDmPolicy,
4
+ OpenClawConfig,
5
+ DmPolicy,
6
+ WizardPrompter,
7
+ } from "openclaw/plugin-sdk";
8
+ import {
9
+ DEFAULT_ACCOUNT_ID,
10
+ addWildcardAllowFrom,
11
+ formatDocsLink,
12
+ normalizeAccountId,
13
+ promptAccountId,
14
+ } from "openclaw/plugin-sdk";
15
+ import {
16
+ listBlueBubblesAccountIds,
17
+ resolveBlueBubblesAccount,
18
+ resolveDefaultBlueBubblesAccountId,
19
+ } from "./accounts.js";
20
+ import { normalizeBlueBubblesServerUrl } from "./types.js";
21
+ import { parseBlueBubblesAllowTarget, normalizeBlueBubblesHandle } from "./targets.js";
22
+
23
+ const channel = "bluebubbles" as const;
24
+
25
+ function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
26
+ const allowFrom =
27
+ dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.bluebubbles?.allowFrom) : undefined;
28
+ return {
29
+ ...cfg,
30
+ channels: {
31
+ ...cfg.channels,
32
+ bluebubbles: {
33
+ ...cfg.channels?.bluebubbles,
34
+ dmPolicy,
35
+ ...(allowFrom ? { allowFrom } : {}),
36
+ },
37
+ },
38
+ };
39
+ }
40
+
41
+ function setBlueBubblesAllowFrom(
42
+ cfg: OpenClawConfig,
43
+ accountId: string,
44
+ allowFrom: string[],
45
+ ): OpenClawConfig {
46
+ if (accountId === DEFAULT_ACCOUNT_ID) {
47
+ return {
48
+ ...cfg,
49
+ channels: {
50
+ ...cfg.channels,
51
+ bluebubbles: {
52
+ ...cfg.channels?.bluebubbles,
53
+ allowFrom,
54
+ },
55
+ },
56
+ };
57
+ }
58
+ return {
59
+ ...cfg,
60
+ channels: {
61
+ ...cfg.channels,
62
+ bluebubbles: {
63
+ ...cfg.channels?.bluebubbles,
64
+ accounts: {
65
+ ...cfg.channels?.bluebubbles?.accounts,
66
+ [accountId]: {
67
+ ...cfg.channels?.bluebubbles?.accounts?.[accountId],
68
+ allowFrom,
69
+ },
70
+ },
71
+ },
72
+ },
73
+ };
74
+ }
75
+
76
+ function parseBlueBubblesAllowFromInput(raw: string): string[] {
77
+ return raw
78
+ .split(/[\n,]+/g)
79
+ .map((entry) => entry.trim())
80
+ .filter(Boolean);
81
+ }
82
+
83
+ async function promptBlueBubblesAllowFrom(params: {
84
+ cfg: OpenClawConfig;
85
+ prompter: WizardPrompter;
86
+ accountId?: string;
87
+ }): Promise<OpenClawConfig> {
88
+ const accountId =
89
+ params.accountId && normalizeAccountId(params.accountId)
90
+ ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
91
+ : resolveDefaultBlueBubblesAccountId(params.cfg);
92
+ const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId });
93
+ const existing = resolved.config.allowFrom ?? [];
94
+ await params.prompter.note(
95
+ [
96
+ "Allowlist BlueBubbles DMs by handle or chat target.",
97
+ "Examples:",
98
+ "- +15555550123",
99
+ "- user@example.com",
100
+ "- chat_id:123",
101
+ "- chat_guid:iMessage;-;+15555550123",
102
+ "Multiple entries: comma- or newline-separated.",
103
+ `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
104
+ ].join("\n"),
105
+ "BlueBubbles allowlist",
106
+ );
107
+ const entry = await params.prompter.text({
108
+ message: "BlueBubbles allowFrom (handle or chat_id)",
109
+ placeholder: "+15555550123, user@example.com, chat_id:123",
110
+ initialValue: existing[0] ? String(existing[0]) : undefined,
111
+ validate: (value) => {
112
+ const raw = String(value ?? "").trim();
113
+ if (!raw) return "Required";
114
+ const parts = parseBlueBubblesAllowFromInput(raw);
115
+ for (const part of parts) {
116
+ if (part === "*") continue;
117
+ const parsed = parseBlueBubblesAllowTarget(part);
118
+ if (parsed.kind === "handle" && !parsed.handle) {
119
+ return `Invalid entry: ${part}`;
120
+ }
121
+ }
122
+ return undefined;
123
+ },
124
+ });
125
+ const parts = parseBlueBubblesAllowFromInput(String(entry));
126
+ const unique = [...new Set(parts)];
127
+ return setBlueBubblesAllowFrom(params.cfg, accountId, unique);
128
+ }
129
+
130
+ const dmPolicy: ChannelOnboardingDmPolicy = {
131
+ label: "BlueBubbles",
132
+ channel,
133
+ policyKey: "channels.bluebubbles.dmPolicy",
134
+ allowFromKey: "channels.bluebubbles.allowFrom",
135
+ getCurrent: (cfg) => cfg.channels?.bluebubbles?.dmPolicy ?? "pairing",
136
+ setPolicy: (cfg, policy) => setBlueBubblesDmPolicy(cfg, policy),
137
+ promptAllowFrom: promptBlueBubblesAllowFrom,
138
+ };
139
+
140
+ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
141
+ channel,
142
+ getStatus: async ({ cfg }) => {
143
+ const configured = listBlueBubblesAccountIds(cfg).some((accountId) => {
144
+ const account = resolveBlueBubblesAccount({ cfg, accountId });
145
+ return account.configured;
146
+ });
147
+ return {
148
+ channel,
149
+ configured,
150
+ statusLines: [`BlueBubbles: ${configured ? "configured" : "needs setup"}`],
151
+ selectionHint: configured ? "configured" : "iMessage via BlueBubbles app",
152
+ quickstartScore: configured ? 1 : 0,
153
+ };
154
+ },
155
+ configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
156
+ const blueBubblesOverride = accountOverrides.bluebubbles?.trim();
157
+ const defaultAccountId = resolveDefaultBlueBubblesAccountId(cfg);
158
+ let accountId = blueBubblesOverride
159
+ ? normalizeAccountId(blueBubblesOverride)
160
+ : defaultAccountId;
161
+ if (shouldPromptAccountIds && !blueBubblesOverride) {
162
+ accountId = await promptAccountId({
163
+ cfg,
164
+ prompter,
165
+ label: "BlueBubbles",
166
+ currentId: accountId,
167
+ listAccountIds: listBlueBubblesAccountIds,
168
+ defaultAccountId,
169
+ });
170
+ }
171
+
172
+ let next = cfg;
173
+ const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId });
174
+
175
+ // Prompt for server URL
176
+ let serverUrl = resolvedAccount.config.serverUrl?.trim();
177
+ if (!serverUrl) {
178
+ await prompter.note(
179
+ [
180
+ "Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).",
181
+ "Find this in the BlueBubbles Server app under Connection.",
182
+ `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
183
+ ].join("\n"),
184
+ "BlueBubbles server URL",
185
+ );
186
+ const entered = await prompter.text({
187
+ message: "BlueBubbles server URL",
188
+ placeholder: "http://192.168.1.100:1234",
189
+ validate: (value) => {
190
+ const trimmed = String(value ?? "").trim();
191
+ if (!trimmed) return "Required";
192
+ try {
193
+ const normalized = normalizeBlueBubblesServerUrl(trimmed);
194
+ new URL(normalized);
195
+ return undefined;
196
+ } catch {
197
+ return "Invalid URL format";
198
+ }
199
+ },
200
+ });
201
+ serverUrl = String(entered).trim();
202
+ } else {
203
+ const keepUrl = await prompter.confirm({
204
+ message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`,
205
+ initialValue: true,
206
+ });
207
+ if (!keepUrl) {
208
+ const entered = await prompter.text({
209
+ message: "BlueBubbles server URL",
210
+ placeholder: "http://192.168.1.100:1234",
211
+ initialValue: serverUrl,
212
+ validate: (value) => {
213
+ const trimmed = String(value ?? "").trim();
214
+ if (!trimmed) return "Required";
215
+ try {
216
+ const normalized = normalizeBlueBubblesServerUrl(trimmed);
217
+ new URL(normalized);
218
+ return undefined;
219
+ } catch {
220
+ return "Invalid URL format";
221
+ }
222
+ },
223
+ });
224
+ serverUrl = String(entered).trim();
225
+ }
226
+ }
227
+
228
+ // Prompt for password
229
+ let password = resolvedAccount.config.password?.trim();
230
+ if (!password) {
231
+ await prompter.note(
232
+ [
233
+ "Enter the BlueBubbles server password.",
234
+ "Find this in the BlueBubbles Server app under Settings.",
235
+ ].join("\n"),
236
+ "BlueBubbles password",
237
+ );
238
+ const entered = await prompter.text({
239
+ message: "BlueBubbles password",
240
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
241
+ });
242
+ password = String(entered).trim();
243
+ } else {
244
+ const keepPassword = await prompter.confirm({
245
+ message: "BlueBubbles password already set. Keep it?",
246
+ initialValue: true,
247
+ });
248
+ if (!keepPassword) {
249
+ const entered = await prompter.text({
250
+ message: "BlueBubbles password",
251
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
252
+ });
253
+ password = String(entered).trim();
254
+ }
255
+ }
256
+
257
+ // Prompt for webhook path (optional)
258
+ const existingWebhookPath = resolvedAccount.config.webhookPath?.trim();
259
+ const wantsWebhook = await prompter.confirm({
260
+ message: "Configure a custom webhook path? (default: /bluebubbles-webhook)",
261
+ initialValue: Boolean(existingWebhookPath && existingWebhookPath !== "/bluebubbles-webhook"),
262
+ });
263
+ let webhookPath = "/bluebubbles-webhook";
264
+ if (wantsWebhook) {
265
+ const entered = await prompter.text({
266
+ message: "Webhook path",
267
+ placeholder: "/bluebubbles-webhook",
268
+ initialValue: existingWebhookPath || "/bluebubbles-webhook",
269
+ validate: (value) => {
270
+ const trimmed = String(value ?? "").trim();
271
+ if (!trimmed) return "Required";
272
+ if (!trimmed.startsWith("/")) return "Path must start with /";
273
+ return undefined;
274
+ },
275
+ });
276
+ webhookPath = String(entered).trim();
277
+ }
278
+
279
+ // Apply config
280
+ if (accountId === DEFAULT_ACCOUNT_ID) {
281
+ next = {
282
+ ...next,
283
+ channels: {
284
+ ...next.channels,
285
+ bluebubbles: {
286
+ ...next.channels?.bluebubbles,
287
+ enabled: true,
288
+ serverUrl,
289
+ password,
290
+ webhookPath,
291
+ },
292
+ },
293
+ };
294
+ } else {
295
+ next = {
296
+ ...next,
297
+ channels: {
298
+ ...next.channels,
299
+ bluebubbles: {
300
+ ...next.channels?.bluebubbles,
301
+ enabled: true,
302
+ accounts: {
303
+ ...next.channels?.bluebubbles?.accounts,
304
+ [accountId]: {
305
+ ...next.channels?.bluebubbles?.accounts?.[accountId],
306
+ enabled: next.channels?.bluebubbles?.accounts?.[accountId]?.enabled ?? true,
307
+ serverUrl,
308
+ password,
309
+ webhookPath,
310
+ },
311
+ },
312
+ },
313
+ },
314
+ };
315
+ }
316
+
317
+ await prompter.note(
318
+ [
319
+ "Configure the webhook URL in BlueBubbles Server:",
320
+ "1. Open BlueBubbles Server → Settings → Webhooks",
321
+ "2. Add your OpenClaw gateway URL + webhook path",
322
+ " Example: https://your-gateway-host:3000/bluebubbles-webhook",
323
+ "3. Enable the webhook and save",
324
+ "",
325
+ `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
326
+ ].join("\n"),
327
+ "BlueBubbles next steps",
328
+ );
329
+
330
+ return { cfg: next, accountId };
331
+ },
332
+ dmPolicy,
333
+ disable: (cfg) => ({
334
+ ...cfg,
335
+ channels: {
336
+ ...cfg.channels,
337
+ bluebubbles: { ...cfg.channels?.bluebubbles, enabled: false },
338
+ },
339
+ }),
340
+ };
package/src/probe.ts ADDED
@@ -0,0 +1,127 @@
1
+ import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
2
+
3
+ export type BlueBubblesProbe = {
4
+ ok: boolean;
5
+ status?: number | null;
6
+ error?: string | null;
7
+ };
8
+
9
+ export type BlueBubblesServerInfo = {
10
+ os_version?: string;
11
+ server_version?: string;
12
+ private_api?: boolean;
13
+ helper_connected?: boolean;
14
+ proxy_service?: string;
15
+ detected_icloud?: string;
16
+ computer_id?: string;
17
+ };
18
+
19
+ /** Cache server info by account ID to avoid repeated API calls */
20
+ const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires: number }>();
21
+ const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
22
+
23
+ function buildCacheKey(accountId?: string): string {
24
+ return accountId?.trim() || "default";
25
+ }
26
+
27
+ /**
28
+ * Fetch server info from BlueBubbles API and cache it.
29
+ * Returns cached result if available and not expired.
30
+ */
31
+ export async function fetchBlueBubblesServerInfo(params: {
32
+ baseUrl?: string | null;
33
+ password?: string | null;
34
+ accountId?: string;
35
+ timeoutMs?: number;
36
+ }): Promise<BlueBubblesServerInfo | null> {
37
+ const baseUrl = params.baseUrl?.trim();
38
+ const password = params.password?.trim();
39
+ if (!baseUrl || !password) return null;
40
+
41
+ const cacheKey = buildCacheKey(params.accountId);
42
+ const cached = serverInfoCache.get(cacheKey);
43
+ if (cached && cached.expires > Date.now()) {
44
+ return cached.info;
45
+ }
46
+
47
+ const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password });
48
+ try {
49
+ const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs ?? 5000);
50
+ if (!res.ok) return null;
51
+ const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
52
+ const data = payload?.data as BlueBubblesServerInfo | undefined;
53
+ if (data) {
54
+ serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS });
55
+ }
56
+ return data ?? null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Get cached server info synchronously (for use in listActions).
64
+ * Returns null if not cached or expired.
65
+ */
66
+ export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null {
67
+ const cacheKey = buildCacheKey(accountId);
68
+ const cached = serverInfoCache.get(cacheKey);
69
+ if (cached && cached.expires > Date.now()) {
70
+ return cached.info;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
77
+ */
78
+ export function parseMacOSMajorVersion(version?: string | null): number | null {
79
+ if (!version) return null;
80
+ const match = /^(\d+)/.exec(version.trim());
81
+ return match ? Number.parseInt(match[1], 10) : null;
82
+ }
83
+
84
+ /**
85
+ * Check if the cached server info indicates macOS 26 or higher.
86
+ * Returns false if no cached info is available (fail open for action listing).
87
+ */
88
+ export function isMacOS26OrHigher(accountId?: string): boolean {
89
+ const info = getCachedBlueBubblesServerInfo(accountId);
90
+ if (!info?.os_version) return false;
91
+ const major = parseMacOSMajorVersion(info.os_version);
92
+ return major !== null && major >= 26;
93
+ }
94
+
95
+ /** Clear the server info cache (for testing) */
96
+ export function clearServerInfoCache(): void {
97
+ serverInfoCache.clear();
98
+ }
99
+
100
+ export async function probeBlueBubbles(params: {
101
+ baseUrl?: string | null;
102
+ password?: string | null;
103
+ timeoutMs?: number;
104
+ }): Promise<BlueBubblesProbe> {
105
+ const baseUrl = params.baseUrl?.trim();
106
+ const password = params.password?.trim();
107
+ if (!baseUrl) return { ok: false, error: "serverUrl not configured" };
108
+ if (!password) return { ok: false, error: "password not configured" };
109
+ const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password });
110
+ try {
111
+ const res = await blueBubblesFetchWithTimeout(
112
+ url,
113
+ { method: "GET" },
114
+ params.timeoutMs,
115
+ );
116
+ if (!res.ok) {
117
+ return { ok: false, status: res.status, error: `HTTP ${res.status}` };
118
+ }
119
+ return { ok: true, status: res.status };
120
+ } catch (err) {
121
+ return {
122
+ ok: false,
123
+ status: null,
124
+ error: err instanceof Error ? err.message : String(err),
125
+ };
126
+ }
127
+ }