| import crypto from "node:crypto"; |
| import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; |
| import { stripMarkdown } from "openclaw/plugin-sdk/bluebubbles"; |
| import { resolveBlueBubblesAccount } from "./accounts.js"; |
| import { |
| getCachedBlueBubblesPrivateApiStatus, |
| isBlueBubblesPrivateApiStatusEnabled, |
| } from "./probe.js"; |
| import { warnBlueBubbles } from "./runtime.js"; |
| import { normalizeSecretInputString } from "./secret-input.js"; |
| import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; |
| import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; |
| import { |
| blueBubblesFetchWithTimeout, |
| buildBlueBubblesApiUrl, |
| type BlueBubblesSendTarget, |
| } from "./types.js"; |
|
|
| export type BlueBubblesSendOpts = { |
| serverUrl?: string; |
| password?: string; |
| accountId?: string; |
| timeoutMs?: number; |
| cfg?: OpenClawConfig; |
| |
| replyToMessageGuid?: string; |
| |
| replyToPartIndex?: number; |
| |
| effectId?: string; |
| }; |
|
|
| export type BlueBubblesSendResult = { |
| messageId: string; |
| }; |
|
|
| |
| const EFFECT_MAP: Record<string, string> = { |
| |
| slam: "com.apple.MobileSMS.expressivesend.impact", |
| loud: "com.apple.MobileSMS.expressivesend.loud", |
| gentle: "com.apple.MobileSMS.expressivesend.gentle", |
| invisible: "com.apple.MobileSMS.expressivesend.invisibleink", |
| "invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink", |
| "invisible ink": "com.apple.MobileSMS.expressivesend.invisibleink", |
| invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink", |
| |
| echo: "com.apple.messages.effect.CKEchoEffect", |
| spotlight: "com.apple.messages.effect.CKSpotlightEffect", |
| balloons: "com.apple.messages.effect.CKHappyBirthdayEffect", |
| confetti: "com.apple.messages.effect.CKConfettiEffect", |
| love: "com.apple.messages.effect.CKHeartEffect", |
| heart: "com.apple.messages.effect.CKHeartEffect", |
| hearts: "com.apple.messages.effect.CKHeartEffect", |
| lasers: "com.apple.messages.effect.CKLasersEffect", |
| fireworks: "com.apple.messages.effect.CKFireworksEffect", |
| celebration: "com.apple.messages.effect.CKSparklesEffect", |
| }; |
|
|
| function resolveEffectId(raw?: string): string | undefined { |
| if (!raw) { |
| return undefined; |
| } |
| const trimmed = raw.trim().toLowerCase(); |
| if (EFFECT_MAP[trimmed]) { |
| return EFFECT_MAP[trimmed]; |
| } |
| const normalized = trimmed.replace(/[\s_]+/g, "-"); |
| if (EFFECT_MAP[normalized]) { |
| return EFFECT_MAP[normalized]; |
| } |
| const compact = trimmed.replace(/[\s_-]+/g, ""); |
| if (EFFECT_MAP[compact]) { |
| return EFFECT_MAP[compact]; |
| } |
| return raw; |
| } |
|
|
| type PrivateApiDecision = { |
| canUsePrivateApi: boolean; |
| throwEffectDisabledError: boolean; |
| warningMessage?: string; |
| }; |
|
|
| function resolvePrivateApiDecision(params: { |
| privateApiStatus: boolean | null; |
| wantsReplyThread: boolean; |
| wantsEffect: boolean; |
| }): PrivateApiDecision { |
| const { privateApiStatus, wantsReplyThread, wantsEffect } = params; |
| const needsPrivateApi = wantsReplyThread || wantsEffect; |
| const canUsePrivateApi = |
| needsPrivateApi && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); |
| const throwEffectDisabledError = wantsEffect && privateApiStatus === false; |
| if (!needsPrivateApi || privateApiStatus !== null) { |
| return { canUsePrivateApi, throwEffectDisabledError }; |
| } |
| const requested = [ |
| wantsReplyThread ? "reply threading" : null, |
| wantsEffect ? "message effects" : null, |
| ] |
| .filter(Boolean) |
| .join(" + "); |
| return { |
| canUsePrivateApi, |
| throwEffectDisabledError, |
| warningMessage: `Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`, |
| }; |
| } |
|
|
| async function parseBlueBubblesMessageResponse(res: Response): Promise<BlueBubblesSendResult> { |
| const body = await res.text(); |
| if (!body) { |
| return { messageId: "ok" }; |
| } |
| try { |
| const parsed = JSON.parse(body) as unknown; |
| return { messageId: extractBlueBubblesMessageId(parsed) }; |
| } catch { |
| return { messageId: "ok" }; |
| } |
| } |
|
|
| type BlueBubblesChatRecord = Record<string, unknown>; |
|
|
| function extractChatGuid(chat: BlueBubblesChatRecord): string | null { |
| const candidates = [ |
| chat.chatGuid, |
| chat.guid, |
| chat.chat_guid, |
| chat.identifier, |
| chat.chatIdentifier, |
| chat.chat_identifier, |
| ]; |
| for (const candidate of candidates) { |
| if (typeof candidate === "string" && candidate.trim()) { |
| return candidate.trim(); |
| } |
| } |
| return null; |
| } |
|
|
| function extractChatId(chat: BlueBubblesChatRecord): number | null { |
| const candidates = [chat.chatId, chat.id, chat.chat_id]; |
| for (const candidate of candidates) { |
| if (typeof candidate === "number" && Number.isFinite(candidate)) { |
| return candidate; |
| } |
| } |
| return null; |
| } |
|
|
| function extractChatIdentifierFromChatGuid(chatGuid: string): string | null { |
| const parts = chatGuid.split(";"); |
| if (parts.length < 3) { |
| return null; |
| } |
| const identifier = parts[2]?.trim(); |
| return identifier ? identifier : null; |
| } |
|
|
| function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] { |
| const raw = |
| (Array.isArray(chat.participants) ? chat.participants : null) ?? |
| (Array.isArray(chat.handles) ? chat.handles : null) ?? |
| (Array.isArray(chat.participantHandles) ? chat.participantHandles : null); |
| if (!raw) { |
| return []; |
| } |
| const out: string[] = []; |
| for (const entry of raw) { |
| if (typeof entry === "string") { |
| out.push(entry); |
| continue; |
| } |
| if (entry && typeof entry === "object") { |
| const record = entry as Record<string, unknown>; |
| const candidate = |
| (typeof record.address === "string" && record.address) || |
| (typeof record.handle === "string" && record.handle) || |
| (typeof record.id === "string" && record.id) || |
| (typeof record.identifier === "string" && record.identifier); |
| if (candidate) { |
| out.push(candidate); |
| } |
| } |
| } |
| return out; |
| } |
|
|
| async function queryChats(params: { |
| baseUrl: string; |
| password: string; |
| timeoutMs?: number; |
| offset: number; |
| limit: number; |
| }): Promise<BlueBubblesChatRecord[]> { |
| const url = buildBlueBubblesApiUrl({ |
| baseUrl: params.baseUrl, |
| path: "/api/v1/chat/query", |
| password: params.password, |
| }); |
| const res = await blueBubblesFetchWithTimeout( |
| url, |
| { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| limit: params.limit, |
| offset: params.offset, |
| with: ["participants"], |
| }), |
| }, |
| params.timeoutMs, |
| ); |
| if (!res.ok) { |
| return []; |
| } |
| const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null; |
| const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null; |
| return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : []; |
| } |
|
|
| export async function resolveChatGuidForTarget(params: { |
| baseUrl: string; |
| password: string; |
| timeoutMs?: number; |
| target: BlueBubblesSendTarget; |
| }): Promise<string | null> { |
| if (params.target.kind === "chat_guid") { |
| return params.target.chatGuid; |
| } |
|
|
| const normalizedHandle = |
| params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : ""; |
| const targetChatId = params.target.kind === "chat_id" ? params.target.chatId : null; |
| const targetChatIdentifier = |
| params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null; |
|
|
| const limit = 500; |
| let participantMatch: string | null = null; |
| for (let offset = 0; offset < 5000; offset += limit) { |
| const chats = await queryChats({ |
| baseUrl: params.baseUrl, |
| password: params.password, |
| timeoutMs: params.timeoutMs, |
| offset, |
| limit, |
| }); |
| if (chats.length === 0) { |
| break; |
| } |
| for (const chat of chats) { |
| if (targetChatId != null) { |
| const chatId = extractChatId(chat); |
| if (chatId != null && chatId === targetChatId) { |
| return extractChatGuid(chat); |
| } |
| } |
| if (targetChatIdentifier) { |
| const guid = extractChatGuid(chat); |
| if (guid) { |
| |
| if (guid === targetChatIdentifier) { |
| return guid; |
| } |
|
|
| |
| |
| const guidIdentifier = extractChatIdentifierFromChatGuid(guid); |
| if (guidIdentifier && guidIdentifier === targetChatIdentifier) { |
| return guid; |
| } |
| } |
|
|
| const identifier = |
| typeof chat.identifier === "string" |
| ? chat.identifier |
| : typeof chat.chatIdentifier === "string" |
| ? chat.chatIdentifier |
| : typeof chat.chat_identifier === "string" |
| ? chat.chat_identifier |
| : ""; |
| if (identifier && identifier === targetChatIdentifier) { |
| return guid ?? extractChatGuid(chat); |
| } |
| } |
| if (normalizedHandle) { |
| const guid = extractChatGuid(chat); |
| const directHandle = guid ? extractHandleFromChatGuid(guid) : null; |
| if (directHandle && directHandle === normalizedHandle) { |
| return guid; |
| } |
| if (!participantMatch && guid) { |
| |
| |
| |
| const isDmChat = guid.includes(";-;"); |
| if (isDmChat) { |
| const participants = extractParticipantAddresses(chat).map((entry) => |
| normalizeBlueBubblesHandle(entry), |
| ); |
| if (participants.includes(normalizedHandle)) { |
| participantMatch = guid; |
| } |
| } |
| } |
| } |
| } |
| } |
| return participantMatch; |
| } |
|
|
| |
| |
| |
| |
| async function createNewChatWithMessage(params: { |
| baseUrl: string; |
| password: string; |
| address: string; |
| message: string; |
| timeoutMs?: number; |
| }): Promise<BlueBubblesSendResult> { |
| const url = buildBlueBubblesApiUrl({ |
| baseUrl: params.baseUrl, |
| path: "/api/v1/chat/new", |
| password: params.password, |
| }); |
| const payload = { |
| addresses: [params.address], |
| message: params.message, |
| tempGuid: `temp-${crypto.randomUUID()}`, |
| }; |
| const res = await blueBubblesFetchWithTimeout( |
| url, |
| { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify(payload), |
| }, |
| params.timeoutMs, |
| ); |
| if (!res.ok) { |
| const errorText = await res.text(); |
| |
| if ( |
| res.status === 400 || |
| res.status === 403 || |
| errorText.toLowerCase().includes("private api") |
| ) { |
| throw new Error( |
| `BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`, |
| ); |
| } |
| throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`); |
| } |
| return parseBlueBubblesMessageResponse(res); |
| } |
|
|
| export async function sendMessageBlueBubbles( |
| to: string, |
| text: string, |
| opts: BlueBubblesSendOpts = {}, |
| ): Promise<BlueBubblesSendResult> { |
| const trimmedText = text ?? ""; |
| if (!trimmedText.trim()) { |
| throw new Error("BlueBubbles send requires text"); |
| } |
| |
| const strippedText = stripMarkdown(trimmedText); |
| if (!strippedText.trim()) { |
| throw new Error("BlueBubbles send requires text (message was empty after markdown removal)"); |
| } |
|
|
| const account = resolveBlueBubblesAccount({ |
| cfg: opts.cfg ?? {}, |
| accountId: opts.accountId, |
| }); |
| const baseUrl = |
| normalizeSecretInputString(opts.serverUrl) || |
| normalizeSecretInputString(account.config.serverUrl); |
| const password = |
| normalizeSecretInputString(opts.password) || |
| normalizeSecretInputString(account.config.password); |
| if (!baseUrl) { |
| throw new Error("BlueBubbles serverUrl is required"); |
| } |
| if (!password) { |
| throw new Error("BlueBubbles password is required"); |
| } |
| const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId); |
|
|
| const target = resolveBlueBubblesSendTarget(to); |
| const chatGuid = await resolveChatGuidForTarget({ |
| baseUrl, |
| password, |
| timeoutMs: opts.timeoutMs, |
| target, |
| }); |
| if (!chatGuid) { |
| |
| |
| if (target.kind === "handle") { |
| return createNewChatWithMessage({ |
| baseUrl, |
| password, |
| address: target.address, |
| message: strippedText, |
| timeoutMs: opts.timeoutMs, |
| }); |
| } |
| throw new Error( |
| "BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.", |
| ); |
| } |
| const effectId = resolveEffectId(opts.effectId); |
| const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim()); |
| const wantsEffect = Boolean(effectId); |
| const privateApiDecision = resolvePrivateApiDecision({ |
| privateApiStatus, |
| wantsReplyThread, |
| wantsEffect, |
| }); |
| if (privateApiDecision.throwEffectDisabledError) { |
| throw new Error( |
| "BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.", |
| ); |
| } |
| if (privateApiDecision.warningMessage) { |
| warnBlueBubbles(privateApiDecision.warningMessage); |
| } |
| const payload: Record<string, unknown> = { |
| chatGuid, |
| tempGuid: crypto.randomUUID(), |
| message: strippedText, |
| }; |
| if (privateApiDecision.canUsePrivateApi) { |
| payload.method = "private-api"; |
| } |
|
|
| |
| if (wantsReplyThread && privateApiDecision.canUsePrivateApi) { |
| payload.selectedMessageGuid = opts.replyToMessageGuid; |
| payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0; |
| } |
|
|
| |
| if (effectId && privateApiDecision.canUsePrivateApi) { |
| payload.effectId = effectId; |
| } |
|
|
| const url = buildBlueBubblesApiUrl({ |
| baseUrl, |
| path: "/api/v1/message/text", |
| password, |
| }); |
| const res = await blueBubblesFetchWithTimeout( |
| url, |
| { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify(payload), |
| }, |
| opts.timeoutMs, |
| ); |
| if (!res.ok) { |
| const errorText = await res.text(); |
| throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`); |
| } |
| return parseBlueBubblesMessageResponse(res); |
| } |
|
|