@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.
@@ -1,5 +1,6 @@
1
- import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
1
+ import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
2
2
  import { z } from "zod";
3
+ import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
3
4
 
4
5
  const allowFromEntry = z.union([z.string(), z.number()]);
5
6
 
@@ -30,7 +31,7 @@ const bluebubblesAccountSchema = z
30
31
  enabled: z.boolean().optional(),
31
32
  markdown: MarkdownConfigSchema,
32
33
  serverUrl: z.string().optional(),
33
- password: z.string().optional(),
34
+ password: buildSecretInputSchema().optional(),
34
35
  webhookPath: z.string().optional(),
35
36
  dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
36
37
  allowFrom: z.array(allowFromEntry).optional(),
@@ -49,8 +50,8 @@ const bluebubblesAccountSchema = z
49
50
  })
50
51
  .superRefine((value, ctx) => {
51
52
  const serverUrl = value.serverUrl?.trim() ?? "";
52
- const password = value.password?.trim() ?? "";
53
- if (serverUrl && !password) {
53
+ const passwordConfigured = hasConfiguredSecretInput(value.password);
54
+ if (serverUrl && !passwordConfigured) {
54
55
  ctx.addIssue({
55
56
  code: z.ZodIssueCode.custom,
56
57
  path: ["password"],
package/src/history.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
2
2
  import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
3
3
  import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
4
4
 
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
- import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
5
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
6
6
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
7
  import { sendBlueBubblesMedia } from "./media-send.js";
8
8
  import { setBlueBubblesRuntime } from "./runtime.js";
package/src/media-send.ts CHANGED
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk";
6
+ import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
7
7
  import { resolveBlueBubblesAccount } from "./accounts.js";
8
8
  import { sendBlueBubblesAttachment } from "./attachments.js";
9
9
  import { resolveBlueBubblesMessageId } from "./monitor.js";
@@ -0,0 +1,205 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
2
+ import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
3
+ import type { BlueBubblesCoreRuntime, WebhookTarget } from "./monitor-shared.js";
4
+
5
+ /**
6
+ * Entry type for debouncing inbound messages.
7
+ * Captures the normalized message and its target for later combined processing.
8
+ */
9
+ type BlueBubblesDebounceEntry = {
10
+ message: NormalizedWebhookMessage;
11
+ target: WebhookTarget;
12
+ };
13
+
14
+ export type BlueBubblesDebouncer = {
15
+ enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
16
+ flushKey: (key: string) => Promise<void>;
17
+ };
18
+
19
+ export type BlueBubblesDebounceRegistry = {
20
+ getOrCreateDebouncer: (target: WebhookTarget) => BlueBubblesDebouncer;
21
+ removeDebouncer: (target: WebhookTarget) => void;
22
+ };
23
+
24
+ /**
25
+ * Default debounce window for inbound message coalescing (ms).
26
+ * This helps combine URL text + link preview balloon messages that BlueBubbles
27
+ * sends as separate webhook events when no explicit inbound debounce config exists.
28
+ */
29
+ const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
30
+
31
+ /**
32
+ * Combines multiple debounced messages into a single message for processing.
33
+ * Used when multiple webhook events arrive within the debounce window.
34
+ */
35
+ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
36
+ if (entries.length === 0) {
37
+ throw new Error("Cannot combine empty entries");
38
+ }
39
+ if (entries.length === 1) {
40
+ return entries[0].message;
41
+ }
42
+
43
+ // Use the first message as the base (typically the text message)
44
+ const first = entries[0].message;
45
+
46
+ // Combine text from all entries, filtering out duplicates and empty strings
47
+ const seenTexts = new Set<string>();
48
+ const textParts: string[] = [];
49
+
50
+ for (const entry of entries) {
51
+ const text = entry.message.text.trim();
52
+ if (!text) {
53
+ continue;
54
+ }
55
+ // Skip duplicate text (URL might be in both text message and balloon)
56
+ const normalizedText = text.toLowerCase();
57
+ if (seenTexts.has(normalizedText)) {
58
+ continue;
59
+ }
60
+ seenTexts.add(normalizedText);
61
+ textParts.push(text);
62
+ }
63
+
64
+ // Merge attachments from all entries
65
+ const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
66
+
67
+ // Use the latest timestamp
68
+ const timestamps = entries
69
+ .map((e) => e.message.timestamp)
70
+ .filter((t): t is number => typeof t === "number");
71
+ const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
72
+
73
+ // Collect all message IDs for reference
74
+ const messageIds = entries
75
+ .map((e) => e.message.messageId)
76
+ .filter((id): id is string => Boolean(id));
77
+
78
+ // Prefer reply context from any entry that has it
79
+ const entryWithReply = entries.find((e) => e.message.replyToId);
80
+
81
+ return {
82
+ ...first,
83
+ text: textParts.join(" "),
84
+ attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
85
+ timestamp: latestTimestamp,
86
+ // Use first message's ID as primary (for reply reference), but we've coalesced others
87
+ messageId: messageIds[0] ?? first.messageId,
88
+ // Preserve reply context if present
89
+ replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
90
+ replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
91
+ replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
92
+ // Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
93
+ balloonBundleId: undefined,
94
+ };
95
+ }
96
+
97
+ function resolveBlueBubblesDebounceMs(
98
+ config: OpenClawConfig,
99
+ core: BlueBubblesCoreRuntime,
100
+ ): number {
101
+ const inbound = config.messages?.inbound;
102
+ const hasExplicitDebounce =
103
+ typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
104
+ if (!hasExplicitDebounce) {
105
+ return DEFAULT_INBOUND_DEBOUNCE_MS;
106
+ }
107
+ return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
108
+ }
109
+
110
+ export function createBlueBubblesDebounceRegistry(params: {
111
+ processMessage: (message: NormalizedWebhookMessage, target: WebhookTarget) => Promise<void>;
112
+ }): BlueBubblesDebounceRegistry {
113
+ const targetDebouncers = new Map<WebhookTarget, BlueBubblesDebouncer>();
114
+
115
+ return {
116
+ getOrCreateDebouncer: (target) => {
117
+ const existing = targetDebouncers.get(target);
118
+ if (existing) {
119
+ return existing;
120
+ }
121
+
122
+ const { account, config, runtime, core } = target;
123
+ const debouncer = core.channel.debounce.createInboundDebouncer<BlueBubblesDebounceEntry>({
124
+ debounceMs: resolveBlueBubblesDebounceMs(config, core),
125
+ buildKey: (entry) => {
126
+ const msg = entry.message;
127
+ // Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
128
+ // same message (e.g., text-only then text+attachment).
129
+ //
130
+ // For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
131
+ // messageId than the originating text. When present, key by associatedMessageGuid
132
+ // to keep text + balloon coalescing working.
133
+ const balloonBundleId = msg.balloonBundleId?.trim();
134
+ const associatedMessageGuid = msg.associatedMessageGuid?.trim();
135
+ if (balloonBundleId && associatedMessageGuid) {
136
+ return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
137
+ }
138
+
139
+ const messageId = msg.messageId?.trim();
140
+ if (messageId) {
141
+ return `bluebubbles:${account.accountId}:msg:${messageId}`;
142
+ }
143
+
144
+ const chatKey =
145
+ msg.chatGuid?.trim() ??
146
+ msg.chatIdentifier?.trim() ??
147
+ (msg.chatId ? String(msg.chatId) : "dm");
148
+ return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
149
+ },
150
+ shouldDebounce: (entry) => {
151
+ const msg = entry.message;
152
+ // Skip debouncing for from-me messages (they're just cached, not processed)
153
+ if (msg.fromMe) {
154
+ return false;
155
+ }
156
+ // Skip debouncing for control commands - process immediately
157
+ if (core.channel.text.hasControlCommand(msg.text, config)) {
158
+ return false;
159
+ }
160
+ // Debounce all other messages to coalesce rapid-fire webhook events
161
+ // (e.g., text+image arriving as separate webhooks for the same messageId)
162
+ return true;
163
+ },
164
+ onFlush: async (entries) => {
165
+ if (entries.length === 0) {
166
+ return;
167
+ }
168
+
169
+ // Use target from first entry (all entries have same target due to key structure)
170
+ const flushTarget = entries[0].target;
171
+
172
+ if (entries.length === 1) {
173
+ // Single message - process normally
174
+ await params.processMessage(entries[0].message, flushTarget);
175
+ return;
176
+ }
177
+
178
+ // Multiple messages - combine and process
179
+ const combined = combineDebounceEntries(entries);
180
+
181
+ if (core.logging.shouldLogVerbose()) {
182
+ const count = entries.length;
183
+ const preview = combined.text.slice(0, 50);
184
+ runtime.log?.(
185
+ `[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
186
+ );
187
+ }
188
+
189
+ await params.processMessage(combined, flushTarget);
190
+ },
191
+ onError: (err) => {
192
+ runtime.error?.(
193
+ `[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`,
194
+ );
195
+ },
196
+ });
197
+
198
+ targetDebouncers.set(target, debouncer);
199
+ return debouncer;
200
+ },
201
+ removeDebouncer: (target) => {
202
+ targetDebouncers.delete(target);
203
+ },
204
+ };
205
+ }
@@ -1,3 +1,4 @@
1
+ import { parseFiniteNumber } from "../../../src/infra/parse-finite-number.js";
1
2
  import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
2
3
  import type { BlueBubblesAttachment } from "./types.js";
3
4
 
@@ -35,17 +36,7 @@ function readNumberLike(record: Record<string, unknown> | null, key: string): nu
35
36
  if (!record) {
36
37
  return undefined;
37
38
  }
38
- const value = record[key];
39
- if (typeof value === "number" && Number.isFinite(value)) {
40
- return value;
41
- }
42
- if (typeof value === "string") {
43
- const parsed = Number.parseFloat(value);
44
- if (Number.isFinite(parsed)) {
45
- return parsed;
46
- }
47
- }
48
- return undefined;
39
+ return parseFiniteNumber(record[key]);
49
40
  }
50
41
 
51
42
  function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
@@ -1,12 +1,14 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
2
2
  import {
3
3
  DM_GROUP_ACCESS_REASON,
4
4
  createScopedPairingAccess,
5
5
  createReplyPrefixOptions,
6
6
  evictOldHistoryKeys,
7
+ issuePairingChallenge,
7
8
  logAckFailure,
8
9
  logInboundDrop,
9
10
  logTypingFailure,
11
+ mapAllowFromEntries,
10
12
  readStoreAllowFromForDmPolicy,
11
13
  recordPendingHistoryEntryIfEnabled,
12
14
  resolveAckReaction,
@@ -14,7 +16,7 @@ import {
14
16
  resolveControlCommandGate,
15
17
  stripMarkdown,
16
18
  type HistoryEntry,
17
- } from "openclaw/plugin-sdk";
19
+ } from "openclaw/plugin-sdk/bluebubbles";
18
20
  import { downloadBlueBubblesAttachment } from "./attachments.js";
19
21
  import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
20
22
  import { fetchBlueBubblesHistory } from "./history.js";
@@ -43,6 +45,7 @@ import type {
43
45
  } from "./monitor-shared.js";
44
46
  import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
45
47
  import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
48
+ import { normalizeSecretInputString } from "./secret-input.js";
46
49
  import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
47
50
  import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
48
51
 
@@ -508,7 +511,7 @@ export async function processMessage(
508
511
 
509
512
  const dmPolicy = account.config.dmPolicy ?? "pairing";
510
513
  const groupPolicy = account.config.groupPolicy ?? "allowlist";
511
- const configuredAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
514
+ const configuredAllowFrom = mapAllowFromEntries(account.config.allowFrom);
512
515
  const storeAllowFrom = await readStoreAllowFromForDmPolicy({
513
516
  provider: "bluebubbles",
514
517
  accountId: account.accountId,
@@ -594,25 +597,24 @@ export async function processMessage(
594
597
  }
595
598
 
596
599
  if (accessDecision.decision === "pairing") {
597
- const { code, created } = await pairing.upsertPairingRequest({
598
- id: message.senderId,
600
+ await issuePairingChallenge({
601
+ channel: "bluebubbles",
602
+ senderId: message.senderId,
603
+ senderIdLine: `Your BlueBubbles sender id: ${message.senderId}`,
599
604
  meta: { name: message.senderName },
600
- });
601
- runtime.log?.(`[bluebubbles] pairing request sender=${message.senderId} created=${created}`);
602
- if (created) {
603
- logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
604
- try {
605
- await sendMessageBlueBubbles(
606
- message.senderId,
607
- core.channel.pairing.buildPairingReply({
608
- channel: "bluebubbles",
609
- idLine: `Your BlueBubbles sender id: ${message.senderId}`,
610
- code,
611
- }),
612
- { cfg: config, accountId: account.accountId },
613
- );
605
+ upsertPairingRequest: pairing.upsertPairingRequest,
606
+ onCreated: () => {
607
+ runtime.log?.(`[bluebubbles] pairing request sender=${message.senderId} created=true`);
608
+ logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
609
+ },
610
+ sendPairingReply: async (text) => {
611
+ await sendMessageBlueBubbles(message.senderId, text, {
612
+ cfg: config,
613
+ accountId: account.accountId,
614
+ });
614
615
  statusSink?.({ lastOutboundAt: Date.now() });
615
- } catch (err) {
616
+ },
617
+ onReplyError: (err) => {
616
618
  logVerbose(
617
619
  core,
618
620
  runtime,
@@ -621,8 +623,8 @@ export async function processMessage(
621
623
  runtime.error?.(
622
624
  `[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`,
623
625
  );
624
- }
625
- }
626
+ },
627
+ });
626
628
  return;
627
629
  }
628
630
 
@@ -731,8 +733,8 @@ export async function processMessage(
731
733
  // surfacing dropped content (allowlist/mention/command gating).
732
734
  cacheInboundMessage();
733
735
 
734
- const baseUrl = account.config.serverUrl?.trim();
735
- const password = account.config.password?.trim();
736
+ const baseUrl = normalizeSecretInputString(account.config.serverUrl);
737
+ const password = normalizeSecretInputString(account.config.password);
736
738
  const maxBytes =
737
739
  account.config.mediaMaxMb && account.config.mediaMaxMb > 0
738
740
  ? account.config.mediaMaxMb * 1024 * 1024
@@ -1,4 +1,4 @@
1
- import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
2
2
  import type { ResolvedBlueBubblesAccount } from "./accounts.js";
3
3
  import { getBlueBubblesRuntime } from "./runtime.js";
4
4
  import type { BlueBubblesAccountConfig } from "./types.js";