@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.
package/src/channel.ts ADDED
@@ -0,0 +1,399 @@
1
+ import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import {
3
+ applyAccountNameToChannelSection,
4
+ buildChannelConfigSchema,
5
+ collectBlueBubblesStatusIssues,
6
+ DEFAULT_ACCOUNT_ID,
7
+ deleteAccountFromConfigSection,
8
+ formatPairingApproveHint,
9
+ migrateBaseNameToDefaultAccount,
10
+ normalizeAccountId,
11
+ PAIRING_APPROVED_MESSAGE,
12
+ resolveBlueBubblesGroupRequireMention,
13
+ resolveBlueBubblesGroupToolPolicy,
14
+ setAccountEnabledInConfigSection,
15
+ } from "openclaw/plugin-sdk";
16
+
17
+ import {
18
+ listBlueBubblesAccountIds,
19
+ type ResolvedBlueBubblesAccount,
20
+ resolveBlueBubblesAccount,
21
+ resolveDefaultBlueBubblesAccountId,
22
+ } from "./accounts.js";
23
+ import { BlueBubblesConfigSchema } from "./config-schema.js";
24
+ import { resolveBlueBubblesMessageId } from "./monitor.js";
25
+ import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
26
+ import { sendMessageBlueBubbles } from "./send.js";
27
+ import {
28
+ extractHandleFromChatGuid,
29
+ looksLikeBlueBubblesTargetId,
30
+ normalizeBlueBubblesHandle,
31
+ normalizeBlueBubblesMessagingTarget,
32
+ parseBlueBubblesTarget,
33
+ } from "./targets.js";
34
+ import { bluebubblesMessageActions } from "./actions.js";
35
+ import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
36
+ import { blueBubblesOnboardingAdapter } from "./onboarding.js";
37
+ import { sendBlueBubblesMedia } from "./media-send.js";
38
+
39
+ const meta = {
40
+ id: "bluebubbles",
41
+ label: "BlueBubbles",
42
+ selectionLabel: "BlueBubbles (macOS app)",
43
+ detailLabel: "BlueBubbles",
44
+ docsPath: "/channels/bluebubbles",
45
+ docsLabel: "bluebubbles",
46
+ blurb: "iMessage via the BlueBubbles mac app + REST API.",
47
+ systemImage: "bubble.left.and.text.bubble.right",
48
+ aliases: ["bb"],
49
+ order: 75,
50
+ preferOver: ["imessage"],
51
+ };
52
+
53
+ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
54
+ id: "bluebubbles",
55
+ meta,
56
+ capabilities: {
57
+ chatTypes: ["direct", "group"],
58
+ media: true,
59
+ reactions: true,
60
+ edit: true,
61
+ unsend: true,
62
+ reply: true,
63
+ effects: true,
64
+ groupManagement: true,
65
+ },
66
+ groups: {
67
+ resolveRequireMention: resolveBlueBubblesGroupRequireMention,
68
+ resolveToolPolicy: resolveBlueBubblesGroupToolPolicy,
69
+ },
70
+ threading: {
71
+ buildToolContext: ({ context, hasRepliedRef }) => ({
72
+ currentChannelId: context.To?.trim() || undefined,
73
+ currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId,
74
+ hasRepliedRef,
75
+ }),
76
+ },
77
+ reload: { configPrefixes: ["channels.bluebubbles"] },
78
+ configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
79
+ onboarding: blueBubblesOnboardingAdapter,
80
+ config: {
81
+ listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as OpenClawConfig),
82
+ resolveAccount: (cfg, accountId) =>
83
+ resolveBlueBubblesAccount({ cfg: cfg as OpenClawConfig, accountId }),
84
+ defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg as OpenClawConfig),
85
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
86
+ setAccountEnabledInConfigSection({
87
+ cfg: cfg as OpenClawConfig,
88
+ sectionKey: "bluebubbles",
89
+ accountId,
90
+ enabled,
91
+ allowTopLevel: true,
92
+ }),
93
+ deleteAccount: ({ cfg, accountId }) =>
94
+ deleteAccountFromConfigSection({
95
+ cfg: cfg as OpenClawConfig,
96
+ sectionKey: "bluebubbles",
97
+ accountId,
98
+ clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
99
+ }),
100
+ isConfigured: (account) => account.configured,
101
+ describeAccount: (account): ChannelAccountSnapshot => ({
102
+ accountId: account.accountId,
103
+ name: account.name,
104
+ enabled: account.enabled,
105
+ configured: account.configured,
106
+ baseUrl: account.baseUrl,
107
+ }),
108
+ resolveAllowFrom: ({ cfg, accountId }) =>
109
+ (resolveBlueBubblesAccount({ cfg: cfg as OpenClawConfig, accountId }).config.allowFrom ??
110
+ []).map(
111
+ (entry) => String(entry),
112
+ ),
113
+ formatAllowFrom: ({ allowFrom }) =>
114
+ allowFrom
115
+ .map((entry) => String(entry).trim())
116
+ .filter(Boolean)
117
+ .map((entry) => entry.replace(/^bluebubbles:/i, ""))
118
+ .map((entry) => normalizeBlueBubblesHandle(entry)),
119
+ },
120
+ actions: bluebubblesMessageActions,
121
+ security: {
122
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
123
+ const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
124
+ const useAccountPath = Boolean(
125
+ (cfg as OpenClawConfig).channels?.bluebubbles?.accounts?.[resolvedAccountId],
126
+ );
127
+ const basePath = useAccountPath
128
+ ? `channels.bluebubbles.accounts.${resolvedAccountId}.`
129
+ : "channels.bluebubbles.";
130
+ return {
131
+ policy: account.config.dmPolicy ?? "pairing",
132
+ allowFrom: account.config.allowFrom ?? [],
133
+ policyPath: `${basePath}dmPolicy`,
134
+ allowFromPath: basePath,
135
+ approveHint: formatPairingApproveHint("bluebubbles"),
136
+ normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
137
+ };
138
+ },
139
+ collectWarnings: ({ account }) => {
140
+ const groupPolicy = account.config.groupPolicy ?? "allowlist";
141
+ if (groupPolicy !== "open") return [];
142
+ return [
143
+ `- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`,
144
+ ];
145
+ },
146
+ },
147
+ messaging: {
148
+ normalizeTarget: normalizeBlueBubblesMessagingTarget,
149
+ targetResolver: {
150
+ looksLikeId: looksLikeBlueBubblesTargetId,
151
+ hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
152
+ },
153
+ formatTargetDisplay: ({ target, display }) => {
154
+ const shouldParseDisplay = (value: string): boolean => {
155
+ if (looksLikeBlueBubblesTargetId(value)) return true;
156
+ return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
157
+ };
158
+
159
+ // Helper to extract a clean handle from any BlueBubbles target format
160
+ const extractCleanDisplay = (value: string | undefined): string | null => {
161
+ const trimmed = value?.trim();
162
+ if (!trimmed) return null;
163
+ try {
164
+ const parsed = parseBlueBubblesTarget(trimmed);
165
+ if (parsed.kind === "chat_guid") {
166
+ const handle = extractHandleFromChatGuid(parsed.chatGuid);
167
+ if (handle) return handle;
168
+ }
169
+ if (parsed.kind === "handle") {
170
+ return normalizeBlueBubblesHandle(parsed.to);
171
+ }
172
+ } catch {
173
+ // Fall through
174
+ }
175
+ // Strip common prefixes and try raw extraction
176
+ const stripped = trimmed
177
+ .replace(/^bluebubbles:/i, "")
178
+ .replace(/^chat_guid:/i, "")
179
+ .replace(/^chat_id:/i, "")
180
+ .replace(/^chat_identifier:/i, "");
181
+ const handle = extractHandleFromChatGuid(stripped);
182
+ if (handle) return handle;
183
+ // Don't return raw chat_guid formats - they contain internal routing info
184
+ if (stripped.includes(";-;") || stripped.includes(";+;")) return null;
185
+ return stripped;
186
+ };
187
+
188
+ // Try to get a clean display from the display parameter first
189
+ const trimmedDisplay = display?.trim();
190
+ if (trimmedDisplay) {
191
+ if (!shouldParseDisplay(trimmedDisplay)) {
192
+ return trimmedDisplay;
193
+ }
194
+ const cleanDisplay = extractCleanDisplay(trimmedDisplay);
195
+ if (cleanDisplay) return cleanDisplay;
196
+ }
197
+
198
+ // Fall back to extracting from target
199
+ const cleanTarget = extractCleanDisplay(target);
200
+ if (cleanTarget) return cleanTarget;
201
+
202
+ // Last resort: return display or target as-is
203
+ return display?.trim() || target?.trim() || "";
204
+ },
205
+ },
206
+ setup: {
207
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
208
+ applyAccountName: ({ cfg, accountId, name }) =>
209
+ applyAccountNameToChannelSection({
210
+ cfg: cfg as OpenClawConfig,
211
+ channelKey: "bluebubbles",
212
+ accountId,
213
+ name,
214
+ }),
215
+ validateInput: ({ input }) => {
216
+ if (!input.httpUrl && !input.password) {
217
+ return "BlueBubbles requires --http-url and --password.";
218
+ }
219
+ if (!input.httpUrl) return "BlueBubbles requires --http-url.";
220
+ if (!input.password) return "BlueBubbles requires --password.";
221
+ return null;
222
+ },
223
+ applyAccountConfig: ({ cfg, accountId, input }) => {
224
+ const namedConfig = applyAccountNameToChannelSection({
225
+ cfg: cfg as OpenClawConfig,
226
+ channelKey: "bluebubbles",
227
+ accountId,
228
+ name: input.name,
229
+ });
230
+ const next =
231
+ accountId !== DEFAULT_ACCOUNT_ID
232
+ ? migrateBaseNameToDefaultAccount({
233
+ cfg: namedConfig,
234
+ channelKey: "bluebubbles",
235
+ })
236
+ : namedConfig;
237
+ if (accountId === DEFAULT_ACCOUNT_ID) {
238
+ return {
239
+ ...next,
240
+ channels: {
241
+ ...next.channels,
242
+ bluebubbles: {
243
+ ...next.channels?.bluebubbles,
244
+ enabled: true,
245
+ ...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
246
+ ...(input.password ? { password: input.password } : {}),
247
+ ...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
248
+ },
249
+ },
250
+ } as OpenClawConfig;
251
+ }
252
+ return {
253
+ ...next,
254
+ channels: {
255
+ ...next.channels,
256
+ bluebubbles: {
257
+ ...next.channels?.bluebubbles,
258
+ enabled: true,
259
+ accounts: {
260
+ ...(next.channels?.bluebubbles?.accounts ?? {}),
261
+ [accountId]: {
262
+ ...(next.channels?.bluebubbles?.accounts?.[accountId] ?? {}),
263
+ enabled: true,
264
+ ...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
265
+ ...(input.password ? { password: input.password } : {}),
266
+ ...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
267
+ },
268
+ },
269
+ },
270
+ },
271
+ } as OpenClawConfig;
272
+ },
273
+ },
274
+ pairing: {
275
+ idLabel: "bluebubblesSenderId",
276
+ normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
277
+ notifyApproval: async ({ cfg, id }) => {
278
+ await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
279
+ cfg: cfg as OpenClawConfig,
280
+ });
281
+ },
282
+ },
283
+ outbound: {
284
+ deliveryMode: "direct",
285
+ textChunkLimit: 4000,
286
+ resolveTarget: ({ to }) => {
287
+ const trimmed = to?.trim();
288
+ if (!trimmed) {
289
+ return {
290
+ ok: false,
291
+ error: new Error("Delivering to BlueBubbles requires --to <handle|chat_guid:GUID>"),
292
+ };
293
+ }
294
+ return { ok: true, to: trimmed };
295
+ },
296
+ sendText: async ({ cfg, to, text, accountId, replyToId }) => {
297
+ const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
298
+ // Resolve short ID (e.g., "5") to full UUID
299
+ const replyToMessageGuid = rawReplyToId
300
+ ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
301
+ : "";
302
+ const result = await sendMessageBlueBubbles(to, text, {
303
+ cfg: cfg as OpenClawConfig,
304
+ accountId: accountId ?? undefined,
305
+ replyToMessageGuid: replyToMessageGuid || undefined,
306
+ });
307
+ return { channel: "bluebubbles", ...result };
308
+ },
309
+ sendMedia: async (ctx) => {
310
+ const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
311
+ const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
312
+ mediaPath?: string;
313
+ mediaBuffer?: Uint8Array;
314
+ contentType?: string;
315
+ filename?: string;
316
+ caption?: string;
317
+ };
318
+ const resolvedCaption = caption ?? text;
319
+ const result = await sendBlueBubblesMedia({
320
+ cfg: cfg as OpenClawConfig,
321
+ to,
322
+ mediaUrl,
323
+ mediaPath,
324
+ mediaBuffer,
325
+ contentType,
326
+ filename,
327
+ caption: resolvedCaption ?? undefined,
328
+ replyToId: replyToId ?? null,
329
+ accountId: accountId ?? undefined,
330
+ });
331
+
332
+ return { channel: "bluebubbles", ...result };
333
+ },
334
+ },
335
+ status: {
336
+ defaultRuntime: {
337
+ accountId: DEFAULT_ACCOUNT_ID,
338
+ running: false,
339
+ lastStartAt: null,
340
+ lastStopAt: null,
341
+ lastError: null,
342
+ },
343
+ collectStatusIssues: collectBlueBubblesStatusIssues,
344
+ buildChannelSummary: ({ snapshot }) => ({
345
+ configured: snapshot.configured ?? false,
346
+ baseUrl: snapshot.baseUrl ?? null,
347
+ running: snapshot.running ?? false,
348
+ lastStartAt: snapshot.lastStartAt ?? null,
349
+ lastStopAt: snapshot.lastStopAt ?? null,
350
+ lastError: snapshot.lastError ?? null,
351
+ probe: snapshot.probe,
352
+ lastProbeAt: snapshot.lastProbeAt ?? null,
353
+ }),
354
+ probeAccount: async ({ account, timeoutMs }) =>
355
+ probeBlueBubbles({
356
+ baseUrl: account.baseUrl,
357
+ password: account.config.password ?? null,
358
+ timeoutMs,
359
+ }),
360
+ buildAccountSnapshot: ({ account, runtime, probe }) => {
361
+ const running = runtime?.running ?? false;
362
+ const probeOk = (probe as BlueBubblesProbe | undefined)?.ok;
363
+ return {
364
+ accountId: account.accountId,
365
+ name: account.name,
366
+ enabled: account.enabled,
367
+ configured: account.configured,
368
+ baseUrl: account.baseUrl,
369
+ running,
370
+ connected: probeOk ?? running,
371
+ lastStartAt: runtime?.lastStartAt ?? null,
372
+ lastStopAt: runtime?.lastStopAt ?? null,
373
+ lastError: runtime?.lastError ?? null,
374
+ probe,
375
+ lastInboundAt: runtime?.lastInboundAt ?? null,
376
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
377
+ };
378
+ },
379
+ },
380
+ gateway: {
381
+ startAccount: async (ctx) => {
382
+ const account = ctx.account;
383
+ const webhookPath = resolveWebhookPathFromConfig(account.config);
384
+ ctx.setStatus({
385
+ accountId: account.accountId,
386
+ baseUrl: account.baseUrl,
387
+ });
388
+ ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
389
+ return monitorBlueBubblesProvider({
390
+ account,
391
+ config: ctx.cfg as OpenClawConfig,
392
+ runtime: ctx.runtime,
393
+ abortSignal: ctx.abortSignal,
394
+ statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
395
+ webhookPath,
396
+ });
397
+ },
398
+ },
399
+ };