import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param" ;
import {
createActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
} from "openclaw/plugin-sdk/channel-actions" ;
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime" ;
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime" ;
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime" ;
import { extractToolSend } from "openclaw/plugin-sdk/tool-send" ;
import { resolveBlueBubblesAccount } from "./accounts.js" ;
import {
BLUEBUBBLES_ACTION_NAMES,
BLUEBUBBLES_ACTIONS,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
} from "./actions-api.js" ;
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js" ;
import { normalizeSecretInputString } from "./secret-input.js" ;
import {
normalizeBlueBubblesHandle,
normalizeBlueBubblesMessagingTarget,
parseBlueBubblesTarget,
} from "./targets.js" ;
import type { BlueBubblesSendTarget } from "./types.js" ;
const loadBlueBubblesActionsRuntime = createLazyRuntimeNamedExport(
() => import ("./actions.runtime.js" ),
"blueBubblesActionsRuntime" ,
);
const providerId = "bluebubbles" ;
function mapTarget(raw: string): BlueBubblesSendTarget {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "chat_guid" ) {
return { kind: "chat_guid" , chatGuid: parsed.chatGuid };
}
if (parsed.kind === "chat_id" ) {
return { kind: "chat_id" , chatId: parsed.chatId };
}
if (parsed.kind === "chat_identifier" ) {
return { kind: "chat_identifier" , chatIdentifier: parsed.chatIdentifier };
}
return {
kind: "handle" ,
address: normalizeBlueBubblesHandle(parsed.to),
service: parsed.service,
};
}
function readMessageText(params: Record<string, unknown>): string | undefined {
return readStringParam(params, "text" ) ?? readStringParam(params, "message" );
}
/** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
...BLUEBUBBLES_ACTION_NAMES,
"upload-file" ,
]);
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
"react" ,
"edit" ,
"unsend" ,
"reply" ,
"sendWithEffect" ,
"renameGroup" ,
"setGroupIcon" ,
"addParticipant" ,
"removeParticipant" ,
"leaveGroup" ,
]);
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: ({ cfg, accountId, currentChannelId }) => {
const account = resolveBlueBubblesAccount({ cfg, accountId });
if (!account.enabled || !account.configured) {
return null ;
}
const gate = createActionGate(account.config.actions);
const actions = new Set<ChannelMessageActionName>();
const macOS26 = isMacOS26OrHigher(account.accountId);
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);
for (const action of BLUEBUBBLES_ACTION_NAMES) {
const spec = BLUEBUBBLES_ACTIONS[action];
if (!spec?.gate) {
continue ;
}
if (privateApiStatus === false && PRIVATE_API_ACTIONS.has(action)) {
continue ;
}
if ("unsupportedOnMacOS26" in spec && spec.unsupportedOnMacOS26 && macOS26) {
continue ;
}
if (gate(spec.gate)) {
actions.add(action);
}
}
const normalizedTarget = currentChannelId
? normalizeBlueBubblesMessagingTarget(currentChannelId)
: undefined;
const lowered = normalizeOptionalLowercaseString(normalizedTarget) ?? "" ;
const isGroupTarget =
lowered.startsWith("chat_guid:" ) ||
lowered.startsWith("chat_id:" ) ||
lowered.startsWith("chat_identifier:" ) ||
lowered.startsWith("group:" );
if (!isGroupTarget) {
for (const action of BLUEBUBBLES_ACTION_NAMES) {
if ("groupOnly" in BLUEBUBBLES_ACTIONS[action] && BLUEBUBBLES_ACTIONS[action].groupOnly) {
actions.delete (action);
}
}
}
if (actions.delete ("sendAttachment" )) {
actions.add("upload-file" );
}
return { actions: Array.from(actions) };
},
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage" ),
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
const runtime = await loadBlueBubblesActionsRuntime();
const account = resolveBlueBubblesAccount({
cfg: cfg,
accountId: accountId ?? undefined,
});
const baseUrl = normalizeSecretInputString(account.config.serverUrl);
const password = normalizeSecretInputString(account.config.password);
const opts = { cfg: cfg, accountId: accountId ?? undefined };
const assertPrivateApiEnabled = () => {
if (getCachedBlueBubblesPrivateApiStatus(account.accountId) === false ) {
throw new Error(
`BlueBubbles ${action} requires Private API, but it is disabled on the BlueBubbles server.`,
);
}
};
// Helper to resolve chatGuid from various params or session context
const resolveChatGuid = async (): Promise<string> => {
const chatGuid = readStringParam(params, "chatGuid" );
if (chatGuid?.trim()) {
return chatGuid.trim();
}
const chatIdentifier = readStringParam(params, "chatIdentifier" );
const chatId = readNumberParam(params, "chatId" , { integer: true });
const to = readStringParam(params, "to" );
// Fall back to session context if no explicit target provided
const contextTarget = toolContext?.currentChannelId?.trim();
const target = chatIdentifier?.trim()
? ({
kind: "chat_identifier" ,
chatIdentifier: chatIdentifier.trim(),
} as BlueBubblesSendTarget)
: typeof chatId === "number"
? ({ kind: "chat_id" , chatId } as BlueBubblesSendTarget)
: to
? mapTarget(to)
: contextTarget
? mapTarget(contextTarget)
: null ;
if (!target) {
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
}
if (!baseUrl || !password) {
throw new Error(`BlueBubbles ${action} requires serverUrl and password.`);
}
const resolved = await runtime.resolveChatGuidForTarget({
baseUrl,
password,
target,
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
});
if (!resolved) {
throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`);
}
return resolved;
};
// Handle react action
if (action === "react" ) {
assertPrivateApiEnabled();
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction." ,
});
if (isEmpty && !remove) {
throw new Error(
"BlueBubbles react requires emoji parameter. Use action=react with emoji=<emoji> and messageId=<message_id>." ,
);
}
const rawMessageId = readStringParam(params, "messageId" );
if (!rawMessageId) {
throw new Error(
"BlueBubbles react requires messageId parameter (the message ID to react to). " +
"Use action=react with messageId=<message_id>, emoji=<emoji>, and to/chatGuid to identify the chat." ,
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
requireKnownShortId: true ,
});
const partIndex = readNumberParam(params, "partIndex" , { integer: true });
const resolvedChatGuid = await resolveChatGuid();
await runtime.sendBlueBubblesReaction({
chatGuid: resolvedChatGuid,
messageGuid: messageId,
emoji,
remove: remove || undefined,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
opts,
});
return jsonResult({ ok: true , ...(remove ? { removed: true } : { added: emoji }) });
}
// Handle edit action
if (action === "edit" ) {
assertPrivateApiEnabled();
// Edit is not supported on macOS 26+
if (isMacOS26OrHigher(accountId ?? undefined)) {
throw new Error(
"BlueBubbles edit is not supported on macOS 26 or higher. " +
"Apple removed the ability to edit iMessages in this version." ,
);
}
const rawMessageId = readStringParam(params, "messageId" );
const newText =
readStringParam(params, "text" ) ??
readStringParam(params, "newText" ) ??
readStringParam(params, "message" );
if (!rawMessageId || !newText) {
const missing: string[] = [];
if (!rawMessageId) {
missing.push("messageId (the message ID to edit)" );
}
if (!newText) {
missing.push("text (the new message content)" );
}
throw new Error(
`BlueBubbles edit requires: ${missing.join(", " )}. ` +
`Use action=edit with messageId=<message_id>, text=<new_content>.`,
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
requireKnownShortId: true ,
});
const partIndex = readNumberParam(params, "partIndex" , { integer: true });
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage" );
await runtime.editBlueBubblesMessage(messageId, newText, {
...opts,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
});
return jsonResult({ ok: true , edited: rawMessageId });
}
// Handle unsend action
if (action === "unsend" ) {
assertPrivateApiEnabled();
const rawMessageId = readStringParam(params, "messageId" );
if (!rawMessageId) {
throw new Error(
"BlueBubbles unsend requires messageId parameter (the message ID to unsend). " +
"Use action=unsend with messageId=<message_id>." ,
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
requireKnownShortId: true ,
});
const partIndex = readNumberParam(params, "partIndex" , { integer: true });
await runtime.unsendBlueBubblesMessage(messageId, {
...opts,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
});
return jsonResult({ ok: true , unsent: rawMessageId });
}
// Handle reply action
if (action === "reply" ) {
assertPrivateApiEnabled();
const rawMessageId = readStringParam(params, "messageId" );
const text = readMessageText(params);
const to = readStringParam(params, "to" ) ?? readStringParam(params, "target" );
if (!rawMessageId || !text || !to) {
const missing: string[] = [];
if (!rawMessageId) {
missing.push("messageId (the message ID to reply to)" );
}
if (!text) {
missing.push("text or message (the reply message content)" );
}
if (!to) {
missing.push("to or target (the chat target)" );
}
throw new Error(
`BlueBubbles reply requires: ${missing.join(", " )}. ` +
`Use action=reply with messageId=<message_id>, message=<your reply>, target=<chat_target>.`,
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
requireKnownShortId: true ,
});
const partIndex = readNumberParam(params, "partIndex" , { integer: true });
const result = await runtime.sendMessageBlueBubbles(to, text, {
...opts,
replyToMessageGuid: messageId,
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
});
return jsonResult({ ok: true , messageId: result.messageId, repliedTo: rawMessageId });
}
// Handle sendWithEffect action
if (action === "sendWithEffect" ) {
assertPrivateApiEnabled();
const text = readMessageText(params);
const to = readStringParam(params, "to" ) ?? readStringParam(params, "target" );
const effectId = readStringParam(params, "effectId" ) ?? readStringParam(params, "effect" );
if (!text || !to || !effectId) {
const missing: string[] = [];
if (!text) {
missing.push("text or message (the message content)" );
}
if (!to) {
missing.push("to or target (the chat target)" );
}
if (!effectId) {
missing.push(
"effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)" ,
);
}
throw new Error(
`BlueBubbles sendWithEffect requires: ${missing.join(", " )}. ` +
`Use action=sendWithEffect with message=<message>, target=<chat_target>, effectId=<effect_name>.`,
);
}
const result = await runtime.sendMessageBlueBubbles(to, text, {
...opts,
effectId,
});
return jsonResult({ ok: true , messageId: result.messageId, effect: effectId });
}
// Handle renameGroup action
if (action === "renameGroup" ) {
assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
const displayName = readStringParam(params, "displayName" ) ?? readStringParam(params, "name" );
if (!displayName) {
throw new Error("BlueBubbles renameGroup requires displayName or name parameter." );
}
await runtime.renameBlueBubblesChat(resolvedChatGuid, displayName, opts);
return jsonResult({ ok: true , renamed: resolvedChatGuid, displayName });
}
// Handle setGroupIcon action
if (action === "setGroupIcon" ) {
assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
const base64Buffer = readStringParam(params, "buffer" );
const filename =
readStringParam(params, "filename" ) ?? readStringParam(params, "name" ) ?? "icon.png" ;
const contentType =
readStringParam(params, "contentType" ) ?? readStringParam(params, "mimeType" );
if (!base64Buffer) {
throw new Error(
"BlueBubbles setGroupIcon requires an image. " +
"Use action=setGroupIcon with media=<image_url> or path=<local_file_path> to set the group icon." ,
);
}
// Decode base64 to buffer
const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0 ));
await runtime.setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
...opts,
contentType: contentType ?? undefined,
});
return jsonResult({ ok: true , chatGuid: resolvedChatGuid, iconSet: true });
}
// Handle addParticipant action
if (action === "addParticipant" ) {
assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
const address = readStringParam(params, "address" ) ?? readStringParam(params, "participant" );
if (!address) {
throw new Error("BlueBubbles addParticipant requires address or participant parameter." );
}
await runtime.addBlueBubblesParticipant(resolvedChatGuid, address, opts);
return jsonResult({ ok: true , added: address, chatGuid: resolvedChatGuid });
}
// Handle removeParticipant action
if (action === "removeParticipant" ) {
assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
const address = readStringParam(params, "address" ) ?? readStringParam(params, "participant" );
if (!address) {
throw new Error("BlueBubbles removeParticipant requires address or participant parameter." );
}
await runtime.removeBlueBubblesParticipant(resolvedChatGuid, address, opts);
return jsonResult({ ok: true , removed: address, chatGuid: resolvedChatGuid });
}
// Handle leaveGroup action
if (action === "leaveGroup" ) {
assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
await runtime.leaveBlueBubblesChat(resolvedChatGuid, opts);
return jsonResult({ ok: true , left: resolvedChatGuid });
}
// Handle sendAttachment action (legacy) and upload-file (canonical)
if (action === "sendAttachment" || action === "upload-file" ) {
const to = readStringParam(params, "to" , { required: true });
const filename = readStringParam(params, "filename" , { required: true });
const caption = readStringParam(params, "caption" ) ?? readStringParam(params, "message" );
const contentType =
readStringParam(params, "contentType" ) ?? readStringParam(params, "mimeType" );
const asVoice = readBooleanParam(params, "asVoice" );
// Buffer can come from params.buffer (base64) or params.path (file path)
const base64Buffer = readStringParam(params, "buffer" );
const filePath = readStringParam(params, "path" ) ?? readStringParam(params, "filePath" );
let buffer: Uint8Array;
if (base64Buffer) {
// Decode base64 to buffer
buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0 ));
} else if (filePath) {
// Read file from path (will be handled by caller providing buffer)
throw new Error(
`BlueBubbles ${action}: filePath not supported in action, provide buffer as base64.`,
);
} else {
throw new Error(`BlueBubbles ${action} requires buffer (base64) parameter.`);
}
const result = await runtime.sendBlueBubblesAttachment({
to,
buffer,
filename,
contentType: contentType ?? undefined,
caption: caption ?? undefined,
asVoice: asVoice ?? undefined,
opts,
});
return jsonResult({ ok: true , messageId: result.messageId });
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};
Messung V0.5 in Prozent C=96 H=97 G=96
¤ Dauer der Verarbeitung: 0.13 Sekunden
(vorverarbeitet am 2026-06-07)
¤
*© Formatika GbR, Deutschland