@openclaw/bluebubbles 2026.1.29 → 2026.2.1
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/index.ts +0 -1
- package/openclaw.plugin.json +1 -3
- package/package.json +5 -2
- package/src/accounts.ts +13 -5
- package/src/actions.test.ts +1 -2
- package/src/actions.ts +70 -35
- package/src/attachments.test.ts +1 -2
- package/src/attachments.ts +38 -20
- package/src/channel.ts +50 -35
- package/src/chat.test.ts +0 -1
- package/src/chat.ts +48 -24
- package/src/media-send.ts +12 -6
- package/src/monitor.test.ts +253 -57
- package/src/monitor.ts +377 -163
- package/src/onboarding.ts +19 -7
- package/src/probe.ts +19 -11
- package/src/reactions.test.ts +0 -1
- package/src/reactions.ts +20 -15
- package/src/send.test.ts +1 -2
- package/src/send.ts +75 -26
- package/src/targets.test.ts +0 -1
- package/src/targets.ts +151 -52
package/index.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
2
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
|
-
|
|
4
3
|
import { bluebubblesPlugin } from "./src/channel.js";
|
|
5
4
|
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
|
|
6
5
|
import { setBlueBubblesRuntime } from "./src/runtime.js";
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/bluebubbles",
|
|
3
|
-
"version": "2026.1
|
|
4
|
-
"type": "module",
|
|
3
|
+
"version": "2026.2.1",
|
|
5
4
|
"description": "OpenClaw BlueBubbles channel plugin",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"devDependencies": {
|
|
7
|
+
"openclaw": "workspace:*"
|
|
8
|
+
},
|
|
6
9
|
"openclaw": {
|
|
7
10
|
"extensions": [
|
|
8
11
|
"./index.ts"
|
package/src/accounts.ts
CHANGED
|
@@ -13,19 +13,25 @@ export type ResolvedBlueBubblesAccount = {
|
|
|
13
13
|
|
|
14
14
|
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
|
15
15
|
const accounts = cfg.channels?.bluebubbles?.accounts;
|
|
16
|
-
if (!accounts || typeof accounts !== "object")
|
|
16
|
+
if (!accounts || typeof accounts !== "object") {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
17
19
|
return Object.keys(accounts).filter(Boolean);
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export function listBlueBubblesAccountIds(cfg: OpenClawConfig): string[] {
|
|
21
23
|
const ids = listConfiguredAccountIds(cfg);
|
|
22
|
-
if (ids.length === 0)
|
|
23
|
-
|
|
24
|
+
if (ids.length === 0) {
|
|
25
|
+
return [DEFAULT_ACCOUNT_ID];
|
|
26
|
+
}
|
|
27
|
+
return ids.toSorted((a, b) => a.localeCompare(b));
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export function resolveDefaultBlueBubblesAccountId(cfg: OpenClawConfig): string {
|
|
27
31
|
const ids = listBlueBubblesAccountIds(cfg);
|
|
28
|
-
if (ids.includes(DEFAULT_ACCOUNT_ID))
|
|
32
|
+
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
|
33
|
+
return DEFAULT_ACCOUNT_ID;
|
|
34
|
+
}
|
|
29
35
|
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
|
30
36
|
}
|
|
31
37
|
|
|
@@ -34,7 +40,9 @@ function resolveAccountConfig(
|
|
|
34
40
|
accountId: string,
|
|
35
41
|
): BlueBubblesAccountConfig | undefined {
|
|
36
42
|
const accounts = cfg.channels?.bluebubbles?.accounts;
|
|
37
|
-
if (!accounts || typeof accounts !== "object")
|
|
43
|
+
if (!accounts || typeof accounts !== "object") {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
38
46
|
return accounts[accountId] as BlueBubblesAccountConfig | undefined;
|
|
39
47
|
}
|
|
40
48
|
|
package/src/actions.test.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
1
2
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
-
|
|
3
3
|
import { bluebubblesMessageActions } from "./actions.js";
|
|
4
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
5
4
|
|
|
6
5
|
vi.mock("./accounts.js", () => ({
|
|
7
6
|
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
package/src/actions.ts
CHANGED
|
@@ -9,14 +9,10 @@ import {
|
|
|
9
9
|
type ChannelMessageActionAdapter,
|
|
10
10
|
type ChannelMessageActionName,
|
|
11
11
|
type ChannelToolSend,
|
|
12
|
-
type OpenClawConfig,
|
|
13
12
|
} from "openclaw/plugin-sdk";
|
|
14
|
-
|
|
13
|
+
import type { BlueBubblesSendTarget } from "./types.js";
|
|
15
14
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
|
16
|
-
import {
|
|
17
|
-
import { isMacOS26OrHigher } from "./probe.js";
|
|
18
|
-
import { sendBlueBubblesReaction } from "./reactions.js";
|
|
19
|
-
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
|
15
|
+
import { sendBlueBubblesAttachment } from "./attachments.js";
|
|
20
16
|
import {
|
|
21
17
|
editBlueBubblesMessage,
|
|
22
18
|
unsendBlueBubblesMessage,
|
|
@@ -26,16 +22,22 @@ import {
|
|
|
26
22
|
removeBlueBubblesParticipant,
|
|
27
23
|
leaveBlueBubblesChat,
|
|
28
24
|
} from "./chat.js";
|
|
29
|
-
import {
|
|
25
|
+
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
|
26
|
+
import { isMacOS26OrHigher } from "./probe.js";
|
|
27
|
+
import { sendBlueBubblesReaction } from "./reactions.js";
|
|
28
|
+
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
|
30
29
|
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
|
31
|
-
import type { BlueBubblesSendTarget } from "./types.js";
|
|
32
30
|
|
|
33
31
|
const providerId = "bluebubbles";
|
|
34
32
|
|
|
35
33
|
function mapTarget(raw: string): BlueBubblesSendTarget {
|
|
36
34
|
const parsed = parseBlueBubblesTarget(raw);
|
|
37
|
-
if (parsed.kind === "chat_guid")
|
|
38
|
-
|
|
35
|
+
if (parsed.kind === "chat_guid") {
|
|
36
|
+
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
|
|
37
|
+
}
|
|
38
|
+
if (parsed.kind === "chat_id") {
|
|
39
|
+
return { kind: "chat_id", chatId: parsed.chatId };
|
|
40
|
+
}
|
|
39
41
|
if (parsed.kind === "chat_identifier") {
|
|
40
42
|
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
|
|
41
43
|
}
|
|
@@ -52,11 +54,17 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
|
|
|
52
54
|
|
|
53
55
|
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
|
54
56
|
const raw = params[key];
|
|
55
|
-
if (typeof raw === "boolean")
|
|
57
|
+
if (typeof raw === "boolean") {
|
|
58
|
+
return raw;
|
|
59
|
+
}
|
|
56
60
|
if (typeof raw === "string") {
|
|
57
61
|
const trimmed = raw.trim().toLowerCase();
|
|
58
|
-
if (trimmed === "true")
|
|
59
|
-
|
|
62
|
+
if (trimmed === "true") {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (trimmed === "false") {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
60
68
|
}
|
|
61
69
|
return undefined;
|
|
62
70
|
}
|
|
@@ -66,41 +74,55 @@ const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_N
|
|
|
66
74
|
|
|
67
75
|
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
68
76
|
listActions: ({ cfg }) => {
|
|
69
|
-
const account = resolveBlueBubblesAccount({ cfg: cfg
|
|
70
|
-
if (!account.enabled || !account.configured)
|
|
71
|
-
|
|
77
|
+
const account = resolveBlueBubblesAccount({ cfg: cfg });
|
|
78
|
+
if (!account.enabled || !account.configured) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const gate = createActionGate(cfg.channels?.bluebubbles?.actions);
|
|
72
82
|
const actions = new Set<ChannelMessageActionName>();
|
|
73
83
|
const macOS26 = isMacOS26OrHigher(account.accountId);
|
|
74
84
|
for (const action of BLUEBUBBLES_ACTION_NAMES) {
|
|
75
85
|
const spec = BLUEBUBBLES_ACTIONS[action];
|
|
76
|
-
if (!spec?.gate)
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
if (!spec?.gate) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (spec.unsupportedOnMacOS26 && macOS26) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (gate(spec.gate)) {
|
|
93
|
+
actions.add(action);
|
|
94
|
+
}
|
|
79
95
|
}
|
|
80
96
|
return Array.from(actions);
|
|
81
97
|
},
|
|
82
98
|
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
|
83
99
|
extractToolSend: ({ args }): ChannelToolSend | null => {
|
|
84
100
|
const action = typeof args.action === "string" ? args.action.trim() : "";
|
|
85
|
-
if (action !== "sendMessage")
|
|
101
|
+
if (action !== "sendMessage") {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
86
104
|
const to = typeof args.to === "string" ? args.to : undefined;
|
|
87
|
-
if (!to)
|
|
105
|
+
if (!to) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
88
108
|
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
|
89
109
|
return { to, accountId };
|
|
90
110
|
},
|
|
91
111
|
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
|
92
112
|
const account = resolveBlueBubblesAccount({
|
|
93
|
-
cfg: cfg
|
|
113
|
+
cfg: cfg,
|
|
94
114
|
accountId: accountId ?? undefined,
|
|
95
115
|
});
|
|
96
116
|
const baseUrl = account.config.serverUrl?.trim();
|
|
97
117
|
const password = account.config.password?.trim();
|
|
98
|
-
const opts = { cfg: cfg
|
|
118
|
+
const opts = { cfg: cfg, accountId: accountId ?? undefined };
|
|
99
119
|
|
|
100
120
|
// Helper to resolve chatGuid from various params or session context
|
|
101
121
|
const resolveChatGuid = async (): Promise<string> => {
|
|
102
122
|
const chatGuid = readStringParam(params, "chatGuid");
|
|
103
|
-
if (chatGuid?.trim())
|
|
123
|
+
if (chatGuid?.trim()) {
|
|
124
|
+
return chatGuid.trim();
|
|
125
|
+
}
|
|
104
126
|
|
|
105
127
|
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
|
106
128
|
const chatId = readNumberParam(params, "chatId", { integer: true });
|
|
@@ -185,8 +207,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
185
207
|
readStringParam(params, "message");
|
|
186
208
|
if (!rawMessageId || !newText) {
|
|
187
209
|
const missing: string[] = [];
|
|
188
|
-
if (!rawMessageId)
|
|
189
|
-
|
|
210
|
+
if (!rawMessageId) {
|
|
211
|
+
missing.push("messageId (the message ID to edit)");
|
|
212
|
+
}
|
|
213
|
+
if (!newText) {
|
|
214
|
+
missing.push("text (the new message content)");
|
|
215
|
+
}
|
|
190
216
|
throw new Error(
|
|
191
217
|
`BlueBubbles edit requires: ${missing.join(", ")}. ` +
|
|
192
218
|
`Use action=edit with messageId=<message_id>, text=<new_content>.`,
|
|
@@ -234,9 +260,15 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
234
260
|
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
|
235
261
|
if (!rawMessageId || !text || !to) {
|
|
236
262
|
const missing: string[] = [];
|
|
237
|
-
if (!rawMessageId)
|
|
238
|
-
|
|
239
|
-
|
|
263
|
+
if (!rawMessageId) {
|
|
264
|
+
missing.push("messageId (the message ID to reply to)");
|
|
265
|
+
}
|
|
266
|
+
if (!text) {
|
|
267
|
+
missing.push("text or message (the reply message content)");
|
|
268
|
+
}
|
|
269
|
+
if (!to) {
|
|
270
|
+
missing.push("to or target (the chat target)");
|
|
271
|
+
}
|
|
240
272
|
throw new Error(
|
|
241
273
|
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
|
|
242
274
|
`Use action=reply with messageId=<message_id>, message=<your reply>, target=<chat_target>.`,
|
|
@@ -262,12 +294,17 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
262
294
|
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
|
|
263
295
|
if (!text || !to || !effectId) {
|
|
264
296
|
const missing: string[] = [];
|
|
265
|
-
if (!text)
|
|
266
|
-
|
|
267
|
-
|
|
297
|
+
if (!text) {
|
|
298
|
+
missing.push("text or message (the message content)");
|
|
299
|
+
}
|
|
300
|
+
if (!to) {
|
|
301
|
+
missing.push("to or target (the chat target)");
|
|
302
|
+
}
|
|
303
|
+
if (!effectId) {
|
|
268
304
|
missing.push(
|
|
269
305
|
"effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)",
|
|
270
306
|
);
|
|
307
|
+
}
|
|
271
308
|
throw new Error(
|
|
272
309
|
`BlueBubbles sendWithEffect requires: ${missing.join(", ")}. ` +
|
|
273
310
|
`Use action=sendWithEffect with message=<message>, target=<chat_target>, effectId=<effect_name>.`,
|
|
@@ -300,9 +337,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
300
337
|
const resolvedChatGuid = await resolveChatGuid();
|
|
301
338
|
const base64Buffer = readStringParam(params, "buffer");
|
|
302
339
|
const filename =
|
|
303
|
-
readStringParam(params, "filename") ??
|
|
304
|
-
readStringParam(params, "name") ??
|
|
305
|
-
"icon.png";
|
|
340
|
+
readStringParam(params, "filename") ?? readStringParam(params, "name") ?? "icon.png";
|
|
306
341
|
const contentType =
|
|
307
342
|
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
|
308
343
|
|
package/src/attachments.test.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
|
4
2
|
import type { BlueBubblesAttachment } from "./types.js";
|
|
3
|
+
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
|
5
4
|
|
|
6
5
|
vi.mock("./accounts.js", () => ({
|
|
7
6
|
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
package/src/attachments.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
1
2
|
import crypto from "node:crypto";
|
|
2
3
|
import path from "node:path";
|
|
3
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
4
4
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
|
5
5
|
import { resolveChatGuidForTarget } from "./send.js";
|
|
6
6
|
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
|
|
@@ -31,7 +31,9 @@ function sanitizeFilename(input: string | undefined, fallback: string): string {
|
|
|
31
31
|
|
|
32
32
|
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
|
|
33
33
|
const currentExt = path.extname(filename);
|
|
34
|
-
if (currentExt.toLowerCase() === extension)
|
|
34
|
+
if (currentExt.toLowerCase() === extension) {
|
|
35
|
+
return filename;
|
|
36
|
+
}
|
|
35
37
|
const base = currentExt ? filename.slice(0, -currentExt.length) : filename;
|
|
36
38
|
return `${base || fallbackBase}${extension}`;
|
|
37
39
|
}
|
|
@@ -39,8 +41,10 @@ function ensureExtension(filename: string, extension: string, fallbackBase: stri
|
|
|
39
41
|
function resolveVoiceInfo(filename: string, contentType?: string) {
|
|
40
42
|
const normalizedType = contentType?.trim().toLowerCase();
|
|
41
43
|
const extension = path.extname(filename).toLowerCase();
|
|
42
|
-
const isMp3 =
|
|
43
|
-
|
|
44
|
+
const isMp3 =
|
|
45
|
+
extension === ".mp3" || (normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false);
|
|
46
|
+
const isCaf =
|
|
47
|
+
extension === ".caf" || (normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false);
|
|
44
48
|
const isAudio = isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/"));
|
|
45
49
|
return { isAudio, isMp3, isCaf };
|
|
46
50
|
}
|
|
@@ -52,8 +56,12 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
|
|
52
56
|
});
|
|
53
57
|
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
|
54
58
|
const password = params.password?.trim() || account.config.password?.trim();
|
|
55
|
-
if (!baseUrl)
|
|
56
|
-
|
|
59
|
+
if (!baseUrl) {
|
|
60
|
+
throw new Error("BlueBubbles serverUrl is required");
|
|
61
|
+
}
|
|
62
|
+
if (!password) {
|
|
63
|
+
throw new Error("BlueBubbles password is required");
|
|
64
|
+
}
|
|
57
65
|
return { baseUrl, password };
|
|
58
66
|
}
|
|
59
67
|
|
|
@@ -62,7 +70,9 @@ export async function downloadBlueBubblesAttachment(
|
|
|
62
70
|
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
|
|
63
71
|
): Promise<{ buffer: Uint8Array; contentType?: string }> {
|
|
64
72
|
const guid = attachment.guid?.trim();
|
|
65
|
-
if (!guid)
|
|
73
|
+
if (!guid) {
|
|
74
|
+
throw new Error("BlueBubbles attachment guid is required");
|
|
75
|
+
}
|
|
66
76
|
const { baseUrl, password } = resolveAccount(opts);
|
|
67
77
|
const url = buildBlueBubblesApiUrl({
|
|
68
78
|
baseUrl,
|
|
@@ -108,9 +118,14 @@ function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
|
|
108
118
|
}
|
|
109
119
|
|
|
110
120
|
function extractMessageId(payload: unknown): string {
|
|
111
|
-
if (!payload || typeof payload !== "object")
|
|
121
|
+
if (!payload || typeof payload !== "object") {
|
|
122
|
+
return "unknown";
|
|
123
|
+
}
|
|
112
124
|
const record = payload as Record<string, unknown>;
|
|
113
|
-
const data =
|
|
125
|
+
const data =
|
|
126
|
+
record.data && typeof record.data === "object"
|
|
127
|
+
? (record.data as Record<string, unknown>)
|
|
128
|
+
: null;
|
|
114
129
|
const candidates = [
|
|
115
130
|
record.messageId,
|
|
116
131
|
record.guid,
|
|
@@ -120,8 +135,12 @@ function extractMessageId(payload: unknown): string {
|
|
|
120
135
|
data?.id,
|
|
121
136
|
];
|
|
122
137
|
for (const candidate of candidates) {
|
|
123
|
-
if (typeof candidate === "string" && candidate.trim())
|
|
124
|
-
|
|
138
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
139
|
+
return candidate.trim();
|
|
140
|
+
}
|
|
141
|
+
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
|
142
|
+
return String(candidate);
|
|
143
|
+
}
|
|
125
144
|
}
|
|
126
145
|
return "unknown";
|
|
127
146
|
}
|
|
@@ -205,9 +224,7 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
205
224
|
const addFile = (name: string, fileBuffer: Uint8Array, fileName: string, mimeType?: string) => {
|
|
206
225
|
parts.push(encoder.encode(`--${boundary}\r\n`));
|
|
207
226
|
parts.push(
|
|
208
|
-
encoder.encode(
|
|
209
|
-
`Content-Disposition: form-data; name="${name}"; filename="${fileName}"\r\n`,
|
|
210
|
-
),
|
|
227
|
+
encoder.encode(`Content-Disposition: form-data; name="${name}"; filename="${fileName}"\r\n`),
|
|
211
228
|
);
|
|
212
229
|
parts.push(encoder.encode(`Content-Type: ${mimeType ?? "application/octet-stream"}\r\n\r\n`));
|
|
213
230
|
parts.push(fileBuffer);
|
|
@@ -229,10 +246,7 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
229
246
|
const trimmedReplyTo = replyToMessageGuid?.trim();
|
|
230
247
|
if (trimmedReplyTo) {
|
|
231
248
|
addField("selectedMessageGuid", trimmedReplyTo);
|
|
232
|
-
addField(
|
|
233
|
-
"partIndex",
|
|
234
|
-
typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0",
|
|
235
|
-
);
|
|
249
|
+
addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
|
|
236
250
|
}
|
|
237
251
|
|
|
238
252
|
// Add optional caption
|
|
@@ -268,11 +282,15 @@ export async function sendBlueBubblesAttachment(params: {
|
|
|
268
282
|
|
|
269
283
|
if (!res.ok) {
|
|
270
284
|
const errorText = await res.text();
|
|
271
|
-
throw new Error(
|
|
285
|
+
throw new Error(
|
|
286
|
+
`BlueBubbles attachment send failed (${res.status}): ${errorText || "unknown"}`,
|
|
287
|
+
);
|
|
272
288
|
}
|
|
273
289
|
|
|
274
290
|
const responseBody = await res.text();
|
|
275
|
-
if (!responseBody)
|
|
291
|
+
if (!responseBody) {
|
|
292
|
+
return { messageId: "ok" };
|
|
293
|
+
}
|
|
276
294
|
try {
|
|
277
295
|
const parsed = JSON.parse(responseBody) as unknown;
|
|
278
296
|
return { messageId: extractMessageId(parsed) };
|
package/src/channel.ts
CHANGED
|
@@ -13,15 +13,18 @@ import {
|
|
|
13
13
|
resolveBlueBubblesGroupToolPolicy,
|
|
14
14
|
setAccountEnabledInConfigSection,
|
|
15
15
|
} from "openclaw/plugin-sdk";
|
|
16
|
-
|
|
17
16
|
import {
|
|
18
17
|
listBlueBubblesAccountIds,
|
|
19
18
|
type ResolvedBlueBubblesAccount,
|
|
20
19
|
resolveBlueBubblesAccount,
|
|
21
20
|
resolveDefaultBlueBubblesAccountId,
|
|
22
21
|
} from "./accounts.js";
|
|
22
|
+
import { bluebubblesMessageActions } from "./actions.js";
|
|
23
23
|
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
|
24
|
+
import { sendBlueBubblesMedia } from "./media-send.js";
|
|
24
25
|
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
|
26
|
+
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
|
27
|
+
import { blueBubblesOnboardingAdapter } from "./onboarding.js";
|
|
25
28
|
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
|
26
29
|
import { sendMessageBlueBubbles } from "./send.js";
|
|
27
30
|
import {
|
|
@@ -31,10 +34,6 @@ import {
|
|
|
31
34
|
normalizeBlueBubblesMessagingTarget,
|
|
32
35
|
parseBlueBubblesTarget,
|
|
33
36
|
} 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
37
|
|
|
39
38
|
const meta = {
|
|
40
39
|
id: "bluebubbles",
|
|
@@ -78,13 +77,12 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
78
77
|
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
|
79
78
|
onboarding: blueBubblesOnboardingAdapter,
|
|
80
79
|
config: {
|
|
81
|
-
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg
|
|
82
|
-
resolveAccount: (cfg, accountId) =>
|
|
83
|
-
|
|
84
|
-
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg as OpenClawConfig),
|
|
80
|
+
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg),
|
|
81
|
+
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }),
|
|
82
|
+
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg),
|
|
85
83
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
86
84
|
setAccountEnabledInConfigSection({
|
|
87
|
-
cfg: cfg
|
|
85
|
+
cfg: cfg,
|
|
88
86
|
sectionKey: "bluebubbles",
|
|
89
87
|
accountId,
|
|
90
88
|
enabled,
|
|
@@ -92,7 +90,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
92
90
|
}),
|
|
93
91
|
deleteAccount: ({ cfg, accountId }) =>
|
|
94
92
|
deleteAccountFromConfigSection({
|
|
95
|
-
cfg: cfg
|
|
93
|
+
cfg: cfg,
|
|
96
94
|
sectionKey: "bluebubbles",
|
|
97
95
|
accountId,
|
|
98
96
|
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
|
|
@@ -106,9 +104,8 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
106
104
|
baseUrl: account.baseUrl,
|
|
107
105
|
}),
|
|
108
106
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
109
|
-
(resolveBlueBubblesAccount({ cfg: cfg
|
|
110
|
-
|
|
111
|
-
(entry) => String(entry),
|
|
107
|
+
(resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
|
|
108
|
+
String(entry),
|
|
112
109
|
),
|
|
113
110
|
formatAllowFrom: ({ allowFrom }) =>
|
|
114
111
|
allowFrom
|
|
@@ -121,9 +118,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
121
118
|
security: {
|
|
122
119
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
123
120
|
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
124
|
-
const useAccountPath = Boolean(
|
|
125
|
-
(cfg as OpenClawConfig).channels?.bluebubbles?.accounts?.[resolvedAccountId],
|
|
126
|
-
);
|
|
121
|
+
const useAccountPath = Boolean(cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId]);
|
|
127
122
|
const basePath = useAccountPath
|
|
128
123
|
? `channels.bluebubbles.accounts.${resolvedAccountId}.`
|
|
129
124
|
: "channels.bluebubbles.";
|
|
@@ -138,7 +133,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
138
133
|
},
|
|
139
134
|
collectWarnings: ({ account }) => {
|
|
140
135
|
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
|
141
|
-
if (groupPolicy !== "open")
|
|
136
|
+
if (groupPolicy !== "open") {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
142
139
|
return [
|
|
143
140
|
`- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`,
|
|
144
141
|
];
|
|
@@ -152,19 +149,25 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
152
149
|
},
|
|
153
150
|
formatTargetDisplay: ({ target, display }) => {
|
|
154
151
|
const shouldParseDisplay = (value: string): boolean => {
|
|
155
|
-
if (looksLikeBlueBubblesTargetId(value))
|
|
152
|
+
if (looksLikeBlueBubblesTargetId(value)) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
156
155
|
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
|
|
157
156
|
};
|
|
158
157
|
|
|
159
158
|
// Helper to extract a clean handle from any BlueBubbles target format
|
|
160
159
|
const extractCleanDisplay = (value: string | undefined): string | null => {
|
|
161
160
|
const trimmed = value?.trim();
|
|
162
|
-
if (!trimmed)
|
|
161
|
+
if (!trimmed) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
163
164
|
try {
|
|
164
165
|
const parsed = parseBlueBubblesTarget(trimmed);
|
|
165
166
|
if (parsed.kind === "chat_guid") {
|
|
166
167
|
const handle = extractHandleFromChatGuid(parsed.chatGuid);
|
|
167
|
-
if (handle)
|
|
168
|
+
if (handle) {
|
|
169
|
+
return handle;
|
|
170
|
+
}
|
|
168
171
|
}
|
|
169
172
|
if (parsed.kind === "handle") {
|
|
170
173
|
return normalizeBlueBubblesHandle(parsed.to);
|
|
@@ -179,9 +182,13 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
179
182
|
.replace(/^chat_id:/i, "")
|
|
180
183
|
.replace(/^chat_identifier:/i, "");
|
|
181
184
|
const handle = extractHandleFromChatGuid(stripped);
|
|
182
|
-
if (handle)
|
|
185
|
+
if (handle) {
|
|
186
|
+
return handle;
|
|
187
|
+
}
|
|
183
188
|
// Don't return raw chat_guid formats - they contain internal routing info
|
|
184
|
-
if (stripped.includes(";-;") || stripped.includes(";+;"))
|
|
189
|
+
if (stripped.includes(";-;") || stripped.includes(";+;")) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
185
192
|
return stripped;
|
|
186
193
|
};
|
|
187
194
|
|
|
@@ -192,12 +199,16 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
192
199
|
return trimmedDisplay;
|
|
193
200
|
}
|
|
194
201
|
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
|
|
195
|
-
if (cleanDisplay)
|
|
202
|
+
if (cleanDisplay) {
|
|
203
|
+
return cleanDisplay;
|
|
204
|
+
}
|
|
196
205
|
}
|
|
197
206
|
|
|
198
207
|
// Fall back to extracting from target
|
|
199
208
|
const cleanTarget = extractCleanDisplay(target);
|
|
200
|
-
if (cleanTarget)
|
|
209
|
+
if (cleanTarget) {
|
|
210
|
+
return cleanTarget;
|
|
211
|
+
}
|
|
201
212
|
|
|
202
213
|
// Last resort: return display or target as-is
|
|
203
214
|
return display?.trim() || target?.trim() || "";
|
|
@@ -207,7 +218,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
207
218
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
208
219
|
applyAccountName: ({ cfg, accountId, name }) =>
|
|
209
220
|
applyAccountNameToChannelSection({
|
|
210
|
-
cfg: cfg
|
|
221
|
+
cfg: cfg,
|
|
211
222
|
channelKey: "bluebubbles",
|
|
212
223
|
accountId,
|
|
213
224
|
name,
|
|
@@ -216,13 +227,17 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
216
227
|
if (!input.httpUrl && !input.password) {
|
|
217
228
|
return "BlueBubbles requires --http-url and --password.";
|
|
218
229
|
}
|
|
219
|
-
if (!input.httpUrl)
|
|
220
|
-
|
|
230
|
+
if (!input.httpUrl) {
|
|
231
|
+
return "BlueBubbles requires --http-url.";
|
|
232
|
+
}
|
|
233
|
+
if (!input.password) {
|
|
234
|
+
return "BlueBubbles requires --password.";
|
|
235
|
+
}
|
|
221
236
|
return null;
|
|
222
237
|
},
|
|
223
238
|
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
224
239
|
const namedConfig = applyAccountNameToChannelSection({
|
|
225
|
-
cfg: cfg
|
|
240
|
+
cfg: cfg,
|
|
226
241
|
channelKey: "bluebubbles",
|
|
227
242
|
accountId,
|
|
228
243
|
name: input.name,
|
|
@@ -257,9 +272,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
257
272
|
...next.channels?.bluebubbles,
|
|
258
273
|
enabled: true,
|
|
259
274
|
accounts: {
|
|
260
|
-
...
|
|
275
|
+
...next.channels?.bluebubbles?.accounts,
|
|
261
276
|
[accountId]: {
|
|
262
|
-
...
|
|
277
|
+
...next.channels?.bluebubbles?.accounts?.[accountId],
|
|
263
278
|
enabled: true,
|
|
264
279
|
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
|
265
280
|
...(input.password ? { password: input.password } : {}),
|
|
@@ -276,7 +291,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
276
291
|
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
|
277
292
|
notifyApproval: async ({ cfg, id }) => {
|
|
278
293
|
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
|
|
279
|
-
cfg: cfg
|
|
294
|
+
cfg: cfg,
|
|
280
295
|
});
|
|
281
296
|
},
|
|
282
297
|
},
|
|
@@ -300,7 +315,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
300
315
|
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
|
301
316
|
: "";
|
|
302
317
|
const result = await sendMessageBlueBubbles(to, text, {
|
|
303
|
-
cfg: cfg
|
|
318
|
+
cfg: cfg,
|
|
304
319
|
accountId: accountId ?? undefined,
|
|
305
320
|
replyToMessageGuid: replyToMessageGuid || undefined,
|
|
306
321
|
});
|
|
@@ -317,7 +332,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
317
332
|
};
|
|
318
333
|
const resolvedCaption = caption ?? text;
|
|
319
334
|
const result = await sendBlueBubblesMedia({
|
|
320
|
-
cfg: cfg
|
|
335
|
+
cfg: cfg,
|
|
321
336
|
to,
|
|
322
337
|
mediaUrl,
|
|
323
338
|
mediaPath,
|
|
@@ -388,7 +403,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
|
388
403
|
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
|
|
389
404
|
return monitorBlueBubblesProvider({
|
|
390
405
|
account,
|
|
391
|
-
config: ctx.cfg
|
|
406
|
+
config: ctx.cfg,
|
|
392
407
|
runtime: ctx.runtime,
|
|
393
408
|
abortSignal: ctx.abortSignal,
|
|
394
409
|
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|